| from dataclasses import dataclass, field |
| from datetime import date, timedelta |
| from typing import List, Optional, Annotated, Union, Set |
|
|
| from solverforge_legacy.solver import SolverStatus |
| from solverforge_legacy.solver.score import HardSoftScore |
| from solverforge_legacy.solver.domain import ( |
| planning_entity, |
| planning_solution, |
| PlanningId, |
| PlanningVariable, |
| PlanningEntityCollectionProperty, |
| ProblemFactCollectionProperty, |
| ProblemFactProperty, |
| ValueRangeProvider, |
| PlanningScore, |
| ) |
| from .json_serialization import JsonDomainBase |
| from pydantic import Field |
|
|
|
|
| |
| |
| |
|
|
|
|
| def calculate_end_date(start_date: Optional[date], duration_in_days: int) -> Optional[date]: |
| """ |
| Calculate the end date by adding working days to the start date, skipping weekends. |
| |
| The end date is exclusive (like Java's implementation). |
| |
| Args: |
| start_date: The start date (inclusive) |
| duration_in_days: Number of working days the job takes |
| |
| Returns: |
| The end date (exclusive), or None if start_date is None |
| """ |
| if start_date is None: |
| return None |
|
|
| |
| |
| |
| |
| weekend_padding = 2 * ((duration_in_days + start_date.weekday()) // 5) |
| return start_date + timedelta(days=duration_in_days + weekend_padding) |
|
|
|
|
| def count_working_days_between(start: date, end: date) -> int: |
| """ |
| Count the number of working days (Mon-Fri) between two dates. |
| |
| Args: |
| start: Start date (inclusive) |
| end: End date (exclusive) |
| |
| Returns: |
| Number of working days between the dates |
| """ |
| if start >= end: |
| return 0 |
|
|
| count = 0 |
| current = start |
| while current < end: |
| if current.weekday() < 5: |
| count += 1 |
| current += timedelta(days=1) |
| return count |
|
|
|
|
| def create_start_date_range(from_date: date, to_date: date) -> List[date]: |
| """ |
| Generate a list of working days (Mon-Fri) within the date range. |
| |
| Args: |
| from_date: Start of range (inclusive) |
| to_date: End of range (exclusive) |
| |
| Returns: |
| List of working days in the range |
| """ |
| dates = [] |
| current = from_date |
| while current < to_date: |
| if current.weekday() < 5: |
| dates.append(current) |
| current += timedelta(days=1) |
| return dates |
|
|
|
|
| |
| |
| |
|
|
|
|
| @dataclass |
| class WorkCalendar: |
| """Defines the planning window for the schedule.""" |
| id: Annotated[str, PlanningId] |
| from_date: date |
| to_date: date |
|
|
|
|
| @dataclass |
| class Crew: |
| """A maintenance crew that can be assigned to jobs.""" |
| id: Annotated[str, PlanningId] |
| name: str |
|
|
| def __hash__(self): |
| return hash(self.id) |
|
|
| def __eq__(self, other): |
| if isinstance(other, Crew): |
| return self.id == other.id |
| return False |
|
|
| def __str__(self): |
| return f"{self.name}({self.id})" |
|
|
|
|
| @planning_entity |
| @dataclass |
| class Job: |
| """ |
| A maintenance job that needs to be scheduled. |
| |
| Planning variables: |
| - crew: The crew assigned to this job |
| - start_date: When the job starts (inclusive) |
| |
| The end_date is computed from start_date and duration_in_days. |
| """ |
| id: Annotated[str, PlanningId] |
| name: str |
| duration_in_days: int |
| min_start_date: date |
| max_end_date: date |
| ideal_end_date: date |
| tags: Set[str] = field(default_factory=set) |
|
|
| |
| crew: Annotated[Optional[Crew], PlanningVariable] = None |
| start_date: Annotated[Optional[date], PlanningVariable] = None |
|
|
| def get_end_date(self) -> Optional[date]: |
| """Calculate the end date based on start_date and duration.""" |
| return calculate_end_date(self.start_date, self.duration_in_days) |
|
|
| @property |
| def end_date(self) -> Optional[date]: |
| """End date property for convenient access.""" |
| return self.get_end_date() |
|
|
| def calculate_overlap(self, other: "Job") -> int: |
| """ |
| Calculate the number of overlapping working days with another job. |
| |
| Args: |
| other: The other job to compare with |
| |
| Returns: |
| Number of overlapping working days |
| """ |
| if self.start_date is None or other.start_date is None: |
| return 0 |
|
|
| self_end = self.get_end_date() |
| other_end = other.get_end_date() |
|
|
| if self_end is None or other_end is None: |
| return 0 |
|
|
| |
| overlap_start = max(self.start_date, other.start_date) |
| overlap_end = min(self_end, other_end) |
|
|
| if overlap_start >= overlap_end: |
| return 0 |
|
|
| return count_working_days_between(overlap_start, overlap_end) |
|
|
| def get_common_tags(self, other: "Job") -> Set[str]: |
| """Get the tags that both jobs share.""" |
| return self.tags & other.tags |
|
|
| def __str__(self): |
| return f"{self.name}({self.id})" |
|
|
|
|
| @planning_solution |
| @dataclass |
| class MaintenanceSchedule: |
| """ |
| The planning solution containing the schedule to be optimized. |
| """ |
| work_calendar: Annotated[WorkCalendar, ProblemFactProperty] |
| crews: Annotated[List[Crew], ProblemFactCollectionProperty, ValueRangeProvider] |
| jobs: Annotated[List[Job], PlanningEntityCollectionProperty] |
| start_date_range: Annotated[ |
| List[date], ProblemFactCollectionProperty, ValueRangeProvider |
| ] = field(default_factory=list) |
| score: Annotated[Optional[HardSoftScore], PlanningScore] = None |
| solver_status: SolverStatus = SolverStatus.NOT_SOLVING |
|
|
| def __post_init__(self): |
| """Initialize start_date_range if not provided.""" |
| if not self.start_date_range and self.work_calendar: |
| self.start_date_range = create_start_date_range( |
| self.work_calendar.from_date, |
| self.work_calendar.to_date |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class WorkCalendarModel(JsonDomainBase): |
| id: str |
| from_date: str = Field(..., alias="fromDate") |
| to_date: str = Field(..., alias="toDate") |
|
|
|
|
| class CrewModel(JsonDomainBase): |
| id: str |
| name: str |
|
|
|
|
| class JobModel(JsonDomainBase): |
| id: str |
| name: str |
| duration_in_days: int = Field(..., alias="durationInDays") |
| min_start_date: str = Field(..., alias="minStartDate") |
| max_end_date: str = Field(..., alias="maxEndDate") |
| ideal_end_date: str = Field(..., alias="idealEndDate") |
| tags: List[str] = Field(default_factory=list) |
| crew: Optional[Union[str, CrewModel]] = None |
| start_date: Optional[str] = Field(None, alias="startDate") |
| end_date: Optional[str] = Field(None, alias="endDate") |
|
|
|
|
| class MaintenanceScheduleModel(JsonDomainBase): |
| work_calendar: WorkCalendarModel = Field(..., alias="workCalendar") |
| crews: List[CrewModel] |
| jobs: List[JobModel] |
| start_date_range: List[str] = Field(default_factory=list, alias="startDateRange") |
| score: Optional[str] = None |
| solver_status: Optional[str] = Field(None, alias="solverStatus") |
|
|