aps-agent/server/shipyard_synthetic/scenarios.py

571 lines
23 KiB
Python

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