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