790 lines
28 KiB
Python
790 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
from collections import Counter, defaultdict
|
||
from copy import deepcopy
|
||
from datetime import date, datetime, timedelta
|
||
from statistics import pstdev
|
||
from typing import Any
|
||
|
||
from .config import GeneratorConfig
|
||
from .constraints import validate_schedule_constraints
|
||
from .models import DatasetBundle, stable_id
|
||
|
||
ALTERNATIVE_MODES = (
|
||
(
|
||
"DELIVERY_FIRST",
|
||
"Delivery First",
|
||
{
|
||
"delivery": 0.45,
|
||
"resource": 0.15,
|
||
"cost": 0.10,
|
||
"overtime": 0.10,
|
||
"dock": 0.10,
|
||
"risk": 0.10,
|
||
},
|
||
),
|
||
(
|
||
"RESOURCE_BALANCED",
|
||
"Resource Balanced",
|
||
{
|
||
"delivery": 0.20,
|
||
"resource": 0.40,
|
||
"cost": 0.10,
|
||
"overtime": 0.10,
|
||
"dock": 0.10,
|
||
"risk": 0.10,
|
||
},
|
||
),
|
||
(
|
||
"COST_FIRST",
|
||
"Cost First",
|
||
{
|
||
"delivery": 0.15,
|
||
"resource": 0.10,
|
||
"cost": 0.45,
|
||
"overtime": 0.10,
|
||
"dock": 0.10,
|
||
"risk": 0.10,
|
||
},
|
||
),
|
||
(
|
||
"MIN_OVERTIME",
|
||
"Minimum Overtime",
|
||
{
|
||
"delivery": 0.15,
|
||
"resource": 0.15,
|
||
"cost": 0.10,
|
||
"overtime": 0.40,
|
||
"dock": 0.10,
|
||
"risk": 0.10,
|
||
},
|
||
),
|
||
(
|
||
"DOCK_UTILIZATION",
|
||
"Dock Utilization First",
|
||
{
|
||
"delivery": 0.15,
|
||
"resource": 0.10,
|
||
"cost": 0.10,
|
||
"overtime": 0.10,
|
||
"dock": 0.45,
|
||
"risk": 0.10,
|
||
},
|
||
),
|
||
(
|
||
"MIN_RISK",
|
||
"Minimum Risk",
|
||
{
|
||
"delivery": 0.15,
|
||
"resource": 0.10,
|
||
"cost": 0.10,
|
||
"overtime": 0.10,
|
||
"dock": 0.10,
|
||
"risk": 0.45,
|
||
},
|
||
),
|
||
(
|
||
"RECOMMENDED",
|
||
"Recommended",
|
||
{
|
||
"delivery": 0.25,
|
||
"resource": 0.15,
|
||
"cost": 0.15,
|
||
"overtime": 0.10,
|
||
"dock": 0.15,
|
||
"risk": 0.20,
|
||
},
|
||
),
|
||
)
|
||
|
||
_COMPARE_FIELDS = ("start", "end", "resourceId", "teamId")
|
||
_SLOT_VIEW_FIELDS = (
|
||
"scheduleSlotId",
|
||
"scheduleVersionId",
|
||
"scenarioId",
|
||
"operationId",
|
||
"workOrderId",
|
||
"productionOrderId",
|
||
"projectId",
|
||
"wbsId",
|
||
"resourceId",
|
||
"teamId",
|
||
"start",
|
||
"end",
|
||
"durationHours",
|
||
"timeFence",
|
||
"baselineStart",
|
||
)
|
||
|
||
|
||
def _as_datetime(value: Any) -> datetime:
|
||
return datetime.fromisoformat(str(value))
|
||
|
||
|
||
def _as_date(value: Any) -> date:
|
||
text = str(value)
|
||
return datetime.fromisoformat(text).date() if "T" in text else date.fromisoformat(text)
|
||
|
||
|
||
def _slot_view(slot: dict[str, Any]) -> dict[str, Any]:
|
||
return {field: slot.get(field) for field in _SLOT_VIEW_FIELDS}
|
||
|
||
|
||
def _slot_deltas(
|
||
baseline_slots: list[dict[str, Any]],
|
||
candidate_slots: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
baseline_by_operation = {
|
||
str(row["operationId"]): row for row in baseline_slots
|
||
}
|
||
deltas: list[dict[str, Any]] = []
|
||
for candidate in candidate_slots:
|
||
operation_id = str(candidate["operationId"])
|
||
baseline = baseline_by_operation[operation_id]
|
||
changed_fields = [
|
||
field
|
||
for field in _COMPARE_FIELDS
|
||
if baseline.get(field) != candidate.get(field)
|
||
]
|
||
if not changed_fields:
|
||
continue
|
||
deltas.append(
|
||
{
|
||
"operationId": operation_id,
|
||
"baselineScheduleSlotId": baseline.get("scheduleSlotId"),
|
||
"alternativeScheduleSlotId": candidate.get("scheduleSlotId"),
|
||
"changedFields": changed_fields,
|
||
"before": _slot_view(baseline),
|
||
"after": _slot_view(candidate),
|
||
}
|
||
)
|
||
return sorted(deltas, key=lambda row: row["operationId"])
|
||
|
||
|
||
def _eligible_order_ids(
|
||
bundle: DatasetBundle,
|
||
config: GeneratorConfig,
|
||
) -> list[str]:
|
||
slots_by_operation = {
|
||
str(row["operationId"]): row for row in bundle.rows("schedule-slots")
|
||
}
|
||
operations_by_order: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
for operation in bundle.rows("operations"):
|
||
if operation.get("active", True):
|
||
operations_by_order[str(operation["productionOrderId"])].append(operation)
|
||
|
||
latest_safe_end = datetime.combine(
|
||
config.planning_horizon_end - timedelta(days=2),
|
||
datetime.min.time(),
|
||
tzinfo=_as_datetime(bundle.rows("schedule-slots")[0]["start"]).tzinfo,
|
||
)
|
||
candidates: list[tuple[datetime, str]] = []
|
||
for order_id, operations in operations_by_order.items():
|
||
order_slots = [
|
||
slots_by_operation[str(operation["operationId"])]
|
||
for operation in operations
|
||
]
|
||
if any(slot.get("timeFence") == "FROZEN" for slot in order_slots):
|
||
continue
|
||
order_end = max(_as_datetime(slot["end"]) for slot in order_slots)
|
||
if order_end >= latest_safe_end:
|
||
continue
|
||
candidates.append((order_end, order_id))
|
||
return [order_id for _, order_id in sorted(candidates)]
|
||
|
||
|
||
def _has_overlap(
|
||
slots: list[dict[str, Any]],
|
||
owner_field: str,
|
||
owner_id: str,
|
||
start: datetime,
|
||
end: datetime,
|
||
ignored_operation_id: str,
|
||
) -> bool:
|
||
for row in slots:
|
||
if str(row.get(owner_field)) != owner_id:
|
||
continue
|
||
if str(row.get("operationId")) == ignored_operation_id:
|
||
continue
|
||
other_start = _as_datetime(row["start"])
|
||
other_end = _as_datetime(row["end"])
|
||
if start < other_end and other_start < end:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _apply_safe_assignment_variation(
|
||
bundle: DatasetBundle,
|
||
slots: list[dict[str, Any]],
|
||
variant_index: int,
|
||
excluded_operation_ids: set[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Apply one deterministic, capacity-safe owner change at unchanged dates."""
|
||
operations = {
|
||
str(row["operationId"]): row for row in bundle.rows("operations")
|
||
}
|
||
resources = {
|
||
str(row["resourceId"]): row for row in bundle.rows("resources")
|
||
}
|
||
teams = {str(row["teamId"]): row for row in bundle.rows("teams")}
|
||
employees_by_team: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
for employee in bundle.rows("employees"):
|
||
employees_by_team[str(employee.get("teamId") or "")].append(employee)
|
||
|
||
resource_intervals: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
|
||
team_intervals: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
|
||
for row in slots:
|
||
interval = (_as_datetime(row["start"]), _as_datetime(row["end"]))
|
||
resource_intervals[str(row.get("resourceId"))].append(interval)
|
||
team_intervals[str(row.get("teamId"))].append(interval)
|
||
for timeline in resource_intervals.values():
|
||
timeline.sort()
|
||
for timeline in team_intervals.values():
|
||
timeline.sort()
|
||
|
||
def owner_is_free(
|
||
timeline: list[tuple[datetime, datetime]],
|
||
start: datetime,
|
||
end: datetime,
|
||
) -> bool:
|
||
for other_start, other_end in timeline:
|
||
if other_start >= end:
|
||
break
|
||
if start < other_end and other_start < end:
|
||
return False
|
||
return True
|
||
|
||
def valid_qualifications(team_id: str, at: date) -> set[str]:
|
||
values: set[str] = set()
|
||
for employee in employees_by_team.get(team_id, []):
|
||
qualification = employee.get("qualificationCode")
|
||
if not qualification:
|
||
continue
|
||
valid_to = employee.get("qualificationValidTo")
|
||
if valid_to and _as_date(valid_to) < at:
|
||
continue
|
||
values.add(str(qualification))
|
||
return values
|
||
|
||
def resource_limit(resource: dict[str, Any], *keys: str) -> float | None:
|
||
for key in keys:
|
||
value = resource.get(key)
|
||
if value not in (None, ""):
|
||
return float(value)
|
||
return None
|
||
|
||
def resource_supports(
|
||
resource: dict[str, Any],
|
||
operation: dict[str, Any],
|
||
original: dict[str, Any],
|
||
start: datetime,
|
||
end: datetime,
|
||
) -> bool:
|
||
required_type = str(operation.get("requiredResourceType") or "")
|
||
if str(resource.get("resourceType") or "") != required_type:
|
||
return False
|
||
if resource.get("status", "ACTIVE") != "ACTIVE":
|
||
return False
|
||
if resource.get("shiftCalendarId") != original.get("shiftCalendarId"):
|
||
return False
|
||
required_group = operation.get("requiredResourceGroup")
|
||
if required_group and str(resource.get("resourceGroupId") or "") != str(required_group):
|
||
return False
|
||
if operation.get("transportWindowStatus") == "OPEN" and str(
|
||
resource.get("transportZone") or ""
|
||
) != str(operation.get("transportZone") or ""):
|
||
return False
|
||
required_tags = {
|
||
str(value) for value in operation.get("requiredCapabilityTags") or []
|
||
}
|
||
resource_tags = {
|
||
str(value) for value in resource.get("capabilityTags") or []
|
||
} | {required_type, "FINITE_CAPACITY"}
|
||
if not required_tags.issubset(resource_tags):
|
||
return False
|
||
dimension_checks = (
|
||
("requiredWeightT", ("maximumWeight", "maximumWeightT")),
|
||
("requiredLengthM", ("maximumLength", "maximumLengthM")),
|
||
("requiredWidthM", ("maximumWidth", "maximumWidthM")),
|
||
("requiredHeightM", ("maximumHeight", "maximumHeightM")),
|
||
("requiredLiftRadiusM", ("maximumLiftRadiusM", "maximumLiftRadius")),
|
||
)
|
||
for requirement_key, limit_keys in dimension_checks:
|
||
required_value = operation.get(requirement_key)
|
||
if required_value in (None, ""):
|
||
continue
|
||
limit = resource_limit(resource, *limit_keys)
|
||
if limit is None or float(required_value) > limit + 1e-9:
|
||
return False
|
||
blackouts = {
|
||
str(value)[:10] for value in resource.get("maintenanceBlackoutDates") or []
|
||
}
|
||
cursor = start.date()
|
||
while cursor <= end.date():
|
||
if cursor.isoformat() in blackouts:
|
||
return False
|
||
cursor += timedelta(days=1)
|
||
return True
|
||
|
||
excluded = excluded_operation_ids or set()
|
||
eligible_slots = sorted(
|
||
(
|
||
row
|
||
for row in slots
|
||
if row.get("timeFence") in {"FREE", "STRATEGIC"}
|
||
and str(row.get("operationId")) not in excluded
|
||
),
|
||
key=lambda row: (str(row.get("start")), str(row.get("operationId"))),
|
||
)
|
||
if not eligible_slots:
|
||
raise RuntimeError("no FREE or STRATEGIC slot is available for assignment repair")
|
||
|
||
start_offset = (variant_index * 104729) % len(eligible_slots)
|
||
for scan_index in range(len(eligible_slots)):
|
||
slot = eligible_slots[(start_offset + scan_index) % len(eligible_slots)]
|
||
operation_id = str(slot["operationId"])
|
||
operation = operations[operation_id]
|
||
start = _as_datetime(slot["start"])
|
||
end = _as_datetime(slot["end"])
|
||
original_resource_id = str(slot.get("resourceId"))
|
||
original_team_id = str(slot.get("teamId"))
|
||
original_resource = resources[original_resource_id]
|
||
original_team = teams[original_team_id]
|
||
|
||
changed_fields: list[str] = []
|
||
resource_candidates = [
|
||
row
|
||
for resource_id, row in resources.items()
|
||
if resource_id != original_resource_id
|
||
and resource_supports(row, operation, original_resource, start, end)
|
||
and owner_is_free(resource_intervals.get(resource_id, []), start, end)
|
||
]
|
||
resource_candidates.sort(key=lambda row: str(row["resourceId"]))
|
||
if resource_candidates:
|
||
selected = resource_candidates[
|
||
(variant_index + scan_index) % len(resource_candidates)
|
||
]
|
||
slot["resourceId"] = str(selected["resourceId"])
|
||
changed_fields.append("resourceId")
|
||
|
||
required_skills = {
|
||
str(value) for value in operation.get("requiredSkillCodes") or []
|
||
}
|
||
required_qualifications = {
|
||
str(value)
|
||
for value in operation.get("requiredQualificationCodes") or []
|
||
}
|
||
team_candidates = [
|
||
row
|
||
for team_id, row in teams.items()
|
||
if team_id != original_team_id
|
||
and row.get("status", "ACTIVE") == "ACTIVE"
|
||
and row.get("shiftCalendarId") == original_team.get("shiftCalendarId")
|
||
and required_skills.issubset(
|
||
{str(value) for value in row.get("skillCodes") or []}
|
||
)
|
||
and int(row.get("crewSize") or 0)
|
||
>= int(operation.get("crewSize") or 1)
|
||
and required_qualifications.issubset(
|
||
valid_qualifications(team_id, start.date())
|
||
)
|
||
and owner_is_free(team_intervals.get(team_id, []), start, end)
|
||
]
|
||
team_candidates.sort(key=lambda row: str(row["teamId"]))
|
||
if team_candidates:
|
||
selected = team_candidates[
|
||
(variant_index * 3 + scan_index) % len(team_candidates)
|
||
]
|
||
slot["teamId"] = str(selected["teamId"])
|
||
changed_fields.append("teamId")
|
||
|
||
if not changed_fields:
|
||
continue
|
||
slot["explanation"] = (
|
||
f"方案局部修复在{slot.get('timeFence')}区保持开始/结束时间不变,"
|
||
f"将资源从{original_resource_id}调整为{slot.get('resourceId')},"
|
||
f"将班组从{original_team_id}调整为{slot.get('teamId')}。"
|
||
)
|
||
slot["evidenceRefs"] = [
|
||
f"operation:{operation_id}",
|
||
f"resource:{slot.get('resourceId')}",
|
||
f"team:{slot.get('teamId')}",
|
||
f"baseline-slot:{slot.get('scheduleSlotId')}",
|
||
]
|
||
return {
|
||
"operationId": operation_id,
|
||
"productionOrderId": slot.get("productionOrderId"),
|
||
"timeFence": slot.get("timeFence"),
|
||
"changedFields": changed_fields,
|
||
"originalResourceId": original_resource_id,
|
||
"resourceId": slot.get("resourceId"),
|
||
"originalTeamId": original_team_id,
|
||
"teamId": slot.get("teamId"),
|
||
}
|
||
raise RuntimeError("no compatible free resource or team assignment was found")
|
||
|
||
|
||
def _normalise_variant_slots(
|
||
bundle: DatasetBundle,
|
||
generated_slots: list[dict[str, Any]],
|
||
schedule_version_id: str,
|
||
scenario_id: str,
|
||
) -> list[dict[str, Any]]:
|
||
baseline_by_operation = {
|
||
str(row["operationId"]): row for row in bundle.rows("schedule-slots")
|
||
}
|
||
normalized: list[dict[str, Any]] = []
|
||
for generated in generated_slots:
|
||
row = deepcopy(generated)
|
||
operation_id = str(row["operationId"])
|
||
baseline = baseline_by_operation[operation_id]
|
||
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["materialReadyAt"] = baseline.get("materialReadyAt")
|
||
row["holdReleaseAt"] = baseline.get("holdReleaseAt")
|
||
row["explanation"] = (
|
||
f"Alternative {scenario_id} keeps valid precedence and time windows, "
|
||
f"using resource {row.get('resourceId')} and team {row.get('teamId')}. "
|
||
"The change passed finite-capacity, skill, maintenance, transport and freeze checks."
|
||
)
|
||
row["evidenceRefs"] = [
|
||
f"operation:{operation_id}",
|
||
f"resource:{row.get('resourceId')}",
|
||
f"team:{row.get('teamId')}",
|
||
f"baseline-slot:{baseline.get('scheduleSlotId')}",
|
||
]
|
||
normalized.append(row)
|
||
return normalized
|
||
|
||
|
||
def _calculate_kpis(
|
||
bundle: DatasetBundle,
|
||
candidate_slots: list[dict[str, Any]],
|
||
deltas: list[dict[str, Any]],
|
||
) -> dict[str, float]:
|
||
operations = {
|
||
str(row["operationId"]): row for row in bundle.rows("operations")
|
||
}
|
||
resources = {
|
||
str(row["resourceId"]): row for row in bundle.rows("resources")
|
||
}
|
||
on_time = 0
|
||
resource_loads: dict[str, float] = defaultdict(float)
|
||
overtime_hours = 0.0
|
||
dock_slots: list[dict[str, Any]] = []
|
||
for slot in candidate_slots:
|
||
operation = operations[str(slot["operationId"])]
|
||
end = _as_datetime(slot["end"])
|
||
need_date = date.fromisoformat(str(operation.get("needDate")))
|
||
on_time += end.date() <= need_date
|
||
duration = float(slot.get("durationHours") or 0.0)
|
||
resource_id = str(slot.get("resourceId"))
|
||
resource_loads[resource_id] += duration
|
||
start = _as_datetime(slot["start"])
|
||
if start.hour < 6 or end.hour > 22:
|
||
overtime_hours += duration
|
||
if str(resources.get(resource_id, {}).get("resourceType")) == "DOCK":
|
||
dock_slots.append(slot)
|
||
|
||
loads = list(resource_loads.values())
|
||
mean_load = sum(loads) / max(1, len(loads))
|
||
dispersion = pstdev(loads) / mean_load if len(loads) > 1 and mean_load else 0.0
|
||
resource_score = max(0.0, 100.0 * (1.0 - min(1.0, dispersion)))
|
||
total_hours = sum(loads)
|
||
changed_ratio = len(deltas) / max(1, len(candidate_slots))
|
||
shift_hours = sum(
|
||
abs(
|
||
(
|
||
_as_datetime(delta["after"]["start"])
|
||
- _as_datetime(delta["before"]["start"])
|
||
).total_seconds()
|
||
)
|
||
/ 3600
|
||
for delta in deltas
|
||
)
|
||
cost_score = max(
|
||
0.0,
|
||
100.0
|
||
- changed_ratio * 35.0
|
||
- min(25.0, shift_hours / max(1.0, total_hours) * 100.0),
|
||
)
|
||
overtime_score = max(
|
||
0.0,
|
||
100.0 - overtime_hours / max(1.0, total_hours) * 100.0,
|
||
)
|
||
if dock_slots:
|
||
dock_start = min(_as_datetime(row["start"]) for row in dock_slots)
|
||
dock_end = max(_as_datetime(row["end"]) for row in dock_slots)
|
||
dock_used = sum(float(row.get("durationHours") or 0.0) for row in dock_slots)
|
||
dock_span = max(1.0, (dock_end - dock_start).total_seconds() / 3600)
|
||
dock_score = min(100.0, dock_used / dock_span * 100.0)
|
||
else:
|
||
dock_score = 100.0
|
||
high_risk = sum(
|
||
row.get("severity") in {"HIGH", "CRITICAL"}
|
||
for row in bundle.rows("conflicts")
|
||
)
|
||
risk_score = max(
|
||
0.0,
|
||
100.0 - high_risk / max(1, len(bundle.rows("conflicts"))) * 100.0,
|
||
)
|
||
return {
|
||
"delivery": round(on_time / max(1, len(candidate_slots)) * 100.0, 4),
|
||
"resource": round(resource_score, 4),
|
||
"cost": round(cost_score, 4),
|
||
"overtime": round(overtime_score, 4),
|
||
"dock": round(dock_score, 4),
|
||
"risk": round(risk_score, 4),
|
||
}
|
||
|
||
|
||
def _build_schedule_variant(
|
||
bundle: DatasetBundle,
|
||
config: GeneratorConfig,
|
||
variant_key: str,
|
||
variant_index: int,
|
||
excluded_operation_ids: set[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Build one deterministic O(n) schedule patch with a local safety proof."""
|
||
baseline_slots = bundle.rows("schedule-slots")
|
||
schedule_version_id = stable_id(
|
||
"schedule-version",
|
||
"variant",
|
||
variant_key,
|
||
config.seed,
|
||
config.scale,
|
||
prefix="SCHV",
|
||
)
|
||
candidate_slots = _normalise_variant_slots(
|
||
bundle,
|
||
baseline_slots,
|
||
schedule_version_id,
|
||
variant_key,
|
||
)
|
||
assignment = _apply_safe_assignment_variation(
|
||
bundle,
|
||
candidate_slots,
|
||
variant_index,
|
||
excluded_operation_ids,
|
||
)
|
||
deltas = _slot_deltas(baseline_slots, candidate_slots)
|
||
if len(deltas) != 1:
|
||
raise RuntimeError(
|
||
f"variant {variant_key} must change exactly one slot, found {len(deltas)}"
|
||
)
|
||
if deltas[0]["before"].get("timeFence") not in {"FREE", "STRATEGIC"}:
|
||
raise RuntimeError(
|
||
f"variant {variant_key} touched a protected time fence"
|
||
)
|
||
changed_field_counts = Counter(deltas[0]["changedFields"])
|
||
local_validation = {
|
||
"valid": True,
|
||
"solveStatus": "FEASIBLE",
|
||
"validationMode": "DELTA_SAFETY_PROOF",
|
||
"checkCount": 1,
|
||
"hardViolationCount": 0,
|
||
"unmarkedHardViolationCount": 0,
|
||
"blockingIssues": [],
|
||
"checks": {
|
||
"changedSlotCount": 1,
|
||
"timeUnchanged": True,
|
||
"precedenceUnchanged": True,
|
||
"materialReadinessUnchanged": True,
|
||
"qualityAndWeatherWindowsUnchanged": True,
|
||
"compatibleResourceOrTeam": True,
|
||
"targetOwnerFreeAtInterval": True,
|
||
"timeFence": assignment["timeFence"],
|
||
},
|
||
}
|
||
return {
|
||
"scheduleVersionId": schedule_version_id,
|
||
"scenarioId": variant_key,
|
||
"solveStatus": "FEASIBLE",
|
||
"targetProductionOrderId": assignment["productionOrderId"],
|
||
"targetOperationId": assignment["operationId"],
|
||
"delayedOperationIds": [],
|
||
"planningAdjustment": {
|
||
"type": "COMPATIBLE_OWNER_SWAP",
|
||
"days": 0,
|
||
"reason": (
|
||
"在FREE或STRATEGIC区保持工序时间不变,仅替换空闲且兼容的"
|
||
"有限资源和/或班组,避免全量重排。"
|
||
),
|
||
"assignment": assignment,
|
||
},
|
||
"scheduleSlots": candidate_slots,
|
||
"slotDeltas": deltas,
|
||
"changedSlotCount": 1,
|
||
"changedFields": sorted(changed_field_counts),
|
||
"changedFieldCounts": dict(sorted(changed_field_counts.items())),
|
||
"constraintValidation": local_validation,
|
||
"normalizedKpis": _calculate_kpis(bundle, candidate_slots, deltas),
|
||
}
|
||
|
||
|
||
def generate_alternatives(
|
||
bundle: DatasetBundle,
|
||
config: GeneratorConfig,
|
||
) -> DatasetBundle:
|
||
baseline_version = (bundle.rows("schedule-versions") or [{}])[0]
|
||
baseline_slots = bundle.rows("schedule-slots")
|
||
alternatives: list[dict[str, Any]] = []
|
||
used_operation_ids: set[str] = set()
|
||
for index, (mode, name, weights) in enumerate(ALTERNATIVE_MODES, 1):
|
||
variant = _build_schedule_variant(
|
||
bundle,
|
||
config,
|
||
mode,
|
||
index,
|
||
used_operation_ids,
|
||
)
|
||
used_operation_ids.add(str(variant["targetOperationId"]))
|
||
normalized = variant["normalizedKpis"]
|
||
score = round(
|
||
sum(normalized[key] * weight for key, weight in weights.items()),
|
||
4,
|
||
)
|
||
validation = variant["constraintValidation"]
|
||
alternatives.append(
|
||
{
|
||
"alternativeId": stable_id(
|
||
"alternative",
|
||
mode,
|
||
config.seed,
|
||
prefix="ALT",
|
||
),
|
||
"mode": mode,
|
||
"name": name,
|
||
"baseScheduleVersionId": baseline_version.get("scheduleVersionId"),
|
||
"scheduleVersionId": variant["scheduleVersionId"],
|
||
"scheduleVersion": {
|
||
"scheduleVersionId": variant["scheduleVersionId"],
|
||
"baseScheduleVersionId": baseline_version.get(
|
||
"scheduleVersionId"
|
||
),
|
||
"versionNo": f"ALT-{index:03d}",
|
||
"scenarioId": mode,
|
||
"immutable": True,
|
||
"slotInheritance": "PATCH_OVER_BASELINE",
|
||
},
|
||
"algorithm": "DETERMINISTIC_COMPATIBLE_OWNER_SWAP",
|
||
"algorithmVersion": "3.0.0",
|
||
"solveStatus": validation["solveStatus"],
|
||
"optimalityGap": None,
|
||
"gapType": "NOT_APPLICABLE",
|
||
"normalization": {
|
||
"range": [0, 100],
|
||
"higherIsBetter": True,
|
||
"source": "ACTUAL_REPAIRED_SCHEDULE",
|
||
},
|
||
"weights": weights,
|
||
"normalizedKpis": normalized,
|
||
"score": score,
|
||
"hardConstraintViolations": 0,
|
||
"unmarkedHardViolationCount": 0,
|
||
"constraintCheckCount": validation["checkCount"],
|
||
"softConstraintCost": round(100 - score, 4),
|
||
"fallbackReason": None,
|
||
"targetProductionOrderId": variant[
|
||
"targetProductionOrderId"
|
||
],
|
||
"targetOperationId": variant["targetOperationId"],
|
||
"planningAdjustment": variant["planningAdjustment"],
|
||
"changedSlotCount": 1,
|
||
"unchangedSlotCount": len(baseline_slots) - 1,
|
||
"changedFields": variant["changedFields"],
|
||
"changedFieldCounts": variant["changedFieldCounts"],
|
||
"slotDeltas": variant["slotDeltas"],
|
||
"alternativeSlots": [variant["slotDeltas"][0]["after"]],
|
||
"constraintValidation": validation,
|
||
"evidenceRefs": [
|
||
"skill:ship-scenario-simulation",
|
||
"KNO-SYN-19-001",
|
||
],
|
||
}
|
||
)
|
||
|
||
combined_version_id = stable_id(
|
||
"schedule-version",
|
||
"alternative-batch-proof",
|
||
config.seed,
|
||
config.scale,
|
||
prefix="SCHV",
|
||
)
|
||
combined_slots = _normalise_variant_slots(
|
||
bundle,
|
||
baseline_slots,
|
||
combined_version_id,
|
||
"ALTERNATIVE_BATCH_PROOF",
|
||
)
|
||
combined_by_operation = {
|
||
str(row["operationId"]): row for row in combined_slots
|
||
}
|
||
for alternative in alternatives:
|
||
delta = alternative["slotDeltas"][0]
|
||
target = combined_by_operation[str(delta["operationId"])]
|
||
for field in _COMPARE_FIELDS:
|
||
target[field] = delta["after"].get(field)
|
||
check_tables = dict(bundle.tables)
|
||
check_tables["schedule-slots"] = combined_slots
|
||
check_bundle = DatasetBundle(
|
||
metadata=bundle.metadata,
|
||
tables=check_tables,
|
||
artifacts=bundle.artifacts,
|
||
diagnostics=bundle.diagnostics,
|
||
)
|
||
batch_validation = validate_schedule_constraints(check_bundle, config)
|
||
if not batch_validation["valid"]:
|
||
raise RuntimeError(
|
||
"seven-alternative combined safety proof failed: "
|
||
+ "; ".join(batch_validation["blockingIssues"][:5])
|
||
)
|
||
batch_proof = {
|
||
"valid": True,
|
||
"solveStatus": "FEASIBLE",
|
||
"validationMode": "DELTA_PROOF_PLUS_SINGLE_BATCH_FULL_VALIDATION",
|
||
"checkCount": batch_validation["checkCount"],
|
||
"hardViolationCount": 0,
|
||
"unmarkedHardViolationCount": 0,
|
||
"blockingIssues": [],
|
||
"combinedProofScheduleVersionId": combined_version_id,
|
||
"combinedChangedSlotCount": len(alternatives),
|
||
}
|
||
for alternative in alternatives:
|
||
alternative["constraintValidation"] = deepcopy(batch_proof)
|
||
alternative["constraintCheckCount"] = batch_validation["checkCount"]
|
||
|
||
recommended = next(
|
||
row for row in alternatives if row["mode"] == "RECOMMENDED"
|
||
)
|
||
best = max(alternatives, key=lambda row: (row["score"], row["mode"]))
|
||
recommended["recommended"] = True
|
||
recommended["recommendationRationale"] = (
|
||
"七个独立版本均采用FREE或STRATEGIC区单槽兼容资源/班组替换;"
|
||
"全部差异合并后仅执行一次完整硬约束校验,兼顾真实性与性能。"
|
||
)
|
||
bundle.artifacts["schedule-alternatives"] = {
|
||
"baselineScheduleVersionId": baseline_version.get("scheduleVersionId"),
|
||
"alternativeCount": len(alternatives),
|
||
"independentScheduleVersionCount": len(
|
||
{row["scheduleVersionId"] for row in alternatives}
|
||
),
|
||
"alternatives": alternatives,
|
||
"recommendedAlternativeId": recommended["alternativeId"],
|
||
"highestScoreAlternativeId": best["alternativeId"],
|
||
"allAlternativesRevalidated": True,
|
||
"validationStrategy": "ONE_COMBINED_FULL_VALIDATION",
|
||
"combinedConstraintValidation": batch_proof,
|
||
"totalChangedSlotCount": len(alternatives),
|
||
"maximumAlternativeDelayDays": 0,
|
||
}
|
||
return bundle
|