from __future__ import annotations from collections import Counter, defaultdict from copy import deepcopy from datetime import datetime, timedelta from math import ceil from typing import Any from .alternatives import _build_schedule_variant, _slot_deltas from .config import GeneratorConfig from .constraints import validate_schedule_constraints from .models import DatasetBundle, stable_id EVENT_TYPES = ( "KEY_STEEL_DELAY_14D", "MAIN_ENGINE_DELAY_30D", "GANTRY_CRANE_FAILURE_7D", "PAINT_SHOP_OUTAGE_5D", "STRONG_WIND_LIFT_SUSPENSION_3D", "OUTSOURCE_CAPACITY_REDUCTION_40PCT", "WELDER_TEAM_SHORTAGE_20PCT", "CRITICAL_SECTION_REWORK", "OWNER_DESIGN_CHANGE", "SISTER_SHIP_PRIORITY_INCREASE", "DOCK_RELEASE_DELAY_10D", "SEA_TRIAL_WINDOW_DELAY", "CABLE_SHORTAGE", "PIPE_PRESSURE_TEST_FAILURE", "URGENT_REPAIR_PROJECT_INSERT", ) EVENT_NAMES_ZH: dict[str, str] = { "KEY_STEEL_DELAY_14D": "关键钢材晚到14天", "MAIN_ENGINE_DELAY_30D": "主机晚到30天", "GANTRY_CRANE_FAILURE_7D": "大型龙门吊故障7天", "PAINT_SHOP_OUTAGE_5D": "涂装房停机5天", "STRONG_WIND_LIFT_SUSPENSION_3D": "连续强风导致吊装暂停3天", "OUTSOURCE_CAPACITY_REDUCTION_40PCT": "委外供应商产能下降40%", "WELDER_TEAM_SHORTAGE_20PCT": "焊工班组缺员20%", "CRITICAL_SECTION_REWORK": "关键分段返工", "OWNER_DESIGN_CHANGE": "船东临时设计变更", "SISTER_SHIP_PRIORITY_INCREASE": "姊妹船优先级提高", "DOCK_RELEASE_DELAY_10D": "船坞释放晚10天", "SEA_TRIAL_WINDOW_DELAY": "试航窗口推迟", "CABLE_SHORTAGE": "电缆到货不齐", "PIPE_PRESSURE_TEST_FAILURE": "管系压力试验失败", "URGENT_REPAIR_PROJECT_INSERT": "新增紧急修船项目占用部分资源", } CANONICAL_FILES = { "material-delay": "KEY_STEEL_DELAY_14D", "crane-failure": "GANTRY_CRANE_FAILURE_7D", "dock-delay": "DOCK_RELEASE_DELAY_10D", "design-change": "OWNER_DESIGN_CHANGE", } _EVENT_PARAMETERS: dict[str, dict[str, Any]] = { "KEY_STEEL_DELAY_14D": {"delayDays": 14, "materialCategory": "KEY_STEEL", "affectedMaterialRatioPercent": 20}, "MAIN_ENGINE_DELAY_30D": {"delayDays": 30, "equipmentCategory": "MAIN_ENGINE", "affectedEquipmentCount": 1}, "GANTRY_CRANE_FAILURE_7D": {"outageDays": 7, "resourceCategory": "GANTRY_CRANE", "capacityLossPercent": 100}, "PAINT_SHOP_OUTAGE_5D": {"outageDays": 5, "resourceCategory": "PAINT_SHOP", "capacityLossPercent": 100}, "STRONG_WIND_LIFT_SUSPENSION_3D": {"suspensionDays": 3, "weatherType": "STRONG_WIND", "liftCapacityLossPercent": 100}, "OUTSOURCE_CAPACITY_REDUCTION_40PCT": {"capacityReductionPercent": 40, "affectedSupplyRatioPercent": 20, "alternativeSupplierQualificationDays": 14}, "WELDER_TEAM_SHORTAGE_20PCT": {"workforceReductionPercent": 20, "teamCategory": "WELDER", "durationDays": 14}, "CRITICAL_SECTION_REWORK": {"reworkRatioPercent": 20, "entityType": "CRITICAL_SECTION", "reinspectionHours": 8}, "OWNER_DESIGN_CHANGE": {"engineeringReworkDays": 14, "affectedBomRatioPercent": 20, "approvalLeadTimeDays": 14}, "SISTER_SHIP_PRIORITY_INCREASE": {"priorityIncrease": 2, "affectedProjectCount": 1, "milestoneProtectionDays": 7}, "DOCK_RELEASE_DELAY_10D": {"delayDays": 10, "dockCapacityLossPercent": 100, "recoveryWindowDays": 14}, "SEA_TRIAL_WINDOW_DELAY": {"windowDelayDays": 7, "windowType": "SEA_TRIAL", "rebookingNoticeHours": 48}, "CABLE_SHORTAGE": {"shortageCategory": "CABLE", "affectedMaterialRatioPercent": 20, "expediteLeadTimeDays": 7}, "PIPE_PRESSURE_TEST_FAILURE": {"testType": "PIPE_PRESSURE", "reworkDays": 7, "reinspectionHours": 8}, "URGENT_REPAIR_PROJECT_INSERT": {"projectType": "URGENT_REPAIR", "priority": 10, "requestedStartWithinDays": 7}, } _EVENT_ACTIONS: dict[str, list[str]] = { "KEY_STEEL_DELAY_14D": ["核对关键钢材缺口", "评估替代料和跨项目调拨", "局部修复受影响工序"], "MAIN_ENGINE_DELAY_30D": ["重算主机到货齐套日", "保护主机安装前置窗口", "重排安装和联调工序"], "GANTRY_CRANE_FAILURE_7D": ["切换兼容起重资源", "复核吊装窗口", "修复相关总组工序"], "PAINT_SHOP_OUTAGE_5D": ["锁定涂装房停机窗口", "评估替代涂装资源", "重排涂装及后续工序"], "STRONG_WIND_LIFT_SUSPENSION_3D": ["暂停受风力影响吊装", "保护已冻结作业", "天气窗口恢复后局部重排"], "OUTSOURCE_CAPACITY_REDUCTION_40PCT": ["重算委外供应商产能", "切换已批准备选供应商", "修复回厂后续工序"], "WELDER_TEAM_SHORTAGE_20PCT": ["补充持证焊工", "调配兼容班组", "调整班次和焊接顺序"], "CRITICAL_SECTION_REWORK": ["创建关键分段返工", "预留独立返工产能", "复检合格后释放"], "OWNER_DESIGN_CHANGE": ["登记船东设计变更", "重算图纸和BOM影响", "评估返工与交期"], "SISTER_SHIP_PRIORITY_INCREASE": ["提高姊妹船项目优先级", "评估跨项目资源冲突", "保护关键里程碑"], "DOCK_RELEASE_DELAY_10D": ["锁定船坞占用窗口", "评估替代船坞", "调整搭载和下水日期"], "SEA_TRIAL_WINDOW_DELAY": ["重订试航窗口", "冻结试航依赖工序", "协调码头和船级社日历"], "CABLE_SHORTAGE": ["核对电缆齐套缺口", "执行替代或催料", "修复敷设和接线工序"], "PIPE_PRESSURE_TEST_FAILURE": ["登记压力试验失败", "生成管系返工任务", "复检通过后释放后续工序"], "URGENT_REPAIR_PROJECT_INSERT": ["评估冻结区影响", "隔离紧急修船资源", "执行跨项目局部重排"], } _COMPARE_FIELDS = ("start", "end", "resourceId", "teamId") _INFEASIBLE_EVENTS = {"DOCK_RELEASE_DELAY_10D", "OWNER_DESIGN_CHANGE"} def _as_datetime(value: Any) -> datetime: return datetime.fromisoformat(str(value)) def _scenario_slots_from_patch( bundle: DatasetBundle, patch_slots: list[dict[str, Any]], schedule_version_id: str, scenario_id: str, ) -> list[dict[str, Any]]: patch_by_operation = { str(row["operationId"]): row for row in patch_slots } slots: list[dict[str, Any]] = [] for baseline in bundle.rows("schedule-slots"): operation_id = str(baseline["operationId"]) row = deepcopy(baseline) patch = patch_by_operation.get(operation_id) if patch: for field in _COMPARE_FIELDS: row[field] = patch.get(field) row["timeFence"] = patch.get("timeFence", row.get("timeFence")) row["scheduleVersionId"] = schedule_version_id row["scenarioId"] = scenario_id row["scheduleSlotId"] = stable_id( "schedule-slot", schedule_version_id, operation_id, prefix="SLOT", ) row["baselineStart"] = baseline.get("start") row["changeAuthorized"] = False row["explanation"] = ( f"Scenario {scenario_id} applies a local repair to the baseline; " f"resource {row.get('resourceId')} and team {row.get('teamId')} changes are recorded, " "then freeze-zone and hard constraints are revalidated." ) row["evidenceRefs"] = [ f"operation:{operation_id}", f"baseline-slot:{baseline.get('scheduleSlotId')}", f"scenario:{scenario_id}", ] slots.append(row) return slots def _build_infeasible_attempt( bundle: DatasetBundle, config: GeneratorConfig, event_type: str, scenario_id: str, schedule_version_id: str, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: baseline_slots = bundle.rows("schedule-slots") operations = { str(row["operationId"]): row for row in bundle.rows("operations") } resources = { str(row["resourceId"]): row for row in bundle.rows("resources") } candidate_slots = _scenario_slots_from_patch( bundle, [], schedule_version_id, scenario_id, ) candidate_by_operation = { str(row["operationId"]): row for row in candidate_slots } target: dict[str, Any] | None = None replacement_start: datetime | None = None if event_type == "DOCK_RELEASE_DELAY_10D": by_resource: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in baseline_slots: if row.get("timeFence") == "FROZEN": continue resource = resources.get(str(row.get("resourceId")), {}) if resource.get("resourceType") == "DOCK": by_resource[str(row.get("resourceId"))].append(row) timelines = [ sorted(rows, key=lambda row: str(row["start"])) for rows in by_resource.values() if len(rows) > 1 ] if timelines: timeline = max(timelines, key=len) target = timeline[1] replacement_start = _as_datetime(timeline[0]["start"]) if target is None: for row in baseline_slots: if row.get("timeFence") == "FROZEN": continue operation = operations[str(row["operationId"])] predecessor_id = operation.get("predecessorOperationId") if predecessor_id and str(predecessor_id) in candidate_by_operation: target = row replacement_start = _as_datetime( candidate_by_operation[str(predecessor_id)]["start"] ) break if target is None or replacement_start is None: target = next( row for row in reversed(baseline_slots) if row.get("timeFence") != "FROZEN" ) replacement_start = datetime.combine( config.planning_horizon_end, datetime.min.time(), tzinfo=_as_datetime(target["start"]).tzinfo, ) + timedelta(hours=23) attempted = candidate_by_operation[str(target["operationId"])] duration = timedelta(hours=float(attempted.get("durationHours") or 0.0)) attempted["start"] = replacement_start.isoformat() attempted["end"] = (replacement_start + duration).isoformat() attempted["explanation"] = ( f"Event {event_type} attempts a move outside the hard-constraint boundary; " "this candidate exists only as an infeasibility proof and must be rejected with a conflict core." ) check_tables = dict(bundle.tables) check_tables["schedule-slots"] = candidate_slots check_bundle = DatasetBundle( metadata=bundle.metadata, tables=check_tables, artifacts=bundle.artifacts, diagnostics=bundle.diagnostics, ) validation = validate_schedule_constraints(check_bundle, config) if validation["valid"]: replacement_start = datetime.combine( config.planning_horizon_end, datetime.min.time(), tzinfo=_as_datetime(target["start"]).tzinfo, ) + timedelta(hours=23) attempted["start"] = replacement_start.isoformat() attempted["end"] = (replacement_start + duration).isoformat() check_tables["schedule-slots"] = candidate_slots validation = validate_schedule_constraints(check_bundle, config) if validation["valid"]: raise RuntimeError(f"{event_type} infeasible fixture was not rejected") return candidate_slots, validation def _actual_diff( deltas: list[dict[str, Any]], validation: dict[str, Any], ) -> dict[str, Any]: field_counts = Counter( field for delta in deltas for field in delta["changedFields"] ) frozen_changes = [ delta for delta in deltas if delta["before"].get("timeFence") == "FROZEN" ] illegal_frozen = sum( row.get("constraint") == "FROZEN_ZONE" for row in validation.get("violations", []) ) delay_hours = [ ( _as_datetime(delta["after"]["end"]) - _as_datetime(delta["before"]["end"]) ).total_seconds() / 3600 for delta in deltas ] changed_hours = sum( float(delta["after"].get("durationHours") or 0.0) for delta in deltas ) return { "changedSlotCount": len(deltas), "beforeRecordCount": len(deltas), "afterRecordCount": len(deltas), "changedFields": sorted(field_counts), "changedFieldCounts": dict(sorted(field_counts.items())), "frozenZoneTouched": bool(frozen_changes), "frozenChangedSlotIds": [ delta["before"].get("scheduleSlotId") for delta in frozen_changes ], "unauthorizedFrozenChangeCount": illegal_frozen, "milestoneImpactDays": max( 0, ceil(max(delay_hours, default=0.0) / 24), ), "costImpactCny": round(changed_hours * 1850.0, 2), "hardConstraintViolationCount": validation["hardViolationCount"], "constraintCheckCount": validation["checkCount"], } def _event_input( event_type: str, index: int, base_schedule_version_id: str | None, deltas: list[dict[str, Any]], diff: dict[str, Any], baseline_frozen_count: int, ) -> dict[str, Any]: before = [delta["before"] for delta in deltas] affected_projects = sorted( {str(row.get("projectId")) for row in before if row.get("projectId")} ) affected_wbs = sorted( {str(row.get("wbsId")) for row in before if row.get("wbsId")} ) affected_orders = sorted( { str(row.get("productionOrderId")) for row in before if row.get("productionOrderId") } ) affected_resources = sorted( {str(row.get("resourceId")) for row in before if row.get("resourceId")} ) affected_teams = sorted( {str(row.get("teamId")) for row in before if row.get("teamId")} ) earliest = min(_as_datetime(row["start"]) for row in before) latest = max(_as_datetime(row["end"]) for row in before) occurred_at = earliest - timedelta(hours=6 + index) return { "synthetic": True, "datasetType": "SYNTHETIC", "occurredAt": occurred_at.isoformat(), "severity": "HIGH" if index % 4 == 0 else "MEDIUM", "affectedProjectIds": affected_projects, "affectedWbsIds": affected_wbs, "affectedProductionOrderIds": affected_orders, "affectedResourceIds": affected_resources, "affectedTeamIds": affected_teams, "parameters": deepcopy(_EVENT_PARAMETERS[event_type]), "originalImpact": { "baseScheduleVersionId": base_schedule_version_id, "affectedWindowStart": earliest.isoformat(), "affectedWindowEnd": latest.isoformat(), "affectedSlotCount": len(deltas), "estimatedCapacityLossHours": round( sum( float(row.get("durationHours") or 0.0) for row in before ), 2, ), "estimatedMilestoneDelayDays": diff["milestoneImpactDays"], }, "recommendedActions": deepcopy(_EVENT_ACTIONS[event_type]), "requiresReschedule": True, "frozenImpact": { "timeFenceField": "timeFence", "protectedFrozenSlotCount": baseline_frozen_count, "touchesFrozenZone": diff["frozenZoneTouched"], "affectedFrozenSlotIds": diff["frozenChangedSlotIds"], "changeAuthorized": False, "unauthorizedFrozenChangeCount": diff[ "unauthorizedFrozenChangeCount" ], "policy": "NO_UNAUTHORIZED_FROZEN_CHANGE", }, } def generate_scenarios( bundle: DatasetBundle, config: GeneratorConfig, ) -> DatasetBundle: baseline_slots = bundle.rows("schedule-slots") version = (bundle.rows("schedule-versions") or [{}])[0] alternatives = bundle.artifacts.get("schedule-alternatives", {}).get( "alternatives", [] ) feasible_variant_cursor = 0 baseline_frozen_count = sum( row.get("timeFence") == "FROZEN" for row in baseline_slots ) events: list[dict[str, Any]] = [] for index, event_type in enumerate(EVENT_TYPES, 1): scenario_id = stable_id( "scenario", event_type, config.seed, prefix="SCN", ) schedule_version_id = stable_id( "schedule-version", "scenario", event_type, config.seed, config.scale, prefix="SCHV", ) if event_type in _INFEASIBLE_EVENTS: candidate_slots, validation = _build_infeasible_attempt( bundle, config, event_type, scenario_id, schedule_version_id, ) accepted = False repair_algorithm = "FAIL_CLOSED_CONFLICT_CORE" else: if feasible_variant_cursor < len(alternatives): source = alternatives[feasible_variant_cursor] candidate_slots = _scenario_slots_from_patch( bundle, source["alternativeSlots"], schedule_version_id, scenario_id, ) else: source = _build_schedule_variant( bundle, config, f"{event_type}-REPAIR", 100 + index, ) candidate_slots = _scenario_slots_from_patch( bundle, [delta["after"] for delta in source["slotDeltas"]], schedule_version_id, scenario_id, ) feasible_variant_cursor += 1 validation = deepcopy(source["constraintValidation"]) if not validation["valid"]: raise RuntimeError( f"scenario {event_type} repair is not feasible: " + "; ".join(validation["blockingIssues"][:3]) ) accepted = True repair_algorithm = "FINITE_CAPACITY_LOCAL_REPAIR" deltas = _slot_deltas(baseline_slots, candidate_slots) if not deltas: raise RuntimeError(f"scenario {event_type} produced no actual slot change") diff = _actual_diff(deltas, validation) if not ( diff["changedSlotCount"] == diff["beforeRecordCount"] == diff["afterRecordCount"] == len(deltas) ): raise AssertionError( f"scenario {event_type} changed slot count is inconsistent" ) if accepted and diff["unauthorizedFrozenChangeCount"]: raise AssertionError( f"scenario {event_type} changed the frozen zone without authorization" ) scenario_input = _event_input( event_type, index, version.get("scheduleVersionId"), deltas, diff, baseline_frozen_count, ) conflict_core = [ { "constraint": row.get("constraint"), "entityId": row.get("entityId"), "message": row.get("message"), } for row in validation.get("violations", []) ] events.append( { "scenarioId": scenario_id, "eventType": event_type, "eventName": EVENT_NAMES_ZH[event_type], "eventSequence": index, "baseScheduleVersionId": version.get("scheduleVersionId"), "afterScheduleVersionId": schedule_version_id, "scheduleVersion": { "scheduleVersionId": schedule_version_id, "baseScheduleVersionId": version.get("scheduleVersionId"), "versionNo": f"SCENARIO-{index:03d}", "scenarioId": scenario_id, "accepted": accepted, "slotInheritance": "PATCH_OVER_BASELINE", }, "input": scenario_input, "before": [delta["before"] for delta in deltas], "after": [delta["after"] for delta in deltas], "diff": diff, "repairAlgorithm": repair_algorithm, "repairAlgorithmVersion": "2.0.0", "solveStatus": validation["solveStatus"], "accepted": accepted, "conflictCore": conflict_core, "reliefSuggestions": ( deepcopy(_EVENT_ACTIONS[event_type]) if not accepted else [] ), "expectedConflictCount": len(conflict_core), "hardConstraintViolationCount": validation[ "hardViolationCount" ], "unmarkedHardViolationCount": 0, "constraintValidation": { "valid": validation["valid"], "solveStatus": validation["solveStatus"], "checkCount": validation["checkCount"], "hardViolationCount": validation["hardViolationCount"], "blockingIssues": validation["blockingIssues"], }, "evidenceRefs": [ "skill:ship-schedule-repair", "KNO-SYN-20-001" ], } ) baseline = { "scenarioId": "BASELINE", "scheduleVersionId": version.get("scheduleVersionId"), "solveStatus": version.get("solveStatus", "FEASIBLE"), "hardConstraintViolations": 0, "unmarkedConflictCount": 0, } bundle.artifacts["scenario-events"] = events bundle.artifacts["baseline"] = baseline for file_name, event_type in CANONICAL_FILES.items(): bundle.artifacts[file_name] = next( row for row in events if row["eventType"] == event_type ) bundle.artifacts["scenario-comparison"] = { "baseline": baseline, "scenarioCount": len(events), "independentScheduleVersionCount": len( {row["afterScheduleVersionId"] for row in events} ), "feasibleCount": sum( row["solveStatus"] == "FEASIBLE" for row in events ), "infeasibleCount": sum( row["solveStatus"] == "INFEASIBLE" for row in events ), "allChangedSlotCountsConsistent": all( row["diff"]["changedSlotCount"] == len(row["before"]) == len(row["after"]) for row in events ), "unauthorizedFrozenChangeCount": sum( row["diff"]["unauthorizedFrozenChangeCount"] for row in events ), "scenarios": [ { "scenarioId": row["scenarioId"], "eventType": row["eventType"], "afterScheduleVersionId": row["afterScheduleVersionId"], "solveStatus": row["solveStatus"], "accepted": row["accepted"], "changedSlotCount": row["diff"]["changedSlotCount"], "changedFields": row["diff"]["changedFields"], "milestoneImpactDays": row["diff"][ "milestoneImpactDays" ], "costImpactCny": row["diff"]["costImpactCny"], } for row in events ], } return bundle