Commit 06241f058d4410332bf709412bcf4abe68fb39b4

Authored by haribo
1 parent 7a1effdd
Exists in master and in 1 other branch dev

Class scheduler finished <== TO BE TESTED

src/pyros/settings.py
... ... @@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/1.9/ref/settings/
12 12  
13 13 # Set MYSQL to False if you want to use SQLITE
14 14 # This line MUST NOT be changed at all (or install_requirements script will become invalid)
15   -MYSQL = False
  15 +MYSQL = True
16 16  
17 17  
18 18  
... ...
src/pyrosapp/models.py
... ... @@ -267,8 +267,8 @@ class Request(models.Model):
267 267  
268 268 class Schedule(models.Model):
269 269 created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
270   - day_start = models.DateTimeField(blank=True, null=True)
271   - day_stop = models.DateTimeField(blank=True, null=True)
  270 + plan_start = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
  271 + plan_end = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
272 272 flag = models.CharField(max_length=45, blank=True, null=True)
273 273  
274 274 class Meta:
... ... @@ -282,8 +282,8 @@ class Schedule(models.Model):
282 282 class ScheduleHistory(models.Model):
283 283 sequences = models.ManyToManyField('Sequence', related_name='schedulehistories')
284 284 created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
285   - day_start = models.DateTimeField(blank=True, null=True)
286   - day_stop = models.DateTimeField(blank=True, null=True)
  285 + plan_start = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
  286 + plan_end = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
287 287 flag = models.CharField(max_length=45, blank=True, null=True)
288 288  
289 289 class Meta:
... ... @@ -310,6 +310,24 @@ class ScientificProgram(models.Model):
310 310 return (str(self.name))
311 311  
312 312 class Sequence(models.Model):
  313 +
  314 + """ Definition of Status enum values """
  315 +
  316 + TOBEPLANNED = "TBP"
  317 + OBSERVABLE = "OBS"
  318 + UNPLANNABLE = "UNPLN"
  319 + PLANNED = "PLND"
  320 + EXECUTED = "EXD"
  321 + REJECTED = "RJTD"
  322 + STATUS_CHOICES = (
  323 + (TOBEPLANNED, "To be planned"),
  324 + (OBSERVABLE, "Observable"),
  325 + (UNPLANNABLE, "Unplannable"),
  326 + (PLANNED, "Planned"),
  327 + (EXECUTED, "Executed"),
  328 + (REJECTED, "Rejected")
  329 + )
  330 +
313 331 request = models.ForeignKey(Request, models.CASCADE, related_name="sequences")
314 332 sequencetype = models.ForeignKey('SequenceType', models.DO_NOTHING, related_name="sequences")
315 333 schedule = models.ForeignKey(Schedule, models.DO_NOTHING, related_name="sequences")
... ... @@ -318,14 +336,11 @@ class Sequence(models.Model):
318 336 created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
319 337 updated = models.DateTimeField(blank=True, null=True, auto_now=True)
320 338 is_alert = models.BooleanField(default=False)
321   - status = models.CharField(max_length=11, blank=True, null=True)
322   - duration = models.FloatField(blank=True, null=True)
  339 + status = models.CharField(max_length=11, blank=True, null=True, choices=STATUS_CHOICES)
323 340 pointing = models.CharField(max_length=45, blank=True, null=True)
324 341 with_drift = models.BooleanField(default=False)
325 342 priority = models.IntegerField(blank=True, null=True)
326 343 analysis_method = models.CharField(max_length=45, blank=True, null=True)
327   - exec_start = models.DateTimeField()
328   - exec_stop = models.DateTimeField(blank=True, null=True)
329 344 moon_min = models.IntegerField(blank=True, null=True)
330 345 alt_min = models.IntegerField(blank=True, null=True)
331 346 type = models.CharField(max_length=6, blank=True, null=True)
... ... @@ -335,6 +350,14 @@ class Sequence(models.Model):
335 350 obsolete = models.BooleanField(default=False)
336 351 processing = models.BooleanField(default=False)
337 352 flag = models.CharField(max_length=45, blank=True, null=True)
  353 + tsp = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
  354 + tep = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
  355 + jd1 = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
  356 + jd2 = models.DecimalField(default=0.0, max_digits=15, decimal_places=8)
  357 + deltaTL = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
  358 + deltaTR = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
  359 + t_prefered = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
  360 + duration = models.DecimalField(default=-1.0, max_digits=15, decimal_places=8)
338 361  
339 362  
340 363 class Meta:
... ...
src/scheduler/models.py
1 1 from django.db import models
  2 +from pyrosapp.models import Schedule, Sequence
  3 +from operator import attrgetter
  4 +from decimal import *
2 5  
3   -# Create your models here.
  6 +DEFAULT_PLAN_START = 2457485.250000 # April 6th 2016, 18:00:00.0 UT
  7 +DEFAULT_PLAN_END = 2457485.916667 # April 7th 2016, 10:00:00.0 UT
  8 +
  9 +class Interval:
  10 + """
  11 + Simple class that represents an interval of time
  12 + Julian days should be used
  13 + """
  14 +
  15 + def __init__(self, start, end):
  16 + self._start = Decimal(start)
  17 + self._end = Decimal(end)
  18 + self.duration = Decimal(end - start)
  19 +
  20 +
  21 + def __str__(self):
  22 + print("["+str(self.start)+" - "+str(self.end)+"]")
  23 +
  24 + def _set_start(self, start):
  25 + if start > self._end:
  26 + raise ValueError("Cannot set start (%d): must be lower than end (%d)" % (start, self._end))
  27 + self._start = start
  28 + self.duration = self._end - self._start
  29 +
  30 + def _set_end(self, end):
  31 + if end < self._start:
  32 + raise ValueError("Cannot set end (%d): must be bigger than start (%d)" % (end, self._start))
  33 + self._end = end
  34 + self.duration = self._end - self._start
  35 +
  36 + start = property(_set_start)
  37 + end = property(_set_end)
  38 +
  39 +class Scheduler():
  40 + """
  41 + Role : create a planning for the following/current night
  42 +
  43 + Read in DB : Sequence, PyrosUser, ScheduleHistory and parents
  44 + Create in DB : Schedule, ScheduleHistory ? (not sure)
  45 + Update in DB : Schedule, Sequence
  46 + Delete in DB : None
  47 +
  48 + Entry point(s) :
  49 + - make_schedule
  50 + """
  51 +
  52 + """
  53 + TODO:
  54 + - voir TODOs dans les commentaires
  55 + - fonction de scheduling ( et descendance)
  56 + - gestion du re-scheduling (en cas de nouvelle requete)
  57 + - tests
  58 + - remplissage des espaces libres
  59 + - gestion de l'historique (je sais pas si c'est ce module qui va s'en charger au final mais bon ...)
  60 +
  61 + """
  62 +
  63 + def __init__(self):
  64 + self.schedule = Schedule()
  65 + # TODO: quel est le "flag" dans le schedule ??
  66 + # il faudrait peut-être appeler ces deux fonctions dans la méthode make_schedule ... ou recevoir plan_start et plan_end en param
  67 + self.get_night_limits() # TODO
  68 + self.intervals = [Interval(self.schedule.plan_start, self.schedule.plan_end) ,]
  69 +
  70 +
  71 + def get_night_limits(self):
  72 + '''
  73 + determines and set plan_start and plan_end (beginning & end of the observation night)
  74 + '''
  75 +
  76 + # TODO: définir comment on calcule plan_start et plan_end (via quels moyens)
  77 + self.schedule.plan_start = DEFAULT_PLAN_START # default value
  78 + self.schedule.plan_end = DEFAULT_PLAN_END # default value
  79 +
  80 +
  81 + def make_schedule(self):
  82 + '''
  83 + ENTRY POINT
  84 +
  85 + Check all 'OBSERVABLE' sequences to create the most optimized planning for the following/current night
  86 +
  87 + It is assumed that all sequences that MUST and CAN be analyse have the OBSERVABLE status (e.g. : there must not be any PLANNED sequence at this point)
  88 +
  89 + :side-effect :
  90 + - modify sequences status and dates in DB
  91 + '''
  92 +
  93 + self.sequences = list(Sequence.objects.filter(status=Sequence.OBSERVABLE))
  94 + self.determine_priorities()
  95 + self.remove_not_eligible_sequences()
  96 + self.sort_by_jd2_and_priorities()
  97 + self.organize_sequences()
  98 + self.save_sequences()
  99 +
  100 +
  101 + def determine_priorities(self):
  102 + '''
  103 + Computes sequences priority according to the user, the scientific program, ...
  104 + '''
  105 +
  106 + # TODO: définir comment on calcule la priorité
  107 + pass
  108 +
  109 +
  110 + def remove_not_eligible_sequences(self):
  111 + '''
  112 + Computes overlap between [jd1; jd2] and [plan_start; plan_end]
  113 + Removes from self.sequences all the sequences that cannot be observed between plan_start and plan_end
  114 + Set UNPLANNABLE sequences if jd2 < plan_start
  115 +
  116 + :side-effect :
  117 + - remove unwanted sequences from self.sequences
  118 + '''
  119 +
  120 + ''' Note (1) '''
  121 + for sequence in list(self.sequences):
  122 + overlap = min(self.schedule.plan_end, sequence.jd2) - max(self.schedule.plan_end, sequence)
  123 + if overlap < sequence.duration:
  124 + if sequence.jd2 < self.schedule.plan_start:
  125 + """ Note (2) """
  126 + sequence.status = Sequence.UNPLANNABLE
  127 + sequence.save()
  128 + self.sequences.remove(sequence)
  129 +
  130 +
  131 + def sort_by_jd2_and_priorities(self):
  132 + '''
  133 + Sort by priority and jd2, priority being the main sorting parameter
  134 + '''
  135 +
  136 + self.sequences.sort(key=attrgetter('priority', 'jd2'))
  137 +
  138 +
  139 + def organize_sequences(self):
  140 + '''
  141 + Main function of the Scheduler
  142 + Arrange a maximum of observable sequences in the planning
  143 +
  144 + Algorithm (for each sequence) :
  145 + - check quota (remove sequence from list if quota is too low)
  146 + - select matching intervals
  147 + - IF matching intervals => place sequence according to tPrefered
  148 + - IF NO matching intervals => try to move other sequences to place this one
  149 +
  150 + :side-effect :
  151 + - remove unwanted sequences from self.sequences
  152 + - change status and dates of sequences in self.sequences (but not in DB yet)
  153 + '''
  154 +
  155 + ''' Note (1) '''
  156 + for sequence in list(self.sequences):
  157 + quota = self.determine_quota(sequence)
  158 + if quota < sequence.duration:
  159 + self.sequences.remove(sequence)
  160 + continue
  161 +
  162 + matching_intervals = self.get_matching_intervals(sequence)
  163 + if len(matching_intervals) > 0:
  164 + self.place_sequence(sequence, matching_intervals)
  165 + sequence_placed = True
  166 + else:
  167 + sequence_placed = self.try_shifting_sequences(sequence)
  168 +
  169 + if sequence_placed == True:
  170 + sequence.status = Sequence.PLANNED
  171 + self.update_quota(sequence)
  172 +
  173 + def determine_quota(self, sequence:Sequence) -> float:
  174 + '''
  175 + Determines the quota (in minutes) according to the current planning duration and the quota of the user and scientific program associated
  176 +
  177 + :returns : The quota (float)
  178 + '''
  179 + # TODO: définir comment on calcule le quota
  180 +
  181 + return sequence.request.pyros_user.quota # default value
  182 +
  183 +
  184 + def get_matching_intervals(self, sequence:Sequence):
  185 + '''
  186 + Find the intervals where the sequence could be inserted
  187 +
  188 + :returns : list of matching Intervals
  189 + '''
  190 +
  191 + matching_intervals = []
  192 +
  193 + for interval in self.intervals:
  194 + overlap = min(sequence.jd2, interval.end) - max(sequence.jd1, interval.start)
  195 + if overlap >= sequence.duration:
  196 + matching_intervals.append(interval)
  197 +
  198 + return matching_intervals
  199 +
  200 +
  201 + def place_sequence(self, sequence:Sequence, matching_intervals) :
  202 + '''
  203 + Place the sequence in the better interval, according to the t_prefered
  204 +
  205 + :type matching_intervals: list [Interval]
  206 + :param matching_intervals: Intervals in which the sequence can be placed
  207 +
  208 + :side-effect :
  209 + - changes self.intervals
  210 + - change the sequence if it it placed
  211 + '''
  212 +
  213 + if len(matching_intervals) == 0:
  214 + raise ValueError("matching_intervals shall not be empty")
  215 +
  216 + prefered_interval = self.get_prefered_interval(sequence, matching_intervals)
  217 + sequence_position_in_interval = self.get_sequence_position_in_interval(sequence, prefered_interval)
  218 + self.insert_sequence_in_interval(sequence, prefered_interval, sequence_position_in_interval)
  219 + self.cut_interval(sequence, prefered_interval)
  220 + self.update_other_deltas(sequence, prefered_interval)
  221 +
  222 +
  223 + def get_prefered_interval(self, sequence:Sequence, matching_intervals) -> Interval:
  224 + '''
  225 + Find the better interval, according to the t_prefered
  226 +
  227 + :type matching_intervals: list [Interval]
  228 + :param matching_intervals: Intervals in which the sequence can be placed
  229 +
  230 + :returns : An Interval that fits sequence.t_prefered at most
  231 + '''
  232 +
  233 + if len(matching_intervals) == 0:
  234 + raise ValueError("matching_intervals shall not be empty")
  235 +
  236 + if sequence.t_prefered == 0:
  237 + prefered_interval = matching_intervals[0]
  238 + else:
  239 + for index, interval in enumerate(matching_intervals):
  240 + if interval.start <= sequence.t_prefered <= interval.end:
  241 + prefered_interval = interval
  242 + break
  243 + elif sequence.t_prefered < interval.start:
  244 + if index == 0:
  245 + prefered_interval = interval
  246 + else:
  247 + prefered_interval = matching_intervals[index - 1]
  248 + break
  249 + return prefered_interval
  250 +
  251 +
  252 + def get_sequence_position_in_interval(self, sequence:Sequence, interval:Interval) -> str:
  253 + '''
  254 + Determines where the sequence will be inserted in the interval, regarding sequence.t_prefered
  255 +
  256 + :returns : A string in ["START", "END", "PREFERED"] describing where the sequence will be inserted in the interval
  257 + '''
  258 +
  259 + if interval.start <= sequence.t_prefered <= interval.end:
  260 + if (sequence.t_prefered - 0.5 * sequence.duration) <= interval.start:
  261 + position_in_interval = "START"
  262 + elif (sequence.t_prefered + 0.5 * sequence.duration) >= interval.end:
  263 + position_in_interval = "END"
  264 + else:
  265 + position_in_interval = "PREFERED"
  266 + else:
  267 + if sequence.t_prefered < interval.start:
  268 + position_in_interval = "START"
  269 + else:
  270 + position_in_interval = "END"
  271 + return position_in_interval
  272 +
  273 +
  274 + def insert_sequence_in_interval(self, sequence:Sequence, interval:Interval, position:str):
  275 + '''
  276 + Inserts the sequence in the interval:
  277 + - sets sequence.tsp and sequence.tep
  278 + - sets sequence.deltaTL and sequence.deltaTR
  279 +
  280 + :param interval: Interval in which the sequence will be inserted
  281 + :param position: String describing where the sequence will be inserted in the interval
  282 +
  283 + :side-effect :
  284 + - modify sequence attributes (tsp, tep, deltaTL, deltaTR)
  285 + '''
  286 +
  287 + if position not in ["START", "END", "PREFERED"]:
  288 + raise ValueError("position must be either 'START', 'END' or 'PREFERED'")
  289 +
  290 + if position == "START":
  291 + sequence.tsp = interval.start
  292 + sequence.tep = interval.start + sequence.duration
  293 + sequence.deltaTL = 0
  294 + sequence.deltaTR = interval.end - sequence.tep
  295 + elif position == "END":
  296 + sequence.tsp = interval.end
  297 + sequence.tep = interval.end - sequence.duration
  298 + sequence.deltaTL = sequence.tsp - interval.start
  299 + sequence.deltaTR = 0
  300 + else:
  301 + sequence.tsp = sequence.t_prefered - 0.5 * sequence.duration
  302 + sequence.tep = sequence.t_prefered + 0.5 * sequence.duration
  303 + sequence.deltaTL = sequence.tsp - interval.start
  304 + sequence.deltaTR = interval.end - sequence.tep
  305 +
  306 +
  307 + def cut_interval(self, sequence:Sequence, interval:Interval):
  308 + '''
  309 + Separates the interval in two parts regarding to the sequence position
  310 + Sorts the interval list in time order
  311 +
  312 + :param interval : the interval in which the sequence was added
  313 +
  314 + :side-effect :
  315 + - removes interval from self.intervals
  316 + - add created intervals to self.intervals
  317 + - sorts self.intervals
  318 + '''
  319 +
  320 + interval_before_sequence = Interval(interval.start, sequence.tsp)
  321 + interval_after_sequence = Interval(sequence.tep, interval.end)
  322 +
  323 + self.intervals.remove(interval)
  324 + if interval_before_sequence.duration > 0:
  325 + self.intervals.append(interval_before_sequence)
  326 + if interval_after_sequence.duration > 0:
  327 + self.intervals.append(interval_after_sequence)
  328 + self.intervals.sort(key=lambda interval: interval.start, reverse=False)
  329 +
  330 +
  331 + def update_other_deltas(self, sequence:Sequence, interval:Interval):
  332 + '''
  333 + Update deltaTL and deltaTR of sequences planned near this sequence
  334 +
  335 + :param interval: Interval in wich the sequence was added
  336 +
  337 + :side-effect :
  338 + - modify deltaTL and deltaTR of sequences before and after the interval
  339 + '''
  340 +
  341 + for sequence_ in self.sequences:
  342 + if sequence_.status == Sequence.PLANNED:
  343 + if sequence_.tep == interval.start:
  344 + sequence_before_interval = sequence_
  345 + elif sequence_.tsp == interval.end:
  346 + sequence_after_interval = sequence_
  347 +
  348 + sequence_before_interval.deltaTR = sequence.tsp - sequence_before_interval.tep
  349 + sequence_after_interval.deltaTL = sequence_after_interval.tsp - sequence.tep
  350 +
  351 +
  352 + def try_shifting_sequences(self, sequence:Sequence) -> bool:
  353 + '''
  354 + Tries to find a place in the planning for the sequence, moving the other sequences
  355 +
  356 + :returns : A boolean -> True if the sequence was placed, False otherwise
  357 +
  358 + :side-effect:
  359 + - might change some sequences' deltaTL and/or deltaTR
  360 + '''
  361 +
  362 + potential_intervals = self.get_potential_intervals()
  363 + for interval in potential_intervals:
  364 + ''' we get the adjacent sequences '''
  365 + for sequence_ in self.sequences:
  366 + if sequence_.status == Sequence.PLANNED:
  367 + if sequence_.tep == interval.start:
  368 + sequence_before_interval = sequence_
  369 + elif sequence_.tsp == interval.end:
  370 + sequence_after_interval = sequence_
  371 +
  372 + available_duration = min(interval.end, sequence.jd2) - max(interval.start, sequence.jd1)
  373 + missing_duration = sequence.duration - available_duration
  374 +
  375 + possible_move_to_left = max(sequence_before_interval.deltaTL, interval.start - sequence.jd1)
  376 + possible_move_to_right = max(sequence_after_interval.deltaTR, sequence.jd2 - interval.end)
  377 +
  378 + if possible_move_to_left >= missing_duration:
  379 + self.move_sequence(sequence_before_interval, missing_duration, "LEFT")
  380 + elif possible_move_to_right >= missing_duration:
  381 + self.move_sequence(sequence_after_interval, missing_duration, "RIGHT")
  382 + elif possible_move_to_left + possible_move_to_right >= missing_duration:
  383 + self.move_sequence(sequence_before_interval, possible_move_to_left, "LEFT")
  384 + self.move_sequence(sequence_after_interval, missing_duration - possible_move_to_left, "RIGHT")
  385 +
  386 + matching_intervals = self.get_matching_intervals(sequence)
  387 + if len(matching_intervals) != 1:
  388 + raise ValueError("There should be one and only one matching interval after shifting")
  389 + self.place_sequence(sequence, matching_intervals)
  390 +
  391 + return False
  392 +
  393 +
  394 + def get_potential_intervals(self, sequence:Sequence):
  395 + '''
  396 + Find the intervals where a part of the sequence could be inserted
  397 +
  398 + :returns : list of partially-matching Intervals
  399 + '''
  400 +
  401 + potential_intervals = []
  402 +
  403 + for interval in self.intervals:
  404 + overlap = min(sequence.jd2, interval.end) - max(sequence.jd1, interval.start)
  405 + if overlap > 0:
  406 + potential_intervals.append(interval)
  407 +
  408 + return potential_intervals
  409 +
  410 +
  411 + def move_sequence(self, sequence:Sequence, time_shift:float, direction:str):
  412 + '''
  413 + Moves the sequence in the wanted direction, decreasing its deltaTL or deltaTR.
  414 +
  415 + :param sequence: sequence to be moved
  416 + :param time_shift: amplitude of the shift
  417 + :param direction: "LEFT" or "RIGHT"
  418 +
  419 + :side-effect :
  420 + - modify the sequence in self.sequences
  421 + - changes the interval before and the interval after the sequence
  422 + '''
  423 +
  424 + if direction not in ["LEFT", "RIGHT"]:
  425 + raise ValueError("direction must be 'LEFT' or 'RIGHT'")
  426 + if time_shift > (sequence.deltaTL if direction == "LEFT" else sequence.deltaTR):
  427 + raise ValueError("Shift value is bigger than deltaT(R/L)")
  428 +
  429 + for interval in self.intervals:
  430 + if interval.end == sequence.jsp:
  431 + interval_before = interval
  432 + elif interval.start == sequence.jep:
  433 + interval_after = interval
  434 +
  435 + if direction == "LEFT":
  436 + interval_before.end -= time_shift
  437 + interval_after.start -= time_shift
  438 + sequence.jsp -= time_shift
  439 + sequence.jep -= time_shift
  440 + sequence.deltaTL -= time_shift
  441 + sequence.deltaTR += time_shift
  442 + else:
  443 + interval_before.end += time_shift
  444 + interval_after.start += time_shift
  445 + sequence.jsp += time_shift
  446 + sequence.jep += time_shift
  447 + sequence.deltaTL += time_shift
  448 + sequence.deltaTR -= time_shift
  449 +
  450 +
  451 + def update_quota(self, sequence:Sequence):
  452 + '''
  453 + Update the quota of the user / scientific program / whatever by substracting the sequence duration to the quotas
  454 +
  455 + :side-effect:
  456 + - Modify User quota in DB
  457 + '''
  458 +
  459 + # TODO: faire les vrais calculs de quota
  460 + user = sequence.request.pyros_user
  461 + user.quota -= sequence.duration # action par défaut qui correspond au code de self.determine_quota
  462 + user.save()
  463 +
  464 + def save_sequences(self):
  465 + '''
  466 + Final function : save in the db all modifications done to sequences
  467 +
  468 + :side-effect :
  469 + - change sequences status and dates in DB
  470 + '''
  471 +
  472 + for sequence in self.sequences:
  473 + sequence.save()
  474 +
  475 +
  476 +
  477 +''' Notes
  478 +
  479 +(1) list(self.sequences) creates a copy in order to modify self.sequences and still iterate on it without unexpected behavior
  480 +(2) UNPLANNABLE is a definitive status meaning that the sequence will never be able to be scheduled
  481 +
  482 +'''
4 483 \ No newline at end of file
... ...