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