876 lines
36 KiB
Python
876 lines
36 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections import defaultdict
|
||
|
|
from datetime import date, datetime, time, timedelta
|
||
|
|
from typing import Any
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
from .config import GeneratorConfig
|
||
|
|
from .constraints import validate_schedule_constraints
|
||
|
|
from .models import DatasetBundle, stable_hash, stable_id
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_date(value: Any, fallback: date) -> date:
|
||
|
|
text = str(value or "")[:10]
|
||
|
|
try:
|
||
|
|
return date.fromisoformat(text)
|
||
|
|
except ValueError:
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_datetime(value: Any, fallback: datetime) -> datetime:
|
||
|
|
if isinstance(value, datetime):
|
||
|
|
parsed = value
|
||
|
|
else:
|
||
|
|
text = str(value or "")
|
||
|
|
try:
|
||
|
|
parsed = datetime.fromisoformat(text)
|
||
|
|
except ValueError:
|
||
|
|
parsed = datetime.combine(_parse_date(text, fallback.date()), fallback.timetz())
|
||
|
|
if parsed.tzinfo is None:
|
||
|
|
return parsed.replace(tzinfo=fallback.tzinfo)
|
||
|
|
return parsed.astimezone(fallback.tzinfo)
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_clock(value: Any, fallback: time) -> time:
|
||
|
|
try:
|
||
|
|
return time.fromisoformat(str(value or ""))
|
||
|
|
except ValueError:
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
|
||
|
|
def _calendar_profile(bundle: DatasetBundle, calendar_id: str | None) -> tuple[set[int], list[tuple[time, time]]]:
|
||
|
|
calendars = {str(row["calendarId"]): row for row in bundle.rows("calendars")}
|
||
|
|
calendar = calendars.get(str(calendar_id or ""), {})
|
||
|
|
working_days = {int(value) for value in calendar.get("workingDays") or [1, 2, 3, 4, 5]}
|
||
|
|
shifts = sorted(
|
||
|
|
[row for row in bundle.rows("shifts") if str(row.get("calendarId")) == str(calendar_id)],
|
||
|
|
key=lambda row: (str(row.get("startTime") or ""), str(row.get("shiftId") or "")),
|
||
|
|
)
|
||
|
|
intervals = [
|
||
|
|
(_parse_clock(row.get("startTime"), time(8)), _parse_clock(row.get("endTime"), time(16)))
|
||
|
|
for row in shifts
|
||
|
|
if row.get("status", "ACTIVE") == "ACTIVE"
|
||
|
|
]
|
||
|
|
return working_days, intervals or [(time(8), time(16))]
|
||
|
|
|
||
|
|
|
||
|
|
def _daily_span(day: date, profile: tuple[set[int], list[tuple[time, time]]], tz: ZoneInfo) -> tuple[datetime, datetime] | None:
|
||
|
|
working_days, intervals = profile
|
||
|
|
if day.isoweekday() not in working_days:
|
||
|
|
return None
|
||
|
|
starts: list[datetime] = []
|
||
|
|
ends: list[datetime] = []
|
||
|
|
for start_clock, end_clock in intervals:
|
||
|
|
start = datetime.combine(day, start_clock, tzinfo=tz)
|
||
|
|
end = datetime.combine(day, end_clock, tzinfo=tz)
|
||
|
|
if end <= start:
|
||
|
|
end += timedelta(days=1)
|
||
|
|
starts.append(start)
|
||
|
|
ends.append(end)
|
||
|
|
return min(starts), max(ends)
|
||
|
|
|
||
|
|
|
||
|
|
def _pair_window(
|
||
|
|
day: date,
|
||
|
|
resource_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
team_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
tz: ZoneInfo,
|
||
|
|
) -> tuple[datetime, datetime] | None:
|
||
|
|
resource_span = _daily_span(day, resource_profile, tz)
|
||
|
|
team_span = _daily_span(day, team_profile, tz)
|
||
|
|
if resource_span is None or team_span is None:
|
||
|
|
return None
|
||
|
|
start = max(resource_span[0], team_span[0])
|
||
|
|
end = min(resource_span[1], team_span[1])
|
||
|
|
return (start, end) if end > start else None
|
||
|
|
|
||
|
|
|
||
|
|
def _place_within_calendar(
|
||
|
|
earliest: datetime,
|
||
|
|
duration_hours: float,
|
||
|
|
resource_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
team_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
horizon_end: date,
|
||
|
|
tz: ZoneInfo,
|
||
|
|
) -> tuple[datetime, datetime]:
|
||
|
|
cursor = earliest.astimezone(tz)
|
||
|
|
for _ in range(max(1, (horizon_end - cursor.date()).days + 2)):
|
||
|
|
span = _pair_window(cursor.date(), resource_profile, team_profile, tz)
|
||
|
|
if span is not None:
|
||
|
|
start = max(cursor, span[0])
|
||
|
|
end = start + timedelta(hours=duration_hours)
|
||
|
|
if end <= span[1]:
|
||
|
|
return start, end
|
||
|
|
cursor = datetime.combine(cursor.date() + timedelta(days=1), time(0), tzinfo=tz)
|
||
|
|
if cursor.date() > horizon_end:
|
||
|
|
break
|
||
|
|
raise ValueError(f"operation cannot be placed inside horizon ending {horizon_end.isoformat()}")
|
||
|
|
|
||
|
|
|
||
|
|
def _time_fence(start: datetime, config: GeneratorConfig) -> str:
|
||
|
|
if start.date() <= config.planning_base_date + timedelta(days=13):
|
||
|
|
return "FROZEN"
|
||
|
|
if start.date() <= config.planning_base_date + timedelta(days=41):
|
||
|
|
return "SEMI_FROZEN"
|
||
|
|
if start.date() >= config.planning_base_date + timedelta(days=181):
|
||
|
|
return "STRATEGIC"
|
||
|
|
return "FREE"
|
||
|
|
|
||
|
|
|
||
|
|
def _resource_weekly_capacity(bundle: DatasetBundle, resource: dict[str, Any]) -> float:
|
||
|
|
profile = _calendar_profile(bundle, resource.get("shiftCalendarId"))
|
||
|
|
daily_hours = 0.0
|
||
|
|
for start_clock, end_clock in profile[1]:
|
||
|
|
start = datetime.combine(date(2000, 1, 3), start_clock)
|
||
|
|
end = datetime.combine(date(2000, 1, 3), end_clock)
|
||
|
|
if end <= start:
|
||
|
|
end += timedelta(days=1)
|
||
|
|
daily_hours += (end - start).total_seconds() / 3600
|
||
|
|
return round(max(1.0, len(profile[0]) * daily_hours), 2)
|
||
|
|
|
||
|
|
|
||
|
|
def _conflict_rows(config: GeneratorConfig, slots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
|
|
kinds = (
|
||
|
|
"RESOURCE_BOTTLENECK_RISK", "MATERIAL_READINESS_RISK", "SUPPLIER_CAPACITY_RISK",
|
||
|
|
"WEATHER_WINDOW_RISK", "QUALITY_HOLD_RISK", "DOCK_WINDOW_RISK", "TEAM_SKILL_RISK",
|
||
|
|
"FREEZE_CHANGE_RISK", "TRANSPORT_WINDOW_RISK", "DESIGN_RELEASE_RISK",
|
||
|
|
)
|
||
|
|
resolutions = (
|
||
|
|
"有限资源串行化并锁定唯一时间线", "按物料齐套日期后移并保留证据", "使用已批准供应商容量窗口",
|
||
|
|
"限定在开放天气窗口内", "质量放行后才允许开工", "船坞资源独占并消除重叠",
|
||
|
|
"仅分配技能和人数均满足的班组", "冻结区保持基线开始时间", "运输和回厂周期纳入最早开工时间",
|
||
|
|
"仅对已释放设计生成正式工单",
|
||
|
|
)
|
||
|
|
rows: list[dict[str, Any]] = []
|
||
|
|
for index in range(config.profile.risk_conflict_count):
|
||
|
|
slot = slots[index % len(slots)]
|
||
|
|
kind = kinds[index % len(kinds)]
|
||
|
|
rows.append({
|
||
|
|
"conflictId": stable_id("conflict", "baseline", index + 1, kind, prefix="CFL"),
|
||
|
|
"scenarioId": "baseline", "scheduleVersionId": slot["scheduleVersionId"],
|
||
|
|
"operationId": slot["operationId"], "resourceId": slot["resourceId"],
|
||
|
|
"conflictType": kind, "severity": ("HIGH", "MEDIUM", "LOW")[index % 3],
|
||
|
|
"detected": True, "expected": True, "hardConstraintViolation": False,
|
||
|
|
"resolutionStatus": "RESOLVED", "resolutionAction": resolutions[index % len(resolutions)],
|
||
|
|
"evidenceRefs": [f"schedule-slot:{slot['scheduleSlotId']}", f"operation:{slot['operationId']}"],
|
||
|
|
})
|
||
|
|
return rows
|
||
|
|
|
||
|
|
|
||
|
|
def _window_datetime(
|
||
|
|
value: Any,
|
||
|
|
fallback: datetime,
|
||
|
|
*,
|
||
|
|
end_of_day: bool = False,
|
||
|
|
) -> datetime | None:
|
||
|
|
if value in (None, ""):
|
||
|
|
return None
|
||
|
|
text = str(value)
|
||
|
|
parsed = _parse_datetime(value, fallback)
|
||
|
|
if "T" not in text and " " not in text and end_of_day:
|
||
|
|
return datetime.combine(parsed.date(), time(23, 59, 59, 999999), tzinfo=fallback.tzinfo)
|
||
|
|
return parsed
|
||
|
|
|
||
|
|
|
||
|
|
def _resource_meets_requirements(
|
||
|
|
resource: dict[str, Any],
|
||
|
|
operation: dict[str, Any],
|
||
|
|
) -> bool:
|
||
|
|
required_group = operation.get("requiredResourceGroup")
|
||
|
|
if required_group and str(resource.get("resourceGroupId") or "") != str(required_group):
|
||
|
|
return False
|
||
|
|
required_zone = operation.get("transportZone")
|
||
|
|
if (
|
||
|
|
operation.get("transportWindowStatus") == "OPEN"
|
||
|
|
and required_zone
|
||
|
|
and str(resource.get("transportZone") or "") != str(required_zone)
|
||
|
|
):
|
||
|
|
return False
|
||
|
|
checks = (
|
||
|
|
("requiredWeightT", "maximumWeight", "maximumWeightT"),
|
||
|
|
("requiredLengthM", "maximumLength", "maximumLengthM"),
|
||
|
|
("requiredWidthM", "maximumWidth", "maximumWidthM"),
|
||
|
|
("requiredHeightM", "maximumHeight", "maximumHeightM"),
|
||
|
|
("requiredLiftRadiusM", "maximumLiftRadiusM", "maximumLiftRadius"),
|
||
|
|
)
|
||
|
|
for requirement_key, *limit_keys in checks:
|
||
|
|
required_value = operation.get(requirement_key)
|
||
|
|
if required_value in (None, ""):
|
||
|
|
continue
|
||
|
|
limit = next(
|
||
|
|
(
|
||
|
|
float(resource[key])
|
||
|
|
for key in limit_keys
|
||
|
|
if resource.get(key) not in (None, "")
|
||
|
|
),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
if limit is None or float(required_value) > limit + 1e-9:
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def _hazards_incompatible(first: str, second: str) -> bool:
|
||
|
|
if not first or not second or first == second:
|
||
|
|
return False
|
||
|
|
return frozenset((first, second)) in {
|
||
|
|
frozenset(("HOT_WORK", "PAINT_VOC")),
|
||
|
|
frozenset(("HOT_WORK", "CONFINED_SPACE")),
|
||
|
|
frozenset(("PAINT_VOC", "CONFINED_SPACE")),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _zone_blocking_end(
|
||
|
|
start: datetime,
|
||
|
|
end: datetime,
|
||
|
|
hazard_class: str,
|
||
|
|
area_units: int,
|
||
|
|
max_area_units: int,
|
||
|
|
occupancy: list[tuple[datetime, datetime, str, int]],
|
||
|
|
) -> datetime | None:
|
||
|
|
overlapping = [row for row in occupancy if start < row[1] and row[0] < end]
|
||
|
|
if not overlapping:
|
||
|
|
return None
|
||
|
|
if any(_hazards_incompatible(hazard_class, row[2]) for row in overlapping):
|
||
|
|
return max(row[1] for row in overlapping)
|
||
|
|
if max_area_units > 0 and area_units + sum(row[3] for row in overlapping) > max_area_units:
|
||
|
|
return max(row[1] for row in overlapping)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _find_feasible_window(
|
||
|
|
earliest: datetime,
|
||
|
|
duration_hours: float,
|
||
|
|
resource_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
team_profile: tuple[set[int], list[tuple[time, time]]],
|
||
|
|
horizon_end: date,
|
||
|
|
timezone: ZoneInfo,
|
||
|
|
maintenance_blackouts: set[str],
|
||
|
|
latest_end: datetime | None,
|
||
|
|
hazard_class: str,
|
||
|
|
area_units: int,
|
||
|
|
max_area_units: int,
|
||
|
|
zone_occupancy: list[tuple[datetime, datetime, str, int]],
|
||
|
|
) -> tuple[datetime, datetime]:
|
||
|
|
cursor = earliest
|
||
|
|
max_attempts = max(32, (horizon_end - cursor.date()).days * 4 + 32)
|
||
|
|
for _ in range(max_attempts):
|
||
|
|
start, end = _place_within_calendar(
|
||
|
|
cursor,
|
||
|
|
duration_hours,
|
||
|
|
resource_profile,
|
||
|
|
team_profile,
|
||
|
|
horizon_end,
|
||
|
|
timezone,
|
||
|
|
)
|
||
|
|
if latest_end is not None and end > latest_end:
|
||
|
|
raise ValueError("candidate exceeds the operation weather/transport window")
|
||
|
|
occupied_dates: set[str] = set()
|
||
|
|
day = start.date()
|
||
|
|
while day <= end.date():
|
||
|
|
occupied_dates.add(day.isoformat())
|
||
|
|
day += timedelta(days=1)
|
||
|
|
if occupied_dates & maintenance_blackouts:
|
||
|
|
next_day = start.date() + timedelta(days=1)
|
||
|
|
cursor = datetime.combine(next_day, time(0), tzinfo=timezone)
|
||
|
|
continue
|
||
|
|
blocking_end = _zone_blocking_end(
|
||
|
|
start,
|
||
|
|
end,
|
||
|
|
hazard_class,
|
||
|
|
area_units,
|
||
|
|
max_area_units,
|
||
|
|
zone_occupancy,
|
||
|
|
)
|
||
|
|
if blocking_end is not None:
|
||
|
|
cursor = max(cursor + timedelta(minutes=1), blocking_end)
|
||
|
|
continue
|
||
|
|
return start, end
|
||
|
|
raise ValueError("no feasible hard-constraint window found before the planning horizon")
|
||
|
|
|
||
|
|
|
||
|
|
def _relation_earliest(
|
||
|
|
operation: dict[str, Any],
|
||
|
|
predecessor: tuple[datetime, datetime] | None,
|
||
|
|
duration_hours: float,
|
||
|
|
base: datetime,
|
||
|
|
) -> datetime:
|
||
|
|
if predecessor is None:
|
||
|
|
return base
|
||
|
|
predecessor_start, predecessor_end = predecessor
|
||
|
|
lag = timedelta(hours=float(operation.get("lagHours") or 0.0))
|
||
|
|
relation_type = str(operation.get("relationType") or "FS").upper()
|
||
|
|
if relation_type == "FS":
|
||
|
|
return predecessor_end + lag
|
||
|
|
if relation_type == "SS":
|
||
|
|
return predecessor_start + lag
|
||
|
|
if relation_type == "FF":
|
||
|
|
return predecessor_end + lag - timedelta(hours=duration_hours)
|
||
|
|
if relation_type == "SF":
|
||
|
|
return predecessor_start + lag - timedelta(hours=duration_hours)
|
||
|
|
raise ValueError(f"unsupported relation type {relation_type}")
|
||
|
|
|
||
|
|
|
||
|
|
def generate_schedule(bundle: DatasetBundle, config: GeneratorConfig) -> DatasetBundle:
|
||
|
|
"""Create an exact deterministic baseline that consumes all generated hard constraints."""
|
||
|
|
active_operations = [
|
||
|
|
row for row in bundle.rows("operations") if row.get("active", True)
|
||
|
|
]
|
||
|
|
operations = sorted(
|
||
|
|
active_operations,
|
||
|
|
key=lambda row: (
|
||
|
|
str(row.get("materialReadyAt") or ""),
|
||
|
|
str(row.get("needDate") or ""),
|
||
|
|
str(row.get("productionOrderId") or ""),
|
||
|
|
int(row.get("sequence") or 0),
|
||
|
|
str(row["operationId"]),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
resources = sorted(
|
||
|
|
[row for row in bundle.rows("resources") if row.get("status", "ACTIVE") == "ACTIVE"],
|
||
|
|
key=lambda row: str(row.get("resourceCode") or row.get("resourceId") or ""),
|
||
|
|
)
|
||
|
|
teams = sorted(
|
||
|
|
[row for row in bundle.rows("teams") if row.get("status", "ACTIVE") == "ACTIVE"],
|
||
|
|
key=lambda row: str(row.get("teamCode") or row.get("teamId") or ""),
|
||
|
|
)
|
||
|
|
if len(operations) != config.profile.schedule_slot_count:
|
||
|
|
raise ValueError("active operation count must equal the exact schedule slot target")
|
||
|
|
if not resources or not teams:
|
||
|
|
raise ValueError("finite scheduling requires non-empty resource and team masters")
|
||
|
|
|
||
|
|
timezone = ZoneInfo(config.timezone)
|
||
|
|
base = datetime.combine(config.planning_base_date, time(6), tzinfo=timezone)
|
||
|
|
horizon_end = datetime.combine(
|
||
|
|
config.planning_horizon_end,
|
||
|
|
time(23, 59, 59),
|
||
|
|
tzinfo=timezone,
|
||
|
|
)
|
||
|
|
schedule_version_id = stable_id(
|
||
|
|
"schedule-version",
|
||
|
|
"baseline",
|
||
|
|
config.seed,
|
||
|
|
config.scale,
|
||
|
|
prefix="SCHV",
|
||
|
|
)
|
||
|
|
input_digest = stable_hash(
|
||
|
|
{
|
||
|
|
"operations": operations,
|
||
|
|
"resources": resources,
|
||
|
|
"teams": teams,
|
||
|
|
"calendars": bundle.rows("calendars"),
|
||
|
|
"shifts": bundle.rows("shifts"),
|
||
|
|
"outsourceSuggestions": bundle.rows("outsource-suggestions"),
|
||
|
|
"planningBaseDate": config.planning_base_date.isoformat(),
|
||
|
|
"planningHorizonEnd": config.planning_horizon_end.isoformat(),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
schedule_version = {
|
||
|
|
"scheduleVersionId": schedule_version_id,
|
||
|
|
"scenarioId": "baseline",
|
||
|
|
"versionNo": "BASELINE-001",
|
||
|
|
"solveStatus": "FEASIBLE",
|
||
|
|
"algorithm": "DETERMINISTIC_SHIPYARD_HARD_CONSTRAINT_HEURISTIC",
|
||
|
|
"algorithmVersion": "2.0.0",
|
||
|
|
"inputDigest": input_digest,
|
||
|
|
"randomSeed": config.seed,
|
||
|
|
"planningBaseDate": config.planning_base_date.isoformat(),
|
||
|
|
"planningHorizonEnd": config.planning_horizon_end.isoformat(),
|
||
|
|
"solveTimeSeconds": 0.0,
|
||
|
|
"optimalityGap": None,
|
||
|
|
"gapType": "NOT_APPLICABLE",
|
||
|
|
"fallbackUsed": False,
|
||
|
|
"hardConstraintCount": 26,
|
||
|
|
"softConstraintCost": 0.0,
|
||
|
|
"immutable": True,
|
||
|
|
"evidenceRefs": [
|
||
|
|
f"input-digest:{input_digest}",
|
||
|
|
"algorithm:DETERMINISTIC_SHIPYARD_HARD_CONSTRAINT_HEURISTIC@2.0.0",
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
resources_by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
|
|
resource_profiles: dict[str, tuple[set[int], list[tuple[time, time]]]] = {}
|
||
|
|
resource_blackouts: dict[str, set[str]] = {}
|
||
|
|
for resource in resources:
|
||
|
|
resource_id = str(resource["resourceId"])
|
||
|
|
resources_by_type[str(resource.get("resourceType") or "")].append(resource)
|
||
|
|
resource_profiles[resource_id] = _calendar_profile(
|
||
|
|
bundle,
|
||
|
|
resource.get("shiftCalendarId"),
|
||
|
|
)
|
||
|
|
resource_blackouts[resource_id] = {
|
||
|
|
str(value)[:10] for value in resource.get("maintenanceBlackoutDates") or []
|
||
|
|
}
|
||
|
|
team_profiles = {
|
||
|
|
str(team["teamId"]): _calendar_profile(bundle, team.get("shiftCalendarId"))
|
||
|
|
for team in teams
|
||
|
|
}
|
||
|
|
team_qualifications: dict[str, set[str]] = defaultdict(set)
|
||
|
|
for employee in bundle.rows("employees"):
|
||
|
|
if employee.get("status", "ACTIVE") != "ACTIVE" or not employee.get("qualificationCode"):
|
||
|
|
continue
|
||
|
|
if _parse_date(employee.get("qualificationValidTo"), config.planning_horizon_end) < config.planning_base_date:
|
||
|
|
continue
|
||
|
|
team_qualifications[str(employee.get("teamId"))].add(
|
||
|
|
str(employee["qualificationCode"])
|
||
|
|
)
|
||
|
|
|
||
|
|
resource_available = {str(row["resourceId"]): base for row in resources}
|
||
|
|
team_available = {str(row["teamId"]): base for row in teams}
|
||
|
|
operation_interval: dict[str, tuple[datetime, datetime]] = {}
|
||
|
|
zone_occupancy: dict[str, list[tuple[datetime, datetime, str, int]]] = defaultdict(list)
|
||
|
|
slots: list[dict[str, Any]] = []
|
||
|
|
operation_by_id = {str(row["operationId"]): row for row in operations}
|
||
|
|
suggestion_by_operation = {
|
||
|
|
str(row["operationId"]): row for row in bundle.rows("outsource-suggestions")
|
||
|
|
}
|
||
|
|
supplier_by_id = {
|
||
|
|
str(row["supplierId"]): row for row in bundle.rows("suppliers")
|
||
|
|
}
|
||
|
|
|
||
|
|
for operation in operations:
|
||
|
|
operation_id = str(operation["operationId"])
|
||
|
|
required_type = str(operation.get("requiredResourceType") or "")
|
||
|
|
required_skills = {
|
||
|
|
str(code) for code in operation.get("requiredSkillCodes") or []
|
||
|
|
}
|
||
|
|
required_qualifications = {
|
||
|
|
str(code) for code in operation.get("requiredQualificationCodes") or []
|
||
|
|
}
|
||
|
|
crew_size = int(operation.get("crewSize") or 1)
|
||
|
|
required_capabilities = {
|
||
|
|
str(value) for value in operation.get("requiredCapabilityTags") or []
|
||
|
|
}
|
||
|
|
compatible_resources = []
|
||
|
|
for resource in resources_by_type.get(required_type, []):
|
||
|
|
tags = {str(value) for value in resource.get("capabilityTags") or []}
|
||
|
|
if not required_capabilities.issubset(tags | {required_type, "FINITE_CAPACITY"}):
|
||
|
|
continue
|
||
|
|
if not _resource_meets_requirements(resource, operation):
|
||
|
|
continue
|
||
|
|
compatible_resources.append(resource)
|
||
|
|
compatible_teams = [
|
||
|
|
team
|
||
|
|
for team in teams
|
||
|
|
if required_skills.issubset(
|
||
|
|
{str(code) for code in team.get("skillCodes") or []}
|
||
|
|
)
|
||
|
|
and required_qualifications.issubset(
|
||
|
|
team_qualifications.get(str(team["teamId"]), set())
|
||
|
|
)
|
||
|
|
and int(team.get("crewSize") or 0) >= crew_size
|
||
|
|
]
|
||
|
|
if not compatible_resources:
|
||
|
|
raise ValueError(
|
||
|
|
f"no finite resource satisfies type/capability/dimension/transport constraints for {operation_id}"
|
||
|
|
)
|
||
|
|
if not compatible_teams:
|
||
|
|
raise ValueError(
|
||
|
|
f"no finite team covers skills/qualifications/crew for {operation_id}"
|
||
|
|
)
|
||
|
|
|
||
|
|
duration = float(operation.get("durationHours") or 0.0)
|
||
|
|
if duration <= 0:
|
||
|
|
raise ValueError(f"operation {operation_id} must have a positive duration")
|
||
|
|
predecessor_id = str(operation.get("predecessorOperationId") or "")
|
||
|
|
predecessor_interval = operation_interval.get(predecessor_id)
|
||
|
|
relation_earliest = _relation_earliest(
|
||
|
|
operation,
|
||
|
|
predecessor_interval,
|
||
|
|
duration,
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
material_ready_date = _parse_date(
|
||
|
|
operation.get("materialReadyAt"),
|
||
|
|
config.planning_base_date,
|
||
|
|
)
|
||
|
|
material_ready = datetime.combine(material_ready_date, time(6), tzinfo=timezone)
|
||
|
|
hold_release = (
|
||
|
|
_parse_datetime(operation.get("holdReleaseAt"), material_ready)
|
||
|
|
if operation.get("holdPoint")
|
||
|
|
else base
|
||
|
|
)
|
||
|
|
|
||
|
|
outsource_return = base
|
||
|
|
suggestion = suggestion_by_operation.get(operation_id)
|
||
|
|
if operation.get("sourcingMode") == "OUTSOURCE":
|
||
|
|
if suggestion is None:
|
||
|
|
raise ValueError(f"outsource operation {operation_id} lacks a suggestion")
|
||
|
|
supplier = supplier_by_id.get(str(suggestion.get("supplierId") or ""))
|
||
|
|
if (
|
||
|
|
supplier is None
|
||
|
|
or not supplier.get("approved", False)
|
||
|
|
or supplier.get("status", "ACTIVE") != "ACTIVE"
|
||
|
|
):
|
||
|
|
raise ValueError(f"outsource operation {operation_id} lacks an approved supplier")
|
||
|
|
if predecessor_interval is None:
|
||
|
|
raise ValueError(f"outsource operation {operation_id} lacks a scheduled predecessor")
|
||
|
|
send_at = max(predecessor_interval[1], material_ready, base)
|
||
|
|
supplier_blackouts = {
|
||
|
|
str(value)[:10] for value in supplier.get("blackoutDates") or []
|
||
|
|
}
|
||
|
|
transport_days = int(suggestion.get("transportDays") or 0)
|
||
|
|
processing_days = int(suggestion.get("processingDays") or 0)
|
||
|
|
inspection_days = int(suggestion.get("inspectionDays") or 0)
|
||
|
|
total_days = transport_days + processing_days + inspection_days
|
||
|
|
while True:
|
||
|
|
candidate_return = send_at + timedelta(days=total_days)
|
||
|
|
if (
|
||
|
|
send_at.date().isoformat() not in supplier_blackouts
|
||
|
|
and candidate_return.date().isoformat() not in supplier_blackouts
|
||
|
|
):
|
||
|
|
break
|
||
|
|
send_at += timedelta(days=1)
|
||
|
|
outsource_return = send_at + timedelta(days=total_days)
|
||
|
|
suggestion.update(
|
||
|
|
{
|
||
|
|
"sendDate": send_at.isoformat(),
|
||
|
|
"returnDate": outsource_return.isoformat(),
|
||
|
|
"capacityBucket": send_at.strftime("%Y-%m"),
|
||
|
|
"supplierCapacityCheck": "PASS",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
operation.update(
|
||
|
|
{
|
||
|
|
"outsourceSendAt": send_at.isoformat(),
|
||
|
|
"outsourceSendDate": send_at.date().isoformat(),
|
||
|
|
"outsourceReturnAt": outsource_return.isoformat(),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
predecessor_return = base
|
||
|
|
predecessor_operation = operation_by_id.get(predecessor_id)
|
||
|
|
if predecessor_operation and predecessor_operation.get("outsourceReturnAt"):
|
||
|
|
predecessor_return = _parse_datetime(
|
||
|
|
predecessor_operation.get("outsourceReturnAt"),
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
earliest = max(
|
||
|
|
base,
|
||
|
|
material_ready,
|
||
|
|
hold_release,
|
||
|
|
relation_earliest,
|
||
|
|
predecessor_return,
|
||
|
|
outsource_return,
|
||
|
|
)
|
||
|
|
latest_end: datetime | None = horizon_end
|
||
|
|
if operation.get("transportWindowStatus") == "OPEN":
|
||
|
|
transport_start = _window_datetime(
|
||
|
|
operation.get("transportWindowStart"),
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
transport_end = _window_datetime(
|
||
|
|
operation.get("transportWindowEnd"),
|
||
|
|
horizon_end,
|
||
|
|
end_of_day=True,
|
||
|
|
)
|
||
|
|
if transport_start is not None:
|
||
|
|
earliest = max(earliest, transport_start)
|
||
|
|
if transport_end is not None:
|
||
|
|
latest_end = min(latest_end, transport_end)
|
||
|
|
if operation.get("weatherSensitive"):
|
||
|
|
if operation.get("weatherWindowStatus") != "OPEN":
|
||
|
|
raise ValueError(f"weather-sensitive operation {operation_id} lacks an open window")
|
||
|
|
weather_start = _window_datetime(
|
||
|
|
operation.get("weatherWindowStart"),
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
weather_end = _window_datetime(
|
||
|
|
operation.get("weatherWindowEnd"),
|
||
|
|
horizon_end,
|
||
|
|
end_of_day=True,
|
||
|
|
)
|
||
|
|
if weather_start is not None:
|
||
|
|
earliest = max(earliest, weather_start)
|
||
|
|
if weather_end is not None:
|
||
|
|
latest_end = min(latest_end, weather_end)
|
||
|
|
|
||
|
|
zone_id = str(operation.get("zoneId") or "")
|
||
|
|
hazard_class = str(operation.get("hazardClass") or "")
|
||
|
|
area_units = int(operation.get("areaDensityUnits") or 0)
|
||
|
|
max_area_units = int(operation.get("maxAreaDensityUnits") or 0)
|
||
|
|
best: tuple[datetime, datetime, str, str] | None = None
|
||
|
|
for resource in compatible_resources:
|
||
|
|
resource_id = str(resource["resourceId"])
|
||
|
|
for team in compatible_teams:
|
||
|
|
team_id = str(team["teamId"])
|
||
|
|
candidate_earliest = max(
|
||
|
|
earliest,
|
||
|
|
resource_available[resource_id],
|
||
|
|
team_available[team_id],
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
start, end = _find_feasible_window(
|
||
|
|
candidate_earliest,
|
||
|
|
duration,
|
||
|
|
resource_profiles[resource_id],
|
||
|
|
team_profiles[team_id],
|
||
|
|
config.planning_horizon_end,
|
||
|
|
timezone,
|
||
|
|
resource_blackouts[resource_id],
|
||
|
|
latest_end,
|
||
|
|
hazard_class,
|
||
|
|
area_units,
|
||
|
|
max_area_units,
|
||
|
|
zone_occupancy.get(zone_id, []),
|
||
|
|
)
|
||
|
|
except ValueError:
|
||
|
|
continue
|
||
|
|
candidate = (end, start, resource_id, team_id)
|
||
|
|
if best is None or candidate < best:
|
||
|
|
best = candidate
|
||
|
|
if best is None:
|
||
|
|
raise AssertionError(
|
||
|
|
f"compatible finite resource/team/window exhausted before horizon for {operation_id} "
|
||
|
|
f"({required_type}, skills={sorted(required_skills)}, qualifications={sorted(required_qualifications)}, "
|
||
|
|
f"ready={earliest.isoformat()})"
|
||
|
|
)
|
||
|
|
end, start, resource_id, team_id = best
|
||
|
|
resource_available[resource_id] = end
|
||
|
|
team_available[team_id] = end
|
||
|
|
operation_interval[operation_id] = (start, end)
|
||
|
|
if operation.get("dockExclusive"):
|
||
|
|
operation["dockId"] = resource_id
|
||
|
|
if operation.get("berthExclusive"):
|
||
|
|
operation["berthId"] = resource_id
|
||
|
|
if operation.get("supportFrameSiteExclusive"):
|
||
|
|
operation["supportFrameSiteId"] = resource_id
|
||
|
|
if zone_id and (hazard_class or area_units):
|
||
|
|
zone_occupancy[zone_id].append(
|
||
|
|
(start, end, hazard_class, area_units)
|
||
|
|
)
|
||
|
|
fence = _time_fence(start, config)
|
||
|
|
operation["timeFence"] = fence
|
||
|
|
operation["frozenBaselineStart"] = start.isoformat() if fence == "FROZEN" else None
|
||
|
|
operation["frozenChangeAuthorized"] = False
|
||
|
|
if operation.get("startedTaskImmutable"):
|
||
|
|
operation.update(
|
||
|
|
{
|
||
|
|
"startedBaselineStart": start.isoformat(),
|
||
|
|
"startedBaselineEnd": end.isoformat(),
|
||
|
|
"startedBaselineResourceId": resource_id,
|
||
|
|
"startedBaselineTeamId": team_id,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
if operation.get("frozenWorkOrderProtected"):
|
||
|
|
operation.update(
|
||
|
|
{
|
||
|
|
"frozenOrderBaselineStart": start.isoformat(),
|
||
|
|
"frozenOrderBaselineEnd": end.isoformat(),
|
||
|
|
"frozenOrderBaselineResourceId": resource_id,
|
||
|
|
"frozenOrderBaselineTeamId": team_id,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
relation_type = str(operation.get("relationType") or "FS")
|
||
|
|
explanation = (
|
||
|
|
f"Scheduled after {relation_type} precedence and "
|
||
|
|
f"{float(operation.get('lagHours') or 0.0):g}h lag; material, design, "
|
||
|
|
"quality, calendars and finite capacity are satisfied. "
|
||
|
|
f"Fence={fence}; resource={resource_id}; team={team_id}."
|
||
|
|
)
|
||
|
|
slots.append(
|
||
|
|
{
|
||
|
|
"scheduleSlotId": stable_id(
|
||
|
|
"schedule-slot",
|
||
|
|
schedule_version_id,
|
||
|
|
operation_id,
|
||
|
|
prefix="SLOT",
|
||
|
|
),
|
||
|
|
"scheduleVersionId": schedule_version_id,
|
||
|
|
"scenarioId": "baseline",
|
||
|
|
"operationId": operation_id,
|
||
|
|
"workOrderId": operation["workOrderId"],
|
||
|
|
"productionOrderId": operation["productionOrderId"],
|
||
|
|
"projectId": operation["projectId"],
|
||
|
|
"wbsId": operation["wbsId"],
|
||
|
|
"zoneId": operation.get("zoneId"),
|
||
|
|
"resourceId": resource_id,
|
||
|
|
"teamId": team_id,
|
||
|
|
"start": start.isoformat(),
|
||
|
|
"end": end.isoformat(),
|
||
|
|
"durationHours": duration,
|
||
|
|
"timeFence": fence,
|
||
|
|
"frozen": fence == "FROZEN",
|
||
|
|
"baselineStart": start.isoformat(),
|
||
|
|
"changeAuthorized": False,
|
||
|
|
"materialReadyAt": material_ready.isoformat(),
|
||
|
|
"outsourceReturnAt": operation.get("outsourceReturnAt"),
|
||
|
|
"holdReleaseAt": hold_release.isoformat() if operation.get("holdPoint") else None,
|
||
|
|
"transportWindowStatus": operation.get("transportWindowStatus"),
|
||
|
|
"weatherWindowStatus": operation.get("weatherWindowStatus"),
|
||
|
|
"hazardClass": operation.get("hazardClass"),
|
||
|
|
"areaDensityUnits": operation.get("areaDensityUnits"),
|
||
|
|
"dockId": operation.get("dockId"),
|
||
|
|
"berthId": operation.get("berthId"),
|
||
|
|
"supportFrameSiteId": operation.get("supportFrameSiteId"),
|
||
|
|
"spatialInterferenceGroup": operation.get("spatialInterferenceGroup"),
|
||
|
|
"status": (
|
||
|
|
"IN_PROGRESS" if operation.get("executionStatus") == "STARTED" else "PLANNED"
|
||
|
|
),
|
||
|
|
"explanation": explanation,
|
||
|
|
"evidenceRefs": [
|
||
|
|
f"operation:{operation_id}",
|
||
|
|
f"resource:{resource_id}",
|
||
|
|
f"team:{team_id}",
|
||
|
|
f"routing:{operation['routingId']}",
|
||
|
|
f"material-ready:{material_ready.date().isoformat()}",
|
||
|
|
f"relation:{relation_type}:{float(operation.get('lagHours') or 0.0)}h",
|
||
|
|
],
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
if len(slots) != config.profile.schedule_slot_count:
|
||
|
|
raise AssertionError(
|
||
|
|
f"exact slot contract failed: {len(slots)} != {config.profile.schedule_slot_count}"
|
||
|
|
)
|
||
|
|
|
||
|
|
loads: dict[tuple[str, date], float] = defaultdict(float)
|
||
|
|
for slot in slots:
|
||
|
|
start = datetime.fromisoformat(str(slot["start"]))
|
||
|
|
bucket = start.date() - timedelta(days=start.date().isoweekday() - 1)
|
||
|
|
loads[(str(slot["resourceId"]), bucket)] += float(slot["durationHours"])
|
||
|
|
resource_by_id = {str(row["resourceId"]): row for row in resources}
|
||
|
|
resource_loads: list[dict[str, Any]] = []
|
||
|
|
for (resource_id, bucket), load_hours in sorted(
|
||
|
|
loads.items(),
|
||
|
|
key=lambda item: (item[0][1], item[0][0]),
|
||
|
|
):
|
||
|
|
capacity_hours = _resource_weekly_capacity(bundle, resource_by_id[resource_id])
|
||
|
|
resource_loads.append(
|
||
|
|
{
|
||
|
|
"resourceLoadId": stable_id(
|
||
|
|
"resource-load",
|
||
|
|
schedule_version_id,
|
||
|
|
resource_id,
|
||
|
|
bucket.isoformat(),
|
||
|
|
prefix="LOAD",
|
||
|
|
),
|
||
|
|
"scheduleVersionId": schedule_version_id,
|
||
|
|
"resourceId": resource_id,
|
||
|
|
"bucketStart": bucket.isoformat(),
|
||
|
|
"bucketType": "ISO_WEEK",
|
||
|
|
"loadHours": round(load_hours, 2),
|
||
|
|
"capacityHours": capacity_hours,
|
||
|
|
"utilization": round(load_hours / capacity_hours, 6),
|
||
|
|
"overloadHours": round(max(0.0, load_hours - capacity_hours), 2),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
conflicts = _conflict_rows(config, slots)
|
||
|
|
first_start = min(datetime.fromisoformat(str(row["start"])) for row in slots)
|
||
|
|
last_end = max(datetime.fromisoformat(str(row["end"])) for row in slots)
|
||
|
|
operation_by_id = {str(row["operationId"]): row for row in operations}
|
||
|
|
on_time = sum(
|
||
|
|
datetime.fromisoformat(str(slot["end"])).date()
|
||
|
|
<= _parse_date(
|
||
|
|
operation_by_id[str(slot["operationId"])].get("needDate"),
|
||
|
|
config.planning_horizon_end,
|
||
|
|
)
|
||
|
|
for slot in slots
|
||
|
|
)
|
||
|
|
explained = sum(
|
||
|
|
bool(slot.get("explanation") and slot.get("evidenceRefs")) for slot in slots
|
||
|
|
)
|
||
|
|
utilization_values = [float(row["utilization"]) for row in resource_loads]
|
||
|
|
kpi_values = (
|
||
|
|
("ACTIVE_OPERATION_COVERAGE", len(slots) / len(operations) * 100, "PERCENT"),
|
||
|
|
("ON_TIME_RATE", on_time / len(slots) * 100, "PERCENT"),
|
||
|
|
("EXPLAINED_DECISION_RATE", explained / len(slots) * 100, "PERCENT"),
|
||
|
|
("HARD_CONSTRAINT_VIOLATIONS", 0, "COUNT"),
|
||
|
|
("UNMARKED_CONFLICTS", 0, "COUNT"),
|
||
|
|
("FROZEN_ILLEGAL_CHANGES", 0, "COUNT"),
|
||
|
|
(
|
||
|
|
"MAKESPAN_DAYS",
|
||
|
|
round((last_end - first_start).total_seconds() / 86400, 4),
|
||
|
|
"DAYS",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"AVERAGE_RESOURCE_UTILIZATION",
|
||
|
|
round(sum(utilization_values) / max(1, len(utilization_values)) * 100, 4),
|
||
|
|
"PERCENT",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"OUTSOURCE_OPERATION_COUNT",
|
||
|
|
sum(row.get("sourcingMode") == "OUTSOURCE" for row in operations),
|
||
|
|
"COUNT",
|
||
|
|
),
|
||
|
|
("SOLVE_STATUS_SCORE", 100, "SCORE"),
|
||
|
|
)
|
||
|
|
kpis = [
|
||
|
|
{
|
||
|
|
"kpiId": stable_id("kpi", schedule_version_id, code, prefix="KPI"),
|
||
|
|
"scheduleVersionId": schedule_version_id,
|
||
|
|
"kpiCode": code,
|
||
|
|
"value": value,
|
||
|
|
"unit": unit,
|
||
|
|
"direction": (
|
||
|
|
"MIN"
|
||
|
|
if code
|
||
|
|
in {
|
||
|
|
"HARD_CONSTRAINT_VIOLATIONS",
|
||
|
|
"UNMARKED_CONFLICTS",
|
||
|
|
"FROZEN_ILLEGAL_CHANGES",
|
||
|
|
"MAKESPAN_DAYS",
|
||
|
|
}
|
||
|
|
else "MAX"
|
||
|
|
),
|
||
|
|
}
|
||
|
|
for code, value, unit in kpi_values
|
||
|
|
]
|
||
|
|
|
||
|
|
bundle.set_rows("schedule-versions", [schedule_version])
|
||
|
|
bundle.set_rows("schedule-slots", slots)
|
||
|
|
bundle.set_rows("resource-loads", resource_loads)
|
||
|
|
bundle.set_rows("conflicts", conflicts)
|
||
|
|
bundle.set_rows("kpis", kpis)
|
||
|
|
constraint_report = validate_schedule_constraints(bundle, config)
|
||
|
|
if not constraint_report["valid"]:
|
||
|
|
raise ValueError(
|
||
|
|
"generated baseline schedule violates hard constraints: "
|
||
|
|
+ "; ".join(constraint_report["blockingIssues"][:8])
|
||
|
|
)
|
||
|
|
schedule_version["hardConstraintCount"] = constraint_report["checkCount"]
|
||
|
|
schedule_version["hardViolationCount"] = constraint_report["hardViolationCount"]
|
||
|
|
schedule_version["unmarkedHardViolationCount"] = constraint_report[
|
||
|
|
"unmarkedHardViolationCount"
|
||
|
|
]
|
||
|
|
bundle.artifacts["algorithmEvidence"] = {
|
||
|
|
"algorithm": schedule_version["algorithm"],
|
||
|
|
"algorithmVersion": schedule_version["algorithmVersion"],
|
||
|
|
"inputDigest": input_digest,
|
||
|
|
"randomSeed": config.seed,
|
||
|
|
"solveStatus": "FEASIBLE",
|
||
|
|
"solveTimeSeconds": 0.0,
|
||
|
|
"optimalityGap": None,
|
||
|
|
"gapType": "NOT_APPLICABLE",
|
||
|
|
"fallbackUsed": False,
|
||
|
|
"hardConstraintCount": constraint_report["checkCount"],
|
||
|
|
"hardViolationCount": 0,
|
||
|
|
"softConstraintCost": 0.0,
|
||
|
|
"explanationCoverage": 1.0,
|
||
|
|
}
|
||
|
|
bundle.artifacts["baseline-results"] = {
|
||
|
|
"scheduleVersionId": schedule_version_id,
|
||
|
|
"solveStatus": "FEASIBLE",
|
||
|
|
"slotCount": len(slots),
|
||
|
|
"resourceLoadCount": len(resource_loads),
|
||
|
|
"detectedExpectedResolvedConflictCount": len(conflicts),
|
||
|
|
"hardViolationCount": 0,
|
||
|
|
"unmarkedHardViolationCount": 0,
|
||
|
|
"constraintReport": constraint_report,
|
||
|
|
}
|
||
|
|
bundle.artifacts["baseline-kpis"] = kpis
|
||
|
|
bundle.artifacts["expected-conflicts"] = conflicts
|
||
|
|
bundle.artifacts["expected-explanations"] = [
|
||
|
|
{
|
||
|
|
"scheduleSlotId": row["scheduleSlotId"],
|
||
|
|
"operationId": row["operationId"],
|
||
|
|
"explanation": row["explanation"],
|
||
|
|
"evidenceRefs": row["evidenceRefs"],
|
||
|
|
}
|
||
|
|
for row in slots
|
||
|
|
]
|
||
|
|
return bundle
|