aps-agent/server/shipyard_synthetic/constraints.py

1005 lines
45 KiB
Python

from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from itertools import pairwise
from typing import Any
from zoneinfo import ZoneInfo
from .config import GeneratorConfig
from .models import DatasetBundle
def _as_datetime(value: Any, timezone: ZoneInfo) -> datetime:
parsed = datetime.fromisoformat(str(value))
return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
def _as_date(value: Any, fallback: date) -> date:
try:
return date.fromisoformat(str(value or "")[:10])
except ValueError:
return fallback
def _clock(value: Any, fallback: time) -> time:
try:
return time.fromisoformat(str(value or ""))
except ValueError:
return fallback
def _calendar_span(
bundle: DatasetBundle,
calendar_id: Any,
day: date,
timezone: ZoneInfo,
) -> tuple[datetime, datetime] | None:
calendar = next((row for row in bundle.rows("calendars") if str(row.get("calendarId")) == str(calendar_id)), None)
working_days = {int(value) for value in (calendar or {}).get("workingDays") or [1, 2, 3, 4, 5]}
if day.isoweekday() not in working_days:
return None
shifts = [
row for row in bundle.rows("shifts")
if str(row.get("calendarId")) == str(calendar_id) and row.get("status", "ACTIVE") == "ACTIVE"
]
if not shifts:
shifts = [{"startTime": "08:00", "endTime": "16:00"}]
starts: list[datetime] = []
ends: list[datetime] = []
for shift in shifts:
start = datetime.combine(day, _clock(shift.get("startTime"), time(8)), tzinfo=timezone)
end = datetime.combine(day, _clock(shift.get("endTime"), time(16)), tzinfo=timezone)
if end <= start:
end += timedelta(days=1)
starts.append(start)
ends.append(end)
return min(starts), max(ends)
def _append_violation(
violations: list[dict[str, Any]],
constraint: str,
entity_id: str,
message: str,
) -> None:
violations.append(
{
"constraint": constraint,
"entityId": entity_id,
"message": message,
"severity": "HARD",
"markedExpected": False,
}
)
def _optional_datetime(
value: Any,
timezone: ZoneInfo,
*,
end_of_day: bool = False,
) -> datetime | None:
if value in (None, ""):
return None
text = str(value)
parsed = datetime.fromisoformat(text)
if "T" not in text and " " not in text and end_of_day:
parsed = datetime.combine(parsed.date(), time(23, 59, 59, 999999))
return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
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 _overlaps(
first_start: datetime,
first_end: datetime,
second_start: datetime,
second_end: datetime,
) -> bool:
return first_start < second_end and second_start < first_end
def _hazards_incompatible(first: str, second: str) -> bool:
if not first or not second:
return False
if first == second:
return False
incompatible_pairs = {
frozenset(("HOT_WORK", "PAINT_VOC")),
frozenset(("HOT_WORK", "CONFINED_SPACE")),
frozenset(("PAINT_VOC", "CONFINED_SPACE")),
}
return frozenset((first, second)) in incompatible_pairs
def _date_span(start: datetime, end: datetime) -> set[str]:
dates: set[str] = set()
cursor = start.date()
while cursor <= end.date():
dates.add(cursor.isoformat())
cursor += timedelta(days=1)
return dates
def validate_schedule_constraints(bundle: DatasetBundle, config: GeneratorConfig) -> dict:
"""Independently validate finite shipyard resource, network and execution constraints."""
timezone = ZoneInfo(config.timezone)
all_operations = {
str(row["operationId"]): row for row in bundle.rows("operations")
}
operations = {
operation_id: row
for operation_id, row in all_operations.items()
if row.get("active", True)
}
work_orders = {
str(row["workOrderId"]): row for row in bundle.rows("work-orders")
}
slots = bundle.rows("schedule-slots")
resources = {str(row["resourceId"]): row for row in bundle.rows("resources")}
teams = {str(row["teamId"]): row for row in bundle.rows("teams")}
suppliers = {str(row["supplierId"]): row for row in bundle.rows("suppliers")}
employees_by_team: dict[str, list[dict[str, Any]]] = defaultdict(list)
for employee in bundle.rows("employees"):
if employee.get("status", "ACTIVE") == "ACTIVE":
employees_by_team[str(employee.get("teamId"))].append(employee)
violations: list[dict[str, Any]] = []
checks = 0
slot_ids: set[str] = set()
slots_by_operation: dict[str, list[dict[str, Any]]] = defaultdict(list)
parsed_slots: list[tuple[dict[str, Any], datetime, datetime]] = []
for slot in slots:
slot_id = str(slot.get("scheduleSlotId") or "")
operation_id = str(slot.get("operationId") or "")
checks += 2
if slot_id in slot_ids:
_append_violation(violations, "UNIQUE_SLOT_ID", slot_id, "duplicate schedule slot id")
slot_ids.add(slot_id)
slots_by_operation[operation_id].append(slot)
try:
start = _as_datetime(slot.get("start"), timezone)
end = _as_datetime(slot.get("end"), timezone)
except (TypeError, ValueError):
_append_violation(violations, "VALID_INTERVAL", slot_id, "slot has an invalid ISO datetime")
continue
parsed_slots.append((slot, start, end))
if end <= start:
_append_violation(violations, "VALID_INTERVAL", slot_id, "slot end must be after start")
for operation_id in sorted(operations):
checks += 1
count = len(slots_by_operation.get(operation_id, []))
if count != 1:
_append_violation(
violations,
"ACTIVE_OPERATION_COVERAGE",
operation_id,
f"expected exactly one slot, found {count}",
)
for operation_id in sorted(set(slots_by_operation) - set(operations)):
checks += 1
_append_violation(
violations,
"NO_ORPHAN_SLOT",
operation_id,
"slot references a missing or inactive operation",
)
checks += 1
if len(slots) != config.profile.schedule_slot_count:
_append_violation(
violations,
"EXACT_SLOT_COUNT",
"baseline",
f"expected {config.profile.schedule_slot_count} slots, found {len(slots)}",
)
for work_order_id, work_order in sorted(work_orders.items()):
if not work_order.get("mesDispatchProtected"):
continue
protected_operations = [
row
for row in all_operations.values()
if str(row.get("workOrderId")) == work_order_id
]
checks += len(protected_operations)
for operation in protected_operations:
operation_id = str(operation["operationId"])
if not operation.get("active", True) or len(slots_by_operation.get(operation_id, [])) != 1:
_append_violation(
violations,
"MES_DISPATCH_DELETE",
operation_id,
"a MES-dispatched operation cannot be deactivated or removed from the schedule",
)
timelines: dict[tuple[str, str], list[tuple[datetime, datetime, str]]] = defaultdict(list)
zone_intervals: dict[
str,
list[tuple[datetime, datetime, str, str, int, int]],
] = defaultdict(list)
exclusive_intervals: dict[
tuple[str, str],
list[tuple[datetime, datetime, str]],
] = defaultdict(list)
slot_interval_by_operation: dict[str, tuple[datetime, datetime]] = {}
slot_by_operation: dict[str, dict[str, Any]] = {}
for slot, start, end in parsed_slots:
slot_id = str(slot["scheduleSlotId"])
operation_id = str(slot["operationId"])
operation = operations.get(operation_id)
resource = resources.get(str(slot.get("resourceId")))
team = teams.get(str(slot.get("teamId")))
checks += 24
if operation is None:
continue
slot_interval_by_operation[operation_id] = (start, end)
slot_by_operation[operation_id] = slot
if resource is None:
_append_violation(violations, "RESOURCE_EXISTS", slot_id, "assigned resource does not exist")
if team is None:
_append_violation(violations, "TEAM_EXISTS", slot_id, "assigned team does not exist")
if resource is None or team is None:
continue
timelines[("resource", str(resource["resourceId"]))].append((start, end, slot_id))
timelines[("team", str(team["teamId"]))].append((start, end, slot_id))
for kind, enabled_field, owner_field in (
("DOCK", "dockExclusive", "dockId"),
("BERTH", "berthExclusive", "berthId"),
("SUPPORT_FRAME_SITE", "supportFrameSiteExclusive", "supportFrameSiteId"),
("SPATIAL_INTERFERENCE", "spatialInterferenceGroup", "spatialInterferenceGroup"),
):
if not operation.get(enabled_field):
continue
owner_id = str(operation.get(owner_field) or "")
checks += 1
if not owner_id:
_append_violation(
violations,
f"{kind}_IDENTITY",
slot_id,
f"{kind.lower()} constraint lacks an occupancy identity",
)
else:
exclusive_intervals[(kind, owner_id)].append((start, end, slot_id))
required_type = str(operation.get("requiredResourceType") or "")
if str(resource.get("resourceType") or "") != required_type:
_append_violation(
violations,
"RESOURCE_TYPE",
slot_id,
f"resource type does not match {required_type}",
)
required_group = operation.get("requiredResourceGroup")
if required_group and str(resource.get("resourceGroupId") or "") != str(required_group):
_append_violation(
violations,
"RESOURCE_GROUP",
slot_id,
"resource does not belong to the required resource group",
)
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):
_append_violation(
violations,
"RESOURCE_CAPABILITY",
slot_id,
"resource capability tags are incomplete",
)
required_skills = {str(value) for value in operation.get("requiredSkillCodes") or []}
team_skills = {str(value) for value in team.get("skillCodes") or []}
if not required_skills.issubset(team_skills):
_append_violation(violations, "TEAM_SKILLS", slot_id, "team lacks required skills")
if int(team.get("crewSize") or 0) < int(operation.get("crewSize") or 1):
_append_violation(
violations,
"TEAM_CREW_SIZE",
slot_id,
"team crew size is below demand",
)
required_qualifications = {
str(value) for value in operation.get("requiredQualificationCodes") or []
}
valid_qualifications = {
str(employee.get("qualificationCode"))
for employee in employees_by_team.get(str(team["teamId"]), [])
if employee.get("qualificationCode")
and _as_date(employee.get("qualificationValidTo"), start.date()) >= start.date()
}
if not required_qualifications.issubset(valid_qualifications):
_append_violation(
violations,
"TEAM_QUALIFICATION",
slot_id,
"team lacks a valid required qualification",
)
dimension_checks = (
("requiredWeightT", _resource_limit(resource, "maximumWeight", "maximumWeightT"), "RESOURCE_WEIGHT"),
("requiredLengthM", _resource_limit(resource, "maximumLength", "maximumLengthM"), "RESOURCE_LENGTH"),
("requiredWidthM", _resource_limit(resource, "maximumWidth", "maximumWidthM"), "RESOURCE_WIDTH"),
("requiredHeightM", _resource_limit(resource, "maximumHeight", "maximumHeightM"), "RESOURCE_HEIGHT"),
("requiredLiftRadiusM", _resource_limit(resource, "maximumLiftRadiusM", "maximumLiftRadius"), "CRANE_LIFT_RADIUS"),
)
for requirement_key, limit, constraint_name in dimension_checks:
required_value = operation.get(requirement_key)
if required_value in (None, ""):
continue
if limit is None or float(required_value) > limit + 1e-9:
_append_violation(
violations,
constraint_name,
slot_id,
f"resource limit does not cover {requirement_key}",
)
expected_duration = float(operation.get("durationHours") or 0.0)
elapsed = (end - start).total_seconds() / 3600
if abs(elapsed - expected_duration) > 0.01:
_append_violation(
violations,
"DURATION",
slot_id,
"slot duration does not equal operation duration",
)
if start.date() < config.planning_base_date or end.date() > config.planning_horizon_end:
_append_violation(
violations,
"PLANNING_HORIZON",
slot_id,
"slot is outside the fixed planning horizon",
)
resource_span = _calendar_span(bundle, resource.get("shiftCalendarId"), start.date(), timezone)
team_span = _calendar_span(bundle, team.get("shiftCalendarId"), start.date(), timezone)
if resource_span is None or not (resource_span[0] <= start and end <= resource_span[1]):
_append_violation(
violations,
"RESOURCE_CALENDAR",
slot_id,
"slot is outside the resource calendar",
)
if team_span is None or not (team_span[0] <= start and end <= team_span[1]):
_append_violation(
violations,
"TEAM_CALENDAR",
slot_id,
"slot is outside the team calendar",
)
maintenance_blackouts = {
str(value)[:10] for value in resource.get("maintenanceBlackoutDates") or []
}
if _date_span(start, end) & maintenance_blackouts:
_append_violation(
violations,
"MAINTENANCE_BLACKOUT",
slot_id,
"slot overlaps a resource maintenance blackout date",
)
material_ready = max(
_as_date(operation.get("materialReadyAt"), config.planning_base_date),
_as_date(operation.get("outsourceReturnAt"), config.planning_base_date),
)
if start.date() < material_ready:
_append_violation(
violations,
"MATERIAL_READY",
slot_id,
"slot starts before material/outsource readiness",
)
if operation.get("holdPoint"):
if operation.get("holdStatus") != "RELEASED":
_append_violation(violations, "QUALITY_HOLD", slot_id, "hold point is not released")
hold_release = _as_date(operation.get("holdReleaseAt"), material_ready)
if start.date() < hold_release:
_append_violation(
violations,
"QUALITY_HOLD",
slot_id,
"slot starts before hold release",
)
if not operation.get("drawingReleased", False):
_append_violation(
violations,
"DESIGN_RELEASE",
slot_id,
"formal work is scheduled before design release",
)
if operation.get("weatherSensitive"):
if operation.get("weatherWindowStatus") != "OPEN":
_append_violation(
violations,
"WEATHER_WINDOW",
slot_id,
"weather-sensitive work lacks an open window",
)
weather_start = _optional_datetime(operation.get("weatherWindowStart"), timezone)
weather_end = _optional_datetime(
operation.get("weatherWindowEnd"),
timezone,
end_of_day=True,
)
if (weather_start and start < weather_start) or (weather_end and end > weather_end):
_append_violation(
violations,
"WEATHER_WINDOW",
slot_id,
"weather-sensitive slot is outside the open weather window",
)
if operation.get("transportWindowStatus") == "OPEN":
if str(resource.get("transportZone") or "") != str(operation.get("transportZone") or ""):
_append_violation(
violations,
"TRANSPORT_ZONE",
slot_id,
"resource transport zone does not match the operation",
)
transport_start = _optional_datetime(operation.get("transportWindowStart"), timezone)
transport_end = _optional_datetime(
operation.get("transportWindowEnd"),
timezone,
end_of_day=True,
)
if (transport_start and start < transport_start) or (transport_end and end > transport_end):
_append_violation(
violations,
"TRANSPORT_WINDOW",
slot_id,
"slot is outside the transport window",
)
elif operation.get("transportZone"):
_append_violation(
violations,
"TRANSPORT_WINDOW",
slot_id,
"transport-zone demand is not in an open transport window",
)
if operation.get("liftingRequired"):
lift_start = _optional_datetime(operation.get("liftWindowStart"), timezone)
lift_end = _optional_datetime(
operation.get("liftWindowEnd"), timezone, end_of_day=True
)
if (
operation.get("liftWindowStatus") != "OPEN"
or "CRANE" not in str(resource.get("resourceType") or "")
or (lift_start and start < lift_start)
or (lift_end and end > lift_end)
):
_append_violation(
violations,
"LIFT_WINDOW",
slot_id,
"lifting work lacks a compatible crane and open lifting window",
)
if operation.get("transportPathRequired") and (
operation.get("transportPathStatus") != "OPEN"
or not operation.get("transportPathId")
):
_append_violation(
violations,
"TRANSPORT_PATH",
slot_id,
"block transport path is missing or unavailable",
)
if operation.get("paintingEnvironmentRequired"):
humidity = float(operation.get("paintHumidityPercent") or 0.0)
max_humidity = float(operation.get("paintMaxHumidityPercent") or 0.0)
temperature = float(operation.get("paintTemperatureC") or 0.0)
min_temperature = float(operation.get("paintMinTemperatureC") or 0.0)
max_temperature = float(operation.get("paintMaxTemperatureC") or 0.0)
if (
operation.get("paintingEnvironmentStatus") != "ACCEPTABLE"
or max_humidity <= 0
or humidity > max_humidity
or temperature < min_temperature
or temperature > max_temperature
):
_append_violation(
violations,
"PAINT_ENVIRONMENT",
slot_id,
"coating temperature or humidity is outside the qualified range",
)
if operation.get("criticalEquipmentRequired"):
equipment_arrival = _optional_datetime(
operation.get("criticalEquipmentArrivalAt"), timezone
)
if (
operation.get("criticalEquipmentStatus") != "AVAILABLE"
or equipment_arrival is None
or start < equipment_arrival
):
_append_violation(
violations,
"CRITICAL_EQUIPMENT_ARRIVAL",
slot_id,
"critical equipment is not available before operation start",
)
if operation.get("witnessRequired"):
witness_start = _optional_datetime(operation.get("witnessWindowStart"), timezone)
witness_end = _optional_datetime(
operation.get("witnessWindowEnd"), timezone, end_of_day=True
)
if (
operation.get("witnessCalendarStatus") != "OPEN"
or (witness_start and start < witness_start)
or (witness_end and end > witness_end)
):
_append_violation(
violations,
"WITNESS_CALENDAR",
slot_id,
"owner or classification-society witness calendar is unavailable",
)
if operation.get("launchTrialWeatherRequired"):
marine_start = _optional_datetime(
operation.get("launchTrialWeatherWindowStart"), timezone
)
marine_end = _optional_datetime(
operation.get("launchTrialWeatherWindowEnd"), timezone, end_of_day=True
)
if (
operation.get("launchTrialWeatherStatus") != "OPEN"
or (marine_start and start < marine_start)
or (marine_end and end > marine_end)
):
_append_violation(
violations,
"LAUNCH_TRIAL_WEATHER",
slot_id,
"launch or sea-trial task lacks an open marine weather window",
)
immutable_fields = (
("start", "startedBaselineStart"),
("end", "startedBaselineEnd"),
("resourceId", "startedBaselineResourceId"),
("teamId", "startedBaselineTeamId"),
)
if operation.get("startedTaskImmutable") and any(
str(slot.get(slot_field)) != str(operation.get(baseline_field))
for slot_field, baseline_field in immutable_fields
):
_append_violation(
violations,
"STARTED_TASK_IMMUTABLE",
slot_id,
"an already-started task cannot be migrated or rescheduled",
)
frozen_order_fields = (
("start", "frozenOrderBaselineStart"),
("end", "frozenOrderBaselineEnd"),
("resourceId", "frozenOrderBaselineResourceId"),
("teamId", "frozenOrderBaselineTeamId"),
)
if operation.get("frozenWorkOrderProtected") and any(
str(slot.get(slot_field)) != str(operation.get(baseline_field))
for slot_field, baseline_field in frozen_order_fields
):
_append_violation(
violations,
"FROZEN_WORK_ORDER_RESCHEDULE",
slot_id,
"a frozen work order cannot be automatically rescheduled",
)
if slot.get("timeFence") == "FROZEN":
baseline_start = str(slot.get("baselineStart") or "")
if baseline_start != str(slot.get("start")) and not slot.get("changeAuthorized"):
_append_violation(
violations,
"FROZEN_ZONE",
slot_id,
"unauthorized frozen-zone date change",
)
zone_id = str(operation.get("zoneId") or "")
hazard_class = str(operation.get("hazardClass") or "")
area_units = int(operation.get("areaDensityUnits") or 0)
max_area_units = int(operation.get("maxAreaDensityUnits") or 0)
if zone_id and (hazard_class or area_units):
zone_intervals[zone_id].append(
(start, end, slot_id, hazard_class, area_units, max_area_units)
)
for (kind, owner_id), timeline in sorted(timelines.items()):
ordered = sorted(timeline)
for previous, current in pairwise(ordered):
checks += 1
if previous[1] > current[0]:
_append_violation(
violations,
"FINITE_CAPACITY_OVERLAP",
owner_id,
f"{kind} timeline overlaps between {previous[2]} and {current[2]}",
)
exclusivity_codes = {
"DOCK": "DOCK_EXCLUSIVITY",
"BERTH": "BERTH_EXCLUSIVITY",
"SUPPORT_FRAME_SITE": "SUPPORT_FRAME_SITE_OCCUPANCY",
"SPATIAL_INTERFERENCE": "GRAND_BLOCK_SPATIAL_INTERFERENCE",
}
for (kind, owner_id), timeline in sorted(exclusive_intervals.items()):
ordered = sorted(timeline)
for previous, current in pairwise(ordered):
checks += 1
if previous[1] > current[0]:
_append_violation(
violations,
exclusivity_codes[kind],
owner_id,
f"{kind.lower()} occupancy overlaps between {previous[2]} and {current[2]}",
)
for zone_id, intervals in sorted(zone_intervals.items()):
ordered = sorted(intervals)
for index, current in enumerate(ordered):
for other in ordered[index + 1 :]:
if other[0] >= current[1]:
break
checks += 1
if _overlaps(current[0], current[1], other[0], other[1]) and _hazards_incompatible(
current[3],
other[3],
):
_append_violation(
violations,
"HAZARD_INCOMPATIBILITY",
zone_id,
f"incompatible hazards overlap between {current[2]} and {other[2]}",
)
events: list[tuple[datetime, int, int, str]] = []
zone_limit = min((row[5] for row in intervals if row[5] > 0), default=0)
for start, end, slot_id, _hazard, units, _limit in intervals:
if units <= 0:
continue
events.append((start, 1, units, slot_id))
events.append((end, 0, -units, slot_id))
active_units = 0
for _moment, event_order, delta, slot_id in sorted(events):
checks += 1
active_units += delta
if event_order == 1 and zone_limit > 0 and active_units > zone_limit:
_append_violation(
violations,
"AREA_DENSITY",
zone_id,
f"area density exceeds {zone_limit} at {slot_id}",
)
for operation_id, operation in sorted(operations.items()):
predecessor_id = operation.get("predecessorOperationId")
if not predecessor_id:
continue
checks += 1
current = slot_interval_by_operation.get(operation_id)
predecessor = slot_interval_by_operation.get(str(predecessor_id))
if current is None or predecessor is None:
continue
lag = timedelta(hours=float(operation.get("lagHours") or 0.0))
relation_type = str(operation.get("relationType") or "FS").upper()
relation_valid = {
"FS": current[0] >= predecessor[1] + lag,
"SS": current[0] >= predecessor[0] + lag,
"FF": current[1] >= predecessor[1] + lag,
"SF": current[1] >= predecessor[0] + lag,
}.get(relation_type)
if relation_valid is None:
_append_violation(
violations,
"PRECEDENCE_RELATION_TYPE",
operation_id,
f"unsupported relation type {relation_type}",
)
elif not relation_valid:
_append_violation(
violations,
f"{relation_type}_PRECEDENCE",
operation_id,
f"{relation_type} relation violates lag {float(operation.get('lagHours') or 0.0)}h",
)
for operation_id, operation in sorted(operations.items()):
erection_predecessor_id = operation.get("erectionPredecessorOperationId")
if not erection_predecessor_id:
continue
checks += 1
current = slot_interval_by_operation.get(operation_id)
predecessor = slot_interval_by_operation.get(str(erection_predecessor_id))
if current is None or predecessor is None or current[0] < predecessor[1]:
_append_violation(
violations,
"SECTION_ERECTION_SEQUENCE",
operation_id,
"section erection starts before its declared erection predecessor completes",
)
outsource_suggestions = bundle.rows("outsource-suggestions")
suggestions_by_operation: dict[str, list[dict[str, Any]]] = defaultdict(list)
supplier_monthly_count: dict[tuple[str, str], int] = defaultdict(int)
for suggestion in outsource_suggestions:
suggestions_by_operation[str(suggestion.get("operationId"))].append(suggestion)
for operation_id, operation in sorted(operations.items()):
suggestions = suggestions_by_operation.get(operation_id, [])
is_outsource = operation.get("sourcingMode") == "OUTSOURCE"
checks += 1
if is_outsource and len(suggestions) != 1:
_append_violation(
violations,
"OUTSOURCE_SUGGESTION_COVERAGE",
operation_id,
f"expected one outsource suggestion, found {len(suggestions)}",
)
if not is_outsource and suggestions:
_append_violation(
violations,
"OUTSOURCE_SUGGESTION_SCOPE",
operation_id,
"MAKE operation unexpectedly has an outsource suggestion",
)
if not is_outsource or len(suggestions) != 1:
continue
suggestion = suggestions[0]
supplier_id = str(suggestion.get("supplierId") or "")
supplier = suppliers.get(supplier_id)
checks += 12
if supplier is None or not supplier.get("approved", False) or supplier.get("status", "ACTIVE") != "ACTIVE":
_append_violation(
violations,
"OUTSOURCE_SUPPLIER_APPROVAL",
operation_id,
"outsource supplier is missing, inactive or unapproved",
)
continue
operation_code = str(suggestion.get("operationCode") or operation.get("outsourceOperationCode") or "")
supplier_codes = {str(value) for value in supplier.get("outsourceOperationCodes") or []}
if not operation_code or operation_code not in supplier_codes:
_append_violation(
violations,
"OUTSOURCE_SUPPLIER_CAPABILITY",
operation_id,
"supplier does not support the outsource operation code",
)
if str(operation.get("outsourceSupplierId") or "") != supplier_id:
_append_violation(
violations,
"OUTSOURCE_SUPPLIER_LINK",
operation_id,
"operation and suggestion supplier ids differ",
)
if (
str(suggestion.get("previousOperationId") or "")
!= str(operation.get("previousOperationId") or "")
or str(suggestion.get("nextOperationId") or "")
!= str(operation.get("nextOperationId") or "")
or not suggestion.get("previousOperationId")
or not suggestion.get("nextOperationId")
):
_append_violation(
violations,
"OUTSOURCE_ROUTE_CONTEXT",
operation_id,
"outsource suggestion lacks matching predecessor/successor context",
)
try:
send_at = _as_datetime(suggestion.get("sendDate"), timezone)
return_at = _as_datetime(suggestion.get("returnDate"), timezone)
except (TypeError, ValueError):
_append_violation(
violations,
"OUTSOURCE_TIME_CHAIN",
operation_id,
"outsource suggestion contains invalid send/return timestamps",
)
continue
total_days = int(suggestion.get("transportDays") or 0) + int(
suggestion.get("processingDays") or 0
) + int(suggestion.get("inspectionDays") or 0)
expected_return = send_at + timedelta(days=total_days)
if abs((return_at - expected_return).total_seconds()) > 1.0:
_append_violation(
violations,
"OUTSOURCE_TIME_CHAIN",
operation_id,
"return timestamp does not include transport, processing and inspection lead time",
)
operation_send = _as_datetime(operation.get("outsourceSendAt"), timezone)
operation_return = _as_datetime(operation.get("outsourceReturnAt"), timezone)
if abs((operation_send - send_at).total_seconds()) > 1.0 or abs(
(operation_return - return_at).total_seconds()
) > 1.0:
_append_violation(
violations,
"OUTSOURCE_TIME_SOURCE",
operation_id,
"operation and suggestion do not share one outsource time source",
)
predecessor_interval = slot_interval_by_operation.get(str(operation.get("previousOperationId")))
current_interval = slot_interval_by_operation.get(operation_id)
next_interval = slot_interval_by_operation.get(str(operation.get("nextOperationId")))
if predecessor_interval and send_at < predecessor_interval[1]:
_append_violation(
violations,
"OUTSOURCE_SEND_AFTER_PREDECESSOR",
operation_id,
"outsource send occurs before predecessor completion",
)
if current_interval and current_interval[0] < return_at:
_append_violation(
violations,
"OUTSOURCE_RETURN_BEFORE_SLOT",
operation_id,
"outsource operation starts before return",
)
if next_interval and next_interval[0] < return_at:
_append_violation(
violations,
"OUTSOURCE_RETURN_BEFORE_SUCCESSOR",
operation_id,
"successor starts before outsource return",
)
supplier_blackouts = {str(value)[:10] for value in supplier.get("blackoutDates") or []}
if send_at.date().isoformat() in supplier_blackouts or return_at.date().isoformat() in supplier_blackouts:
_append_violation(
violations,
"SUPPLIER_BLACKOUT",
operation_id,
"outsource send or return occurs on a supplier blackout date",
)
capacity_bucket = send_at.strftime("%Y-%m")
supplier_monthly_count[(supplier_id, capacity_bucket)] += 1
for (supplier_id, capacity_bucket), assigned_count in sorted(supplier_monthly_count.items()):
checks += 1
supplier = suppliers[supplier_id]
capacity = float(supplier.get("monthlyCapacity") or 0.0)
capacity_unit = str(supplier.get("monthlyCapacityUnit") or "OPERATION")
if capacity_unit == "OPERATION" and capacity > 0 and assigned_count > capacity:
_append_violation(
violations,
"SUPPLIER_MONTHLY_CAPACITY",
f"{supplier_id}:{capacity_bucket}",
f"assigned {assigned_count} operations above capacity {capacity}",
)
conflicts = bundle.rows("conflicts")
checks += len(conflicts) + 1
if len(conflicts) != config.profile.risk_conflict_count:
_append_violation(
violations,
"EXACT_CONFLICT_COUNT",
"baseline",
f"expected {config.profile.risk_conflict_count} conflict/risk rows, found {len(conflicts)}",
)
for conflict in conflicts:
if not (
conflict.get("detected") is True
and conflict.get("expected") is True
and conflict.get("resolutionStatus") == "RESOLVED"
and conflict.get("hardConstraintViolation") is False
):
_append_violation(
violations,
"MARKED_RESOLVED_RISK",
str(conflict.get("conflictId") or ""),
"baseline risk must be detected, expected, resolved and not a hard violation",
)
business_constraint_specs = (
("\u5de5\u5e8f\u524d\u540e\u5173\u7cfb", sum(bool(row.get("predecessorOperationId")) for row in operations.values()), {"FS_PRECEDENCE", "SS_PRECEDENCE", "FF_PRECEDENCE", "SF_PRECEDENCE", "PRECEDENCE_RELATION_TYPE"}),
("\u7269\u6599\u9f50\u5957", len(parsed_slots), {"MATERIAL_READY"}),
("\u8bbe\u8ba1\u56fe\u7eb8\u5df2\u91ca\u653e", len(parsed_slots), {"DESIGN_RELEASE"}),
("\u8d44\u6e90\u6709\u9650\u80fd\u529b", len(parsed_slots), {"RESOURCE_TYPE", "RESOURCE_GROUP", "RESOURCE_CAPABILITY", "RESOURCE_WEIGHT", "RESOURCE_LENGTH", "RESOURCE_WIDTH", "RESOURCE_HEIGHT", "FINITE_CAPACITY_OVERLAP"}),
("\u8bbe\u5907\u7ef4\u62a4\u7a97\u53e3", sum(bool(row.get("maintenanceBlackoutDates")) for row in resources.values()), {"MAINTENANCE_BLACKOUT"}),
("\u4eba\u5458\u6280\u80fd\u548c\u4eba\u6570", sum(bool(row.get("requiredSkillCodes")) or int(row.get("crewSize") or 0) > 0 for row in operations.values()), {"TEAM_SKILLS", "TEAM_CREW_SIZE", "TEAM_QUALIFICATION"}),
("\u8239\u575e\u72ec\u5360", sum(bool(row.get("dockExclusive")) for row in operations.values()), {"DOCK_EXCLUSIVITY", "DOCK_IDENTITY"}),
("\u7801\u5934\u6cca\u4f4d\u72ec\u5360", sum(bool(row.get("berthExclusive")) for row in operations.values()), {"BERTH_EXCLUSIVITY", "BERTH_IDENTITY"}),
("\u80ce\u67b6\u548c\u573a\u5730\u5360\u7528", sum(bool(row.get("supportFrameSiteExclusive")) for row in operations.values()), {"SUPPORT_FRAME_SITE_OCCUPANCY", "SUPPORT_FRAME_SITE_IDENTITY"}),
("\u540a\u88c5\u80fd\u529b\u548c\u540a\u88c5\u7a97\u53e3", sum(bool(row.get("liftingRequired")) for row in operations.values()), {"LIFT_WINDOW", "CRANE_LIFT_RADIUS", "RESOURCE_WEIGHT"}),
("\u5206\u6bb5\u8fd0\u8f93\u8def\u5f84", sum(bool(row.get("transportPathRequired")) for row in operations.values()), {"TRANSPORT_PATH", "TRANSPORT_WINDOW", "TRANSPORT_ZONE"}),
("\u6d82\u88c5\u73af\u5883\u6761\u4ef6", sum(bool(row.get("paintingEnvironmentRequired")) for row in operations.values()), {"PAINT_ENVIRONMENT"}),
("\u8d28\u91cf Hold Point", sum(bool(row.get("holdPoint")) for row in operations.values()), {"QUALITY_HOLD"}),
("\u59d4\u5916\u5230\u8d27", len(outsource_suggestions), {"OUTSOURCE_TIME_CHAIN", "OUTSOURCE_RETURN_BEFORE_SLOT", "OUTSOURCE_RETURN_BEFORE_SUCCESSOR", "OUTSOURCE_SEND_AFTER_PREDECESSOR"}),
("\u4f9b\u5e94\u5546\u4ea7\u80fd", len(outsource_suggestions), {"SUPPLIER_MONTHLY_CAPACITY", "SUPPLIER_BLACKOUT"}),
("\u9879\u76ee\u51bb\u7ed3\u533a", sum(row.get("timeFence") == "FROZEN" for row in slots), {"FROZEN_ZONE"}),
("\u5df2\u5f00\u5de5\u4efb\u52a1\u4e0d\u53ef\u968f\u610f\u8fc1\u79fb", sum(bool(row.get("startedTaskImmutable")) for row in operations.values()), {"STARTED_TASK_IMMUTABLE"}),
("\u5df2\u4e0b\u53d1 MES \u4efb\u52a1\u4e0d\u53ef\u81ea\u52a8\u5220\u9664", sum(bool(row.get("mesDispatchProtected")) for row in work_orders.values()), {"MES_DISPATCH_DELETE"}),
("\u51bb\u7ed3\u5de5\u5355\u4e0d\u5141\u8bb8\u81ea\u52a8\u6539\u671f", sum(bool(row.get("frozenWorkOrderProtected")) for row in operations.values()), {"FROZEN_WORK_ORDER_RESCHEDULE"}),
("\u5206\u6bb5\u642d\u8f7d\u987a\u5e8f", sum(bool(row.get("erectionPredecessorOperationId")) for row in operations.values()), {"SECTION_ERECTION_SEQUENCE"}),
("\u603b\u6bb5\u7a7a\u95f4\u548c\u5e72\u6d89\u5173\u7cfb", sum(bool(row.get("spatialInterferenceGroup")) for row in operations.values()), {"GRAND_BLOCK_SPATIAL_INTERFERENCE", "SPATIAL_INTERFERENCE_IDENTITY"}),
("\u5371\u9669\u4f5c\u4e1a\u4e92\u65a5", sum(bool(row.get("hazardClass")) for row in operations.values()), {"HAZARD_INCOMPATIBILITY"}),
("\u540c\u533a\u57df\u591a\u4e13\u4e1a\u65bd\u5de5\u5bc6\u5ea6", sum(int(row.get("areaDensityUnits") or 0) > 0 for row in operations.values()), {"AREA_DENSITY"}),
("\u5173\u952e\u8bbe\u5907\u5230\u8d27\u6761\u4ef6", sum(bool(row.get("criticalEquipmentRequired")) for row in operations.values()), {"CRITICAL_EQUIPMENT_ARRIVAL"}),
("\u8239\u4e1c\u548c\u8239\u7ea7\u793e\u89c1\u8bc1\u65e5\u5386", sum(bool(row.get("witnessRequired")) for row in operations.values()), {"WITNESS_CALENDAR"}),
("\u4e0b\u6c34\u548c\u8bd5\u822a\u5929\u6c14\u7a97\u53e3", sum(bool(row.get("launchTrialWeatherRequired")) for row in operations.values()), {"LAUNCH_TRIAL_WEATHER"}),
)
business_constraint_coverage = {}
for business_name, sample_count, violation_codes in business_constraint_specs:
checks += 1
violation_count = sum(
row.get("constraint") in violation_codes for row in violations
)
business_constraint_coverage[business_name] = {
"positiveSampleCount": int(sample_count),
"violationCount": int(violation_count),
"violationCodes": sorted(violation_codes),
}
if sample_count <= 0:
_append_violation(
violations,
"BUSINESS_CONSTRAINT_COVERAGE",
business_name,
"baseline lacks a positive sample for this named business constraint",
)
blocking = [
f"{row['constraint']}:{row['entityId']}:{row['message']}" for row in violations
]
check_summary = {
"activeOperationCoverage": len(operations),
"scheduleSlotCount": len(slots),
"resourceTimelineCount": sum(1 for key in timelines if key[0] == "resource"),
"teamTimelineCount": sum(1 for key in timelines if key[0] == "team"),
"precedenceArcCount": sum(
bool(row.get("predecessorOperationId")) for row in operations.values()
),
"precedenceRelationCounts": {
relation_type: sum(row.get("relationType") == relation_type for row in operations.values())
for relation_type in ("FS", "SS", "FF", "SF")
},
"positiveLagCount": sum(float(row.get("lagHours") or 0.0) > 0 for row in operations.values()),
"negativeLagCount": sum(float(row.get("lagHours") or 0.0) < 0 for row in operations.values()),
"qualificationCheckCount": sum(bool(row.get("requiredQualificationCodes")) for row in operations.values()),
"physicalDimensionCheckCount": sum(bool(row.get("requiredLengthM")) for row in operations.values()),
"weightCheckCount": sum(bool(row.get("requiredWeightT")) for row in operations.values()),
"liftRadiusCheckCount": sum(bool(row.get("requiredLiftRadiusM")) for row in operations.values()),
"transportWindowCheckCount": sum(row.get("transportWindowStatus") == "OPEN" for row in operations.values()),
"hazardCheckCount": sum(bool(row.get("hazardClass")) for row in operations.values()),
"areaDensityCheckCount": sum(int(row.get("areaDensityUnits") or 0) > 0 for row in operations.values()),
"maintenanceBlackoutResourceCount": sum(bool(row.get("maintenanceBlackoutDates")) for row in resources.values()),
"weatherWindowCheckCount": sum(bool(row.get("weatherSensitive")) for row in operations.values()),
"outsourceChainCheckCount": len(outsource_suggestions),
"materialReadyCheckCount": len(parsed_slots),
"holdPointCheckCount": sum(bool(row.get("holdPoint")) for row in operations.values()),
"frozenSlotCheckCount": sum(row.get("timeFence") == "FROZEN" for row in slots),
"expectedResolvedRiskCount": len(conflicts),
"businessConstraintCoverage": business_constraint_coverage,
}
return {
"valid": not violations,
"solveStatus": "FEASIBLE" if not violations else "INFEASIBLE",
"checkCount": checks,
"hardViolationCount": len(violations),
"unmarkedHardViolationCount": len(violations),
"blockingIssues": blocking,
"violations": violations,
"checks": check_summary,
"businessConstraintCoverage": business_constraint_coverage,
"planningBaseDate": config.planning_base_date.isoformat(),
"planningHorizonEnd": config.planning_horizon_end.isoformat(),
}