aps-agent/server/shipyard_synthetic/scheduler.py

876 lines
36 KiB
Python
Raw Normal View History

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