aps-agent/server/shipyard_synthetic/alternatives.py

790 lines
28 KiB
Python
Raw Normal View History

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