diff --git a/apps/web/package.json b/apps/web/package.json index 9c8cd0b..39c5fb7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@atos/dispatch": "0.0.0", + "@atos/operations": "0.0.0", "@atos/power": "0.0.0", "@atos/scenario": "0.0.0", "@atos/scenario-editor": "0.0.0", diff --git a/apps/web/src/App.css b/apps/web/src/App.css index 9a048e2..9b9bd09 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -136,6 +136,39 @@ h1 { background: #ffffff; } +.deployment-footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: center; + border-top: 1px solid #ccd6d1; + padding: 20px 0 0; + color: #4d635d; + font-size: 0.9rem; + font-weight: 700; +} + +.deployment-footer a { + text-decoration: underline; + text-underline-offset: 3px; +} + +.deployment-footer a:focus-visible { + outline: 3px solid #d4a72c; + outline-offset: 3px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + clip-path: inset(50%); +} + .workspace-panel h2 { margin-bottom: 0; color: #1d3832; diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index fb5b8b5..e6094e1 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -11,6 +11,7 @@ describe("ATOS web shell", () => { "Capacity", "Dispatch", "Simulation", + "Operations", ]); }); @@ -32,11 +33,18 @@ describe("ATOS web shell", () => { expect(screen.getByRole("heading", { name: "DC Power Integrity" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Dispatch Planning Core" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Simulation Event Log" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Closed-Loop Operations" })).toBeInTheDocument(); expect(within(placeholderRegion).getByRole("heading", { name: "Capacity" })).toBeInTheDocument(); expect(within(placeholderRegion).queryByRole("heading", { name: "Layout" })).not.toBeInTheDocument(); expect(within(placeholderRegion).queryByRole("heading", { name: "Power" })).not.toBeInTheDocument(); expect(within(placeholderRegion).queryByRole("heading", { name: "Dispatch" })).not.toBeInTheDocument(); expect(within(placeholderRegion).queryByRole("heading", { name: "Simulation" })).not.toBeInTheDocument(); + expect(within(placeholderRegion).queryByRole("heading", { name: "Operations" })).not.toBeInTheDocument(); + expect(screen.getByRole("contentinfo", { name: /deployment freshness/i })).toHaveTextContent(/ATOS v/); + expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute( + "href", + "https://github.com/recklessnode/ATOS", + ); }); it("renders the loaded six-tile fixture counts", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3235818..bb6b4b3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,10 @@ import { useLayoutEffect } from "react"; import { getSixTileCitySummary } from "@atos/scenario"; import "./App.css"; +import { DeploymentFooter } from "./DeploymentFooter"; import { DispatchWorkspace } from "./features/dispatch-workspace"; import { LayoutEditor } from "./features/layout-editor"; +import { OperationsWorkspace } from "./features/operations-workspace"; import { PowerWorkspace } from "./features/power-workspace"; import { SimulationWorkspace } from "./features/simulation-workspace"; import { WORKSPACES } from "./workspaces"; @@ -68,9 +70,10 @@ export function App() { +
- {WORKSPACES.filter((workspace) => !["layout", "power", "dispatch", "simulation"].includes(workspace.id)).map((workspace) => ( + {WORKSPACES.filter((workspace) => !["layout", "power", "dispatch", "simulation", "operations"].includes(workspace.id)).map((workspace) => (

{workspace.status}

@@ -80,6 +83,7 @@ export function App() {
))}
+ ); } diff --git a/apps/web/src/DeploymentFooter.tsx b/apps/web/src/DeploymentFooter.tsx new file mode 100644 index 0000000..2606205 --- /dev/null +++ b/apps/web/src/DeploymentFooter.tsx @@ -0,0 +1,24 @@ +import { BUILD_METADATA, type BuildMetadata } from "./build-metadata"; + +type DeploymentFooterProps = { + metadata?: BuildMetadata; +}; + +export function DeploymentFooter({ metadata = BUILD_METADATA }: DeploymentFooterProps) { + return ( +
+ Deployed version: + ATOS v{metadata.version} + + commit {metadata.shortSha} + + + + + GitHub + +
+ ); +} diff --git a/apps/web/src/build-metadata.test.tsx b/apps/web/src/build-metadata.test.tsx new file mode 100644 index 0000000..f7cb9fc --- /dev/null +++ b/apps/web/src/build-metadata.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DeploymentFooter } from "./DeploymentFooter"; +import { resolveBuildMetadata } from "./build-metadata"; + +describe("deployment freshness metadata", () => { + it("resolves build-time version, short SHA, commit date, and repository URL", () => { + const metadata = resolveBuildMetadata({ + version: "0.5.0", + commitSha: "b0928ac123456789", + commitDate: "2026-07-11T12:34:56Z", + repositoryUrl: "https://github.com/recklessnode/ATOS", + source: "github-pages", + }); + + expect(metadata.version).toBe("0.5.0"); + expect(metadata.shortSha).toBe("b0928ac"); + expect(metadata.commitDate).toBe("07/11/2026"); + expect(metadata.label).toContain("ATOS v0.5.0"); + }); + + it("uses truthful local-development fallbacks", () => { + const metadata = resolveBuildMetadata({}); + + expect(metadata.version).toBe("0.0.0"); + expect(metadata.shortSha).toBe("unknown"); + expect(metadata.commitDate).toBe("unknown date"); + expect(metadata.source).toBe("dev"); + }); + + it("renders the persistent repository footer accessibly", () => { + render(); + + const footer = screen.getByRole("contentinfo", { name: /deployment freshness/i }); + expect(footer).toHaveTextContent("ATOS v0.5.0"); + expect(footer).toHaveTextContent("commit b0928ac"); + expect(footer).toHaveTextContent("07/11/2026"); + expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute( + "href", + "https://github.com/recklessnode/ATOS", + ); + }); +}); diff --git a/apps/web/src/build-metadata.ts b/apps/web/src/build-metadata.ts new file mode 100644 index 0000000..b99d665 --- /dev/null +++ b/apps/web/src/build-metadata.ts @@ -0,0 +1,53 @@ +export type RawBuildMetadata = { + version?: string; + shortSha?: string; + commitSha?: string; + commitDate?: string; + repositoryUrl?: string; + source?: string; +}; + +export type BuildMetadata = { + version: string; + shortSha: string; + commitDate: string; + repositoryUrl: string; + label: string; + source: string; +}; + +export function resolveBuildMetadata(raw: RawBuildMetadata = {}): BuildMetadata { + const version = raw.version?.trim() || "0.0.0"; + const commitSha = raw.shortSha?.trim() || raw.commitSha?.slice(0, 7) || "unknown"; + const commitDate = readableDate(raw.commitDate); + const repositoryUrl = raw.repositoryUrl?.trim() || "https://github.com/recklessnode/ATOS"; + const source = raw.source?.trim() || "dev"; + return { + version, + shortSha: commitSha, + commitDate, + repositoryUrl, + source, + label: `ATOS v${version} · commit ${commitSha} · ${commitDate} · GitHub`, + }; +} + +export const BUILD_METADATA = resolveBuildMetadata( + typeof __ATOS_BUILD_METADATA__ === "undefined" ? {} : __ATOS_BUILD_METADATA__, +); + +function readableDate(value: string | undefined): string { + if (!value || value === "unknown") { + return "unknown date"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "unknown date"; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone: "UTC", + }).format(date); +} diff --git a/apps/web/src/features/operations-workspace/OperationsWorkspace.css b/apps/web/src/features/operations-workspace/OperationsWorkspace.css new file mode 100644 index 0000000..109d74a --- /dev/null +++ b/apps/web/src/features/operations-workspace/OperationsWorkspace.css @@ -0,0 +1,231 @@ +.operations-workspace { + display: grid; + gap: 18px; + border-bottom: 1px solid #ccd6d1; + padding: 28px 0; +} + +.operations-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.82fr); + gap: 18px; + align-items: end; +} + +.operations-heading h2 { + margin-bottom: 0; + color: #17332e; + font-size: 1.75rem; +} + +.operations-heading p:last-child { + margin-bottom: 0; + color: #465b55; +} + +.operations-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.operations-controls button, +.operations-list button, +.operations-panel button { + min-height: 38px; + border: 1px solid #8ea9a0; + border-radius: 8px; + padding: 8px 12px; + background: #ffffff; + color: #17332e; + font-weight: 700; +} + +.operations-controls button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.operations-summary { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.operations-metric, +.operations-panel { + border: 1px solid #ccd6d1; + border-radius: 8px; + background: #ffffff; +} + +.operations-metric { + min-height: 96px; + padding: 14px; +} + +.operations-metric p, +.operations-metric span { + margin: 0; + color: #61736e; + font-size: 0.82rem; + font-weight: 700; +} + +.operations-metric strong { + display: block; + margin: 4px 0; + color: #1c302b; + font-size: 1.25rem; +} + +.operations-map-panel { + min-height: 520px; +} + +.operations-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.operations-grid-wide { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.operations-panel { + min-width: 0; + padding: 14px; +} + +.operations-panel-heading { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.operations-panel-heading h3 { + margin: 0; + color: #1d3933; + font-size: 1rem; +} + +.operations-panel-heading span { + min-width: 30px; + border-radius: 999px; + padding: 4px 8px; + background: #e8efec; + color: #263b35; + font-size: 0.82rem; + font-weight: 800; + text-align: center; +} + +.operations-list, +.operations-metrics-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.operations-list.compact { + gap: 8px; +} + +.operations-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + border-left: 4px solid #6e9388; + border-radius: 8px; + padding: 10px; + background: #f8faf8; +} + +.operations-list li.cancelled, +.operations-list li.failed, +.operations-list li.worsened, +.operations-list li.open { + border-left-color: #9c3d35; +} + +.operations-list li.delayed, +.operations-list li.deferred, +.operations-list li.changed, +.operations-list li.transformed { + border-left-color: #b58921; +} + +.operations-list li.replanned, +.operations-list li.replacement, +.operations-list li.resolved, +.operations-list li.unchanged { + border-left-color: #3e7e65; +} + +.operations-list strong, +.operations-list span, +.operations-list small { + display: block; +} + +.operations-list strong { + color: #1c302b; +} + +.operations-list span { + color: #40544e; +} + +.operations-list small, +.empty-state { + color: #61736e; +} + +.operations-metrics-list { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.operations-metrics-list div { + border: 1px solid #d7e0dc; + border-radius: 8px; + padding: 10px; + background: #f8faf8; +} + +.operations-metrics-list dt { + color: #61736e; + font-size: 0.78rem; + font-weight: 800; +} + +.operations-metrics-list dd { + margin: 4px 0 0; + color: #1d3933; + font-size: 1.15rem; + font-weight: 800; +} + +@media (max-width: 760px) { + .operations-heading, + .operations-grid, + .operations-grid-wide, + .operations-summary, + .operations-metrics-list { + grid-template-columns: 1fr; + } + + .operations-map-panel { + min-height: 460px; + } + + .operations-list li { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/features/operations-workspace/OperationsWorkspace.test.tsx b/apps/web/src/features/operations-workspace/OperationsWorkspace.test.tsx new file mode 100644 index 0000000..8a45784 --- /dev/null +++ b/apps/web/src/features/operations-workspace/OperationsWorkspace.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { OperationsWorkspace } from "./OperationsWorkspace"; + +describe("OperationsWorkspace", () => { + it("renders closed-loop operations panels and map", () => { + render(); + + const workspace = screen.getByRole("region", { name: "Operations workspace" }); + expect(within(workspace).getByRole("heading", { name: "Closed-Loop Operations" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Operations controls" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Operations summary" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Planning generations" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Pending requests and policy decisions" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Plan diff" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Reservation reconciliation" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Incident correlation" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Deficiency carry-forward" })).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Operations metrics" })).toBeInTheDocument(); + expect(within(workspace).getByLabelText("Operations scenario map")).toBeInTheDocument(); + }); + + it("applies pending replans and manual deterministic replans", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Apply pending replan" })); + expect(screen.getByRole("region", { name: "Plan diff" })).not.toHaveTextContent("No revised planning generation"); + expect(screen.getByRole("region", { name: "Reservation reconciliation" })).not.toHaveTextContent("No reservation reconciliation"); + + fireEvent.click(screen.getByRole("button", { name: "Manual deterministic replan" })); + expect(screen.getAllByText(/planning-generation:operations:scenario-six-tile-city-v1:2/).length).toBeGreaterThan(0); + }); + + it("focuses incident targets on the shared map by stable ID", () => { + render(); + + const incidentPanel = screen.getByRole("region", { name: "Incident correlation" }); + fireEvent.click(within(incidentPanel).getAllByRole("button", { name: /Focus incident/ })[0]); + + const details = screen.getByLabelText("Selection details"); + expect(details).not.toHaveTextContent("No object selected"); + }); + + it("keeps Operations usable at phone width", () => { + const originalWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + + render(); + + const workspace = screen.getByRole("region", { name: "Operations workspace" }); + expect(within(workspace).getByRole("region", { name: "Operations controls" })).toBeInTheDocument(); + expect(within(workspace).getByLabelText("Operations scenario map")).toBeInTheDocument(); + expect(within(workspace).getByRole("region", { name: "Operations metrics" })).toBeInTheDocument(); + + Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth }); + }); +}); diff --git a/apps/web/src/features/operations-workspace/OperationsWorkspace.tsx b/apps/web/src/features/operations-workspace/OperationsWorkspace.tsx new file mode 100644 index 0000000..8569f29 --- /dev/null +++ b/apps/web/src/features/operations-workspace/OperationsWorkspace.tsx @@ -0,0 +1,472 @@ +import { useMemo, useRef, useState } from "react"; +import { + createDefaultOperationsSession, + performOperationsReplan, + requestManualReplan, + type OperationsIncident, + type OperationsSession, +} from "@atos/operations"; +import { + ScenarioMap, + type ScenarioMapFocusRequest, + type ScenarioMapLiveOverlay, +} from "../scenario-map"; +import { buildScenarioMapRenderModel, type ScenarioMapRenderModel } from "../scenario-map/render-model"; +import { selectionKey, type ScenarioSelection } from "../scenario-map/selection"; +import "./OperationsWorkspace.css"; + +type OperationsWorkspaceProps = { + sessionOverride?: OperationsSession; +}; + +export function OperationsWorkspace({ sessionOverride }: OperationsWorkspaceProps) { + const [session, setSession] = useState(() => sessionOverride ?? createDefaultOperationsSession()); + const focusRequestId = useRef(0); + const [focusRequest, setFocusRequest] = useState(); + const runtime = session.runtime; + const mapModel = useMemo(() => { + const base = buildScenarioMapRenderModel(runtime.scenario); + return { ...base, layers: { ...base.layers, guideway: true, stations: true, electrical: true, diagnostics: true } }; + }, [runtime.scenario]); + const liveOverlay = useMemo(() => ({ + occupancies: [ + ...runtime.guidewayOccupancy.map((occupancy) => ({ + id: occupancy.id, + kind: "guideway" as const, + resourceId: `guideway-link:${occupancy.linkId}`, + missionId: occupancy.missionId, + label: `${occupancy.linkId} occupied by ${occupancy.missionId}`, + })), + ...runtime.serviceOccupancy.map((occupancy) => ({ + id: occupancy.id, + kind: "service" as const, + resourceId: occupancy.resourceId, + missionId: occupancy.missionId, + action: occupancy.action, + label: `${occupancy.resourceId} ${occupancy.action} by ${occupancy.missionId}`, + })), + ], + }), [runtime.guidewayOccupancy, runtime.serviceOccupancy]); + + function focusSelection(selection: ScenarioSelection | null): void { + if (!selection) { + return; + } + focusRequestId.current += 1; + setFocusRequest({ selection, requestId: focusRequestId.current }); + } + + function focusByIds(ids: readonly string[]): void { + focusSelection(selectionForIds(ids, mapModel, session)); + } + + function runManualReplan(): void { + setSession((current) => + performOperationsReplan( + requestManualReplan(current, { note: "Operator requested a deterministic manual replan from the Operations workspace." }), + ) + ); + } + + function runPendingReplan(): void { + setSession((current) => performOperationsReplan(current)); + } + + return ( +
+
+
+

Operations

+

Closed-Loop Operations

+
+

+ Deterministic orchestration between dispatch planning and simulation execution: + replan policy, runtime projection, reservation reconciliation, plan diffs, + incidents, deficiencies, and operating metrics. +

+
+ +
+ + +
+ + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ ); +} + +function OperationsSummary({ session }: { session: OperationsSession }) { + const generation = currentGeneration(session); + const activeMissions = session.runtime.missions.filter((mission) => + !["completed", "failed", "cancelled", "blocked"].includes(mission.state) + ).length; + return ( +
+ + + + + + +
+ ); +} + +function MetricCard({ detail, label, value }: { detail?: string; label: string; value: string }) { + return ( +
+

{label}

+ {value} + {detail ? {detail} : null} +
+ ); +} + +function GenerationPanel({ session }: { session: OperationsSession }) { + return ( +
+ +
    + {session.generations.map((generation) => ( +
  • +
    + {generation.id} + {generation.dispatchResult.missionPlans.length} missions · {generation.dispatchResult.reservations.length} reservations + {generation.policyDecision?.mode ?? "initial"} · {formatTime(generation.createdAt)} +
    +
  • + ))} +
+
+ ); +} + +function RequestPanel({ + onFocus, + session, +}: { + onFocus: (ids: readonly string[]) => void; + session: OperationsSession; +}) { + const requests = session.pendingRequests.length > 0 ? session.pendingRequests : session.policyDecisions.map((decision) => ({ + id: decision.requestId, + source: "operator" as const, + trigger: decision.trigger, + status: "requested" as const, + currentTime: session.runtime.clock.currentTime, + triggeredByEventId: decision.requestId, + chitIds: decision.scopeChitIds, + affectedAssetIds: decision.scopeAssetIds, + releasedReservationIds: [], + retainedReservationIds: [], + assetStates: [], + reason: decision.rationale, + })); + return ( +
+ +
    + {requests.map((request) => { + const decision = session.policyDecisions.find((candidate) => candidate.requestId === request.id); + return ( +
  • +
    + {request.id} + {request.trigger} · {decision?.mode ?? "unclassified"} + {decision?.rationale ?? request.reason} +
    + +
  • + ); + })} +
+
+ ); +} + +function PlanDiffPanel({ + onFocus, + session, +}: { + onFocus: (ids: readonly string[]) => void; + session: OperationsSession; +}) { + return ( +
+ + {session.planDiff.records.length === 0 ? ( +

No revised planning generation has been produced yet.

+ ) : ( +
    + {session.planDiff.records.map((record) => ( +
  • +
    + {record.status} + {record.previousMissionId ?? "new"} → {record.revisedMissionId ?? "cancelled"} + {record.rationale} +
    + +
  • + ))} +
+ )} +
+ ); +} + +function ReservationReconciliationPanel({ + onFocus, + session, +}: { + onFocus: (ids: readonly string[]) => void; + session: OperationsSession; +}) { + return ( +
+ + {session.reservationReconciliation.records.length === 0 ? ( +

No reservation reconciliation has been run yet.

+ ) : ( +
+ + + + + + + + + + + {session.reservationReconciliation.records.map((record) => ( + + + + + + + ))} + +
ResourceStatusMissionFocus
{record.resourceId}{record.status}{record.missionPlanId} + +
+
+ )} +
+ ); +} + +function IncidentPanel({ + onFocus, + session, +}: { + onFocus: (ids: readonly string[]) => void; + session: OperationsSession; +}) { + return ( +
+ +
    + {session.incidents.map((incident) => ( + + ))} +
+
+ ); +} + +function IncidentItem({ + incident, + onFocus, +}: { + incident: OperationsIncident; + onFocus: (ids: readonly string[]) => void; +}) { + return ( +
  • +
    + {incident.resolutionState} + {incident.id} + {incident.summary} +
    + +
  • + ); +} + +function DeficiencyPanel({ + onFocus, + session, +}: { + onFocus: (ids: readonly string[]) => void; + session: OperationsSession; +}) { + return ( +
    + + {session.deficiencyCarryForward.records.length === 0 ? ( +

    No deficiency carry-forward comparison has been run yet.

    + ) : ( +
      + {session.deficiencyCarryForward.records.map((record) => ( +
    • +
      + {record.status} + {record.kind.replaceAll("_", " ")} + {record.rationale} +
      + +
    • + ))} +
    + )} +
    + ); +} + +function MetricsPanel({ session }: { session: OperationsSession }) { + const metrics = session.metrics; + return ( +
    + +
    + + + + + + + + + + +
    +
    + ); +} + +function MetricRow({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +function PanelHeading({ count, title }: { count: number; title: string }) { + return ( +
    +

    {title}

    + {count} +
    + ); +} + +function currentGeneration(session: OperationsSession) { + return session.generations.find((generation) => generation.id === session.currentGenerationId) ?? session.generations[0]; +} + +function selectionForIds( + ids: readonly string[], + model: ScenarioMapRenderModel, + session: OperationsSession, +): ScenarioSelection | null { + for (const rawId of ids.filter(Boolean)) { + const normalizedId = normalizeResourceId(rawId); + const runtimeAsset = session.runtime.assets.find((asset) => asset.assetId === normalizedId); + const candidates: ScenarioSelection[] = [ + { kind: "serviceZone", id: normalizedId }, + { kind: "station", id: normalizedId }, + { kind: "tile", id: normalizedId }, + { kind: "guidewayLink", id: normalizedId }, + { kind: "guidewayNode", id: normalizedId }, + { kind: "electricalNode", id: normalizedId }, + { kind: "electricalBranch", id: normalizedId }, + { kind: "electricalSource", id: normalizedId }, + { kind: "electricalLoad", id: normalizedId }, + ...(runtimeAsset?.tileId ? [{ kind: "tile" as const, id: runtimeAsset.tileId }] : []), + ...(runtimeAsset?.serviceZoneId ? [{ kind: "serviceZone" as const, id: runtimeAsset.serviceZoneId }] : []), + ...(runtimeAsset?.nodeId ? [{ kind: "guidewayNode" as const, id: runtimeAsset.nodeId }] : []), + ]; + const selection = candidates.find((candidate) => model.boundsByKey.has(selectionKey(candidate))); + if (selection) { + return selection; + } + } + return null; +} + +function normalizeResourceId(id: string): string { + return id + .replace(/^asset:/, "") + .replace(/^guideway-link:/, "") + .replace(/^station-zone:/, "") + .replace(/^power-window:/, ""); +} + +function formatTime(value: string): string { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(value)); +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(value); +} + +function formatPercent(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, style: "percent" }).format(value); +} diff --git a/apps/web/src/features/operations-workspace/index.ts b/apps/web/src/features/operations-workspace/index.ts new file mode 100644 index 0000000..ff04fa0 --- /dev/null +++ b/apps/web/src/features/operations-workspace/index.ts @@ -0,0 +1 @@ +export * from "./OperationsWorkspace"; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 11f02fe..0e0f2ba 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __ATOS_BUILD_METADATA__: import("./build-metadata").RawBuildMetadata; diff --git a/apps/web/src/workspaces.ts b/apps/web/src/workspaces.ts index 8872705..cc51969 100644 --- a/apps/web/src/workspaces.ts +++ b/apps/web/src/workspaces.ts @@ -1,6 +1,6 @@ export type Workspace = { - id: "layout" | "power" | "capacity" | "dispatch" | "simulation"; - name: "Layout" | "Power" | "Capacity" | "Dispatch" | "Simulation"; + id: "layout" | "power" | "capacity" | "dispatch" | "simulation" | "operations"; + name: "Layout" | "Power" | "Capacity" | "Dispatch" | "Simulation" | "Operations"; summary: string; status: string; }; @@ -36,4 +36,10 @@ export const WORKSPACES = [ summary: "Deterministic event clock, mission execution, occupancy, faults, energy, and replay.", status: "Implemented", }, + { + id: "operations", + name: "Operations", + summary: "Closed-loop replanning orchestration, plan diffs, incidents, metrics, and operator review.", + status: "Implemented", + }, ] as const satisfies readonly Workspace[]; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d6fe9b4..f5b84ad 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,8 +1,37 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const rootPackage = JSON.parse(readFileSync(resolve(rootDir, "package.json"), "utf8")) as { version?: string }; + +function gitValue(command: string, fallback: string): string { + try { + return execSync(command, { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || fallback; + } catch { + return fallback; + } +} + +const commitSha = process.env.VITE_COMMIT_SHA ?? process.env.GITHUB_SHA ?? gitValue("git rev-parse HEAD", "unknown"); +const commitDate = process.env.VITE_COMMIT_DATE ?? gitValue("git show -s --format=%cI HEAD", "unknown"); +const buildMetadata = { + version: process.env.VITE_ATOS_VERSION ?? rootPackage.version ?? "0.0.0", + shortSha: commitSha === "unknown" ? "unknown" : commitSha.slice(0, 7), + commitSha, + commitDate, + repositoryUrl: "https://github.com/recklessnode/ATOS", + source: process.env.GITHUB_ACTIONS ? "github-pages" : "local", +}; export default defineConfig({ base: process.env.VITE_BASE_PATH ?? "/", + define: { + __ATOS_BUILD_METADATA__: JSON.stringify(buildMetadata), + }, plugins: [react()], test: { environment: "jsdom", diff --git a/docs/provenance/ai-contributions.ndjson b/docs/provenance/ai-contributions.ndjson index 4f07752..5cb30c5 100644 --- a/docs/provenance/ai-contributions.ndjson +++ b/docs/provenance/ai-contributions.ndjson @@ -19,3 +19,6 @@ {"schema_version":1,"commit_sha":"eb6480a70ab44c38d3eb861e7b438af07d41370e","timestamp":"2026-07-16T23:05:03-07:00","issue_numbers":[17],"pull_request_number":20,"requested_role":"Luna-style cleanup for generated cache artifacts","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"repository hygiene","summary":"Ignored Python cache artifacts produced by CAD validation tooling without changing S1 CAD behavior or acceptance semantics.","files_or_packages":[".gitignore"],"assumptions":["Python bytecode caches should not be tracked as part of the printable prototype kit."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment."],"tests_run":["not separately recorded for this commit"],"human_review_status":"Draft PR #20 pending human review; Issue #17 remains open"} {"schema_version":1,"commit_sha":"self-referential-unavailable-in-own-content","timestamp":"2026-07-16T23:50:31-07:00","issue_numbers":[17],"pull_request_number":20,"requested_role":"PR #20 audit remediation and provenance follow-up","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"audit remediation, known-gap documentation, preview generation, validation reporting, and provenance documentation","summary":"Documented the known non-manifold split-STL gap, changed CAD validation reporting from unqualified PASS to CONDITIONAL PASS when only known split mesh gaps remain, added STL-derived SVG previews for major CAD parts, updated the CAD README, and added machine-readable AI provenance records for PR #20.","files_or_packages":["cad/s1/README.md","cad/s1/asset-report.md","cad/s1/known-mesh-gaps.md","cad/s1/previews","tools/cad/s1_generate_and_validate.py","docs/provenance/ai-contributions.ndjson"],"assumptions":["The known split mesh gap should remain visible and documented rather than silently waived.","The full reference STLs are still the canonical visual and envelope geometry.","Chromium should not be used in this environment, so previews are generated as STL-derived SVGs from mesh vertices.","A commit cannot embed its own final SHA in a tracked file without an additional follow-up commit or history rewrite, so this record uses a truthful self-reference marker and commit-message trailers provide Git-level metadata."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment.","The known non-manifold split STLs still require hand repair, native closed-half CAD redesign, a more capable CAD/mesh pipeline, or additional geometry-focused model effort before Issue #17 can claim full watertight split-part acceptance.","This record's commit SHA is intentionally self-referential because the final SHA is computed from the file content."],"tests_run":["python3 -m pip install --user --break-system-packages -r tools/cad/requirements.txt","python3 tools/cad/s1_generate_and_validate.py","python3 tools/cad/s1_generate_and_validate.py --no-render","XML parse validation for cad/s1/previews/*.svg","npm test","npm run lint","npm run build","VITE_BASE_PATH=/ATOS/ npm run build"],"human_review_status":"Draft PR #20 pending human review; Issue #17 remains open"} {"schema_version":1,"commit_sha":"self-referential-unavailable-in-own-content","timestamp":"2026-07-17T00:32:59-07:00","issue_numbers":[17],"pull_request_number":20,"requested_role":"PR #20 audit remediation with Sol-style CAD validation review, Terra-style implementation, and Luna-style documentation/provenance cleanup","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"CAD mesh remediation, split alignment sockets, interface fit validation, documentation, and provenance","summary":"Resolved the remaining PR #20 audit blockers by repairing generated split STLs through manifold3d, making acceptance validation fail by default on missing watertight proof, implementing split alignment receiver sockets, sharing mount-pin and latch-slot geometry across module bases and the standalone interface plate, updating the CAD documentation to remove active known-gap waivers, and recording provenance for the follow-up commit.","files_or_packages":["cad/s1","tools/cad","docs/provenance/ai-contributions.ndjson"],"assumptions":["Issue #17 still expects 220 x 220 mm common-bed split assets, so the remediation preserves that target instead of switching to a larger print bed.","OpenSCAD remains the editable source of record while manifold3d is used as a deterministic STL post-processing step for split outputs.","A commit cannot embed its own final SHA in a tracked file without an additional follow-up commit or history rewrite, so this record uses a truthful self-reference marker and commit-message trailers provide Git-level metadata."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment.","OpenSCAD may still warn on raw clipped split geometry before the validator's manifold3d normalization step; acceptance is based on the final generated STLs and report.","This record's commit SHA is intentionally self-referential because the final SHA is computed from the file content."],"tests_run":["python3 -m pip install --user --break-system-packages manifold3d","python3 tools/cad/s1_generate_and_validate.py","python3 tools/cad/s1_generate_and_validate.py --no-render","direct trimesh watertight sweep for cad/s1/stl/*.stl","XML parse validation for cad/s1/previews/*.svg","npm test","npm run lint","npm run build","VITE_BASE_PATH=/ATOS/ npm run build"],"human_review_status":"Draft PR #20 pending human review after audit remediation; Issue #17 remains open"} +{"schema_version":1,"commit_sha":"3452c03b6d99e2f4cea4f5b994b0ae4f83f19bbe","timestamp":"2026-07-17T02:19:01-07:00","issue_numbers":[15,13,12,2],"pull_request_number":24,"requested_role":"Sol-style operations architecture review; Terra-style operations package and workspace implementation; Luna-style selectors, accessibility labels, responsive CSS, and deterministic tests","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"React-free operations orchestration package, Operations workspace, deployment freshness footer, and tests","summary":"Added the @atos/operations package with deterministic operations sessions, replanning policy, runtime-to-dispatch projection, reservation reconciliation, plan diffs, deficiency carry-forward, incident correlation, operations metrics, manual replanning orchestration, a responsive Operations workspace, build metadata footer, and versioning policy documentation.","files_or_packages":["packages/operations","apps/web/src/features/operations-workspace","apps/web/src/DeploymentFooter.tsx","apps/web/src/build-metadata.ts","apps/web/src/build-metadata.test.tsx","apps/web/src/App.tsx","apps/web/src/workspaces.ts","apps/web/vite.config.ts","docs/versioning.md","package.json","package-lock.json"],"assumptions":["Dispatch and simulation are merged into main and define the stable planner/runtime boundary for Issue #15.","Operations should preserve historical scenarios and mission plans while projecting runtime state into new dispatch inputs.","Partial replans can record affected scope while delegating candidate planning to the existing deterministic dispatch planner over projected active demand.","Build freshness metadata should be injected by Vite from Git/environment data and fall back truthfully in local builds."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment.","Partial replans record affected scope but currently use the existing deterministic planner over the projected queue rather than a specialized partial-planner algorithm.","No browser screenshots were recaptured and Chromium was not used in this environment per maintainer guidance.","Vite reports the existing large chunk warning after minification, but the build succeeds."],"tests_run":["npm run test --workspace @atos/operations","npm run build --workspace @atos/operations","npm run test --workspace @atos/web","npm run build --workspace @atos/web","npm run lint","npm run build","npm test","VITE_BASE_PATH=/ATOS/ npm run build","git diff --check"],"human_review_status":"Draft PR #24 pending human review; Issue #15 remains open until merge"} +{"schema_version":1,"commit_sha":"self-referential-unavailable-in-own-content","timestamp":"2026-07-17T02:20:23-07:00","issue_numbers":[15],"pull_request_number":24,"requested_role":"provenance follow-up","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"provenance documentation","summary":"Recorded machine-readable AI contribution provenance for the Issue #15 operations-orchestration PR without changing operations behavior, architecture, UI, tests, or screenshots.","files_or_packages":["docs/provenance/ai-contributions.ndjson"],"assumptions":["A commit cannot embed its own final SHA in a tracked file without an additional follow-up commit or history rewrite, so this provenance commit uses a truthful self-reference marker and commit-message trailers provide Git-level metadata.","No source-file authorship headers should be added.","The exact runtime model identifier is not exposed by this Codex environment."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment.","This record's commit SHA is intentionally self-referential because the final SHA is computed from the file content.","No behavior, UI, tests, architecture, or screenshots were changed by this provenance-only commit."],"tests_run":["npm test","npm run lint","npm run build","VITE_BASE_PATH=/ATOS/ npm run build","NDJSON validation with node --input-type=module -e"],"human_review_status":"Draft PR #24 pending human review"} +{"schema_version":1,"commit_sha":"self-referential-unavailable-in-own-content","timestamp":"2026-07-17T03:16:37-07:00","issue_numbers":[15,13,12,2],"pull_request_number":24,"requested_role":"PR #24 audit remediation with Sol-style operations architecture review, Terra-style package implementation, and Luna-style regression coverage","actual_model":"inherited-model-unreported","model_override_available":false,"execution_environment":"Codex in repository recklessnode/ATOS","contribution_type":"audit remediation, runtime constraint projection, partial replanning, reservation gating, metrics, handoff, and deterministic tests","summary":"Addressed the PR #24 audit blockers by passing runtime occupancy, retained reservations, outages, active assets, power constraints, and remaining chit quantities into dispatch; scoping partial replans while preserving unaffected plans and reservations; blocking promotion when reconciliation reports capacity-exceeding duplicate reservation ownership; resolving incident reservation IDs to resource IDs; replacing placeholder metrics with chit/event-derived calculations; and handing the session runtime to the promoted dispatch generation.","files_or_packages":["packages/dispatch/src/planner.ts","packages/dispatch/src/types.ts","packages/operations/src/state-projection.ts","packages/operations/src/orchestration.ts","packages/operations/src/reservation-reconciliation.ts","packages/operations/src/incident-correlation.ts","packages/operations/src/metrics.ts","packages/operations/src/operations.test.ts","packages/operations/src/types.ts"],"assumptions":["The existing dispatch planner remains the canonical deterministic planner, with runtime constraints supplied through planner options instead of adding a separate planner implementation.","Partial replans should preserve unaffected mission plans and reservations while replanning only the scoped chit/mission set against retained runtime resources.","Capacity-aware duplicate reservation ownership should block promotion only when overlapping owners exceed the known resource capacity; power-window reservations remain intentionally shareable.","A commit cannot embed its own final SHA in a tracked file without an additional follow-up commit or history rewrite, so this record uses a truthful self-reference marker and commit-message trailers provide Git-level metadata."],"known_limitations":["The exact runtime model identifier was not exposed by the Codex environment.","The runtime handoff preserves compatible in-progress execution state and future events but does not add a new simulator execution model or hidden dispatch replanning.","No browser screenshots were recaptured and Chromium was not used in this environment per maintainer guidance.","Vite reports the existing large chunk warning after minification, but both builds succeed."],"tests_run":["npm test -w @atos/operations","npm run lint","npm test","npm run build","VITE_BASE_PATH=/ATOS/ npm run build"],"human_review_status":"Draft PR #24 pending human review after audit remediation"} diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..43ad5a9 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,11 @@ +# ATOS Prototype Versioning + +ATOS uses the root `package.json` version as the visible prototype build version. Vite injects that value, the current commit SHA, and the commit date at build time so a deployed static page can identify exactly what code it represents. + +The prototype version is deterministic for a given build, but it is not a public API stability guarantee. Until the API is mature, version changes are lightweight: + +- patch versions cover focused fixes, validation improvements, and small UI or documentation updates; +- minor versions cover new prototype workspaces, packages, scenario capabilities, or visible workflow slices; +- major versions are reserved for future schema or public API breaks once ATOS declares stable external contracts. + +Local development builds use truthful fallbacks such as `unknown` for unavailable commit metadata. Pull requests should record the visible version and relevant build metadata in provenance and PR summaries when deployment behavior changes. diff --git a/package-lock.json b/package-lock.json index d03a115..0c736f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "version": "0.0.0", "dependencies": { "@atos/dispatch": "0.0.0", + "@atos/operations": "0.0.0", "@atos/power": "0.0.0", "@atos/scenario": "0.0.0", "@atos/scenario-editor": "0.0.0", @@ -114,6 +115,10 @@ "resolved": "packages/layout", "link": true }, + "node_modules/@atos/operations": { + "resolved": "packages/operations", + "link": true + }, "node_modules/@atos/power": { "resolved": "packages/power", "link": true @@ -3931,6 +3936,16 @@ "@atos/domain": "0.0.0" } }, + "packages/operations": { + "name": "@atos/operations", + "version": "0.0.0", + "dependencies": { + "@atos/dispatch": "0.0.0", + "@atos/domain": "0.0.0", + "@atos/scenario": "0.0.0", + "@atos/simulation": "0.0.0" + } + }, "packages/power": { "name": "@atos/power", "version": "0.0.0" diff --git a/package.json b/package.json index 2072420..100c65f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "packages/*" ], "scripts": { - "build": "npm run build --workspace @atos/domain && npm run build --workspace @atos/layout && npm run build --workspace @atos/scenario && npm run build --workspace @atos/power && npm run build --workspace @atos/dispatch && npm run build --workspace @atos/simulation && npm run build --workspace @atos/scenario-editor && npm run build --workspace @atos/web", + "build": "npm run build --workspace @atos/domain && npm run build --workspace @atos/layout && npm run build --workspace @atos/scenario && npm run build --workspace @atos/power && npm run build --workspace @atos/dispatch && npm run build --workspace @atos/simulation && npm run build --workspace @atos/operations && npm run build --workspace @atos/scenario-editor && npm run build --workspace @atos/web", "cad:s1": "python3 tools/cad/s1_generate_and_validate.py", "lint": "eslint . --max-warnings=0", "test": "npm run test --workspaces --if-present" diff --git a/packages/dispatch/src/planner.ts b/packages/dispatch/src/planner.ts index f6f1087..19205ad 100644 --- a/packages/dispatch/src/planner.ts +++ b/packages/dispatch/src/planner.ts @@ -33,6 +33,14 @@ type CandidateEvaluation = DispatchCandidate & { supportWorkers: DispatchWorker[]; }; +type NormalizedRuntimeConstraints = { + allowedChitIds: ReadonlySet; + retainedReservations: DispatchReservation[]; + unavailableResourceIds: ReadonlySet; + unavailableAssetIds: ReadonlySet; + powerConstraintIds: StableId[]; +}; + export function createDispatchPlannerInput( scenario: ScenarioDocumentV1, options: DispatchPlannerOptions = {}, @@ -52,20 +60,39 @@ export function createDispatchPlannerInput( }; } +function normalizeRuntimeConstraints( + constraints: DispatchPlannerOptions["runtimeConstraints"] | undefined, +): NormalizedRuntimeConstraints { + const unavailableResourceIds = new Set(constraints?.unavailableResourceIds ?? []); + const unavailableAssetIds = new Set(constraints?.unavailableAssetIds ?? []); + return { + allowedChitIds: new Set(constraints?.allowedChitIds ?? []), + retainedReservations: [...(constraints?.retainedReservations ?? [])] + .map((reservation) => ({ ...reservation })) + .sort(compareById), + unavailableResourceIds, + unavailableAssetIds, + powerConstraintIds: [...new Set(constraints?.powerConstraintIds ?? [])].sort(), + }; +} + export function planDispatch(input: DispatchPlannerInput): DispatchPlannerResult { const scenario = input.scenario; const generatedAt = input.options?.currentTime ?? scenario.simulation.currentTime; const powerAnalysis = input.powerAnalysis ?? analyzePowerNetwork(input.electrical); + const runtimeConstraints = normalizeRuntimeConstraints(input.options?.runtimeConstraints); const normalizedChits = [ ...normalizeDispatchChits(scenario.chits, generatedAt), ...(input.options?.generatedChits ?? []), - ].sort((left, right) => { + ].filter((chit) => + runtimeConstraints.allowedChitIds.size === 0 || runtimeConstraints.allowedChitIds.has(chit.id) + ).sort((left, right) => { const scoreCompare = right.rankScore - left.rankScore; return scoreCompare === 0 ? left.id.localeCompare(right.id) : scoreCompare; }); - const assets = buildDispatchAssets(scenario); + const assets = applyRuntimeAssetConstraints(buildDispatchAssets(scenario), runtimeConstraints); const workers = buildDispatchWorkers(assets); - const powerGate = evaluatePowerLaunchGate(powerAnalysis, assets); + const powerGate = applyRuntimePowerConstraints(evaluatePowerLaunchGate(powerAnalysis, assets), runtimeConstraints); const assetIndex = assetById(assets); const vehicleWorkers = workers.filter((worker) => worker.source === "vehicle").sort(compareById); const resourceCapacity = reservationCapacityIndex(scenario); @@ -73,7 +100,7 @@ export function planDispatch(input: DispatchPlannerInput): DispatchPlannerResult const allCandidates: DispatchCandidate[] = []; const transientSuperWorkers: TransientSuperWorker[] = []; - const reservations: DispatchReservation[] = []; + const reservations: DispatchReservation[] = runtimeConstraints.retainedReservations.map((reservation) => ({ ...reservation })); const missionPlans: MissionPlan[] = []; const deficiencyGates: DeficiencyGate[] = []; @@ -88,6 +115,7 @@ export function planDispatch(input: DispatchPlannerInput): DispatchPlannerResult assets, powerGate, input, + runtimeConstraints, })) .sort(compareCandidateEvaluations); @@ -95,7 +123,9 @@ export function planDispatch(input: DispatchPlannerInput): DispatchPlannerResult const eligible = evaluations.filter((candidate) => candidate.match.eligible && candidate.route.reachable); const launchable = eligible.filter((candidate) => candidate.launchGate.status !== "blocked"); const window = planningWindowForGroup(group, generatedAt, launchable[0]?.route); - const chosen = launchable.find((candidate) => reservationsAvailable(candidate, group, window, reservations, resourceCapacity)); + const chosen = launchable.find((candidate) => + reservationsAvailable(candidate, group, window, reservations, resourceCapacity, runtimeConstraints) + ); if (!chosen) { if (group.chits.length > 1) { @@ -150,6 +180,46 @@ export function planDispatch(input: DispatchPlannerInput): DispatchPlannerResult }; } +function applyRuntimeAssetConstraints( + assets: readonly DispatchAsset[], + constraints: NormalizedRuntimeConstraints, +): DispatchAsset[] { + return assets.map((asset) => { + const resourceIds = resourceIdsForAsset(asset); + const unavailable = constraints.unavailableAssetIds.has(asset.id) || + resourceIds.some((resourceId) => constraints.unavailableResourceIds.has(resourceId)); + return unavailable ? { ...asset, state: "offline" } : { ...asset }; + }); +} + +function resourceIdsForAsset(asset: DispatchAsset): StableId[] { + const rawId = asset.id.startsWith("asset:") ? asset.id.slice("asset:".length) : asset.id; + return uniqueSorted([ + asset.id, + rawId, + `asset:${rawId}`, + asset.serviceZoneId, + asset.serviceZoneId ? `station-zone:${asset.serviceZoneId}` : undefined, + asset.kind === "guideway" ? `guideway-link:${rawId}` : undefined, + ].filter((resourceId): resourceId is StableId => Boolean(resourceId))); +} + +function applyRuntimePowerConstraints( + gate: PowerLaunchGate, + constraints: NormalizedRuntimeConstraints, +): PowerLaunchGate { + if (constraints.powerConstraintIds.length === 0) { + return gate; + } + return { + ...gate, + status: "blocked", + message: `${gate.message} Runtime power constraints remain active on ${constraints.powerConstraintIds.join(", ")}.`, + reasonCodes: uniqueSorted([...gate.reasonCodes, "runtime-power-constraint"]), + affectedPowerIds: uniqueSorted([...gate.affectedPowerIds, ...constraints.powerConstraintIds]), + }; +} + type PlanningWindow = { startTime: string; endTime: string; @@ -291,17 +361,28 @@ function compareChitsForGroup(left: DispatchChit | undefined, right: DispatchChi return dueCompare === 0 ? left.id.localeCompare(right.id) : dueCompare; } -function routeForGroup(group: DispatchChitGroup, input: DispatchPlannerInput): GuidewayRoute { +function routeForGroup( + group: DispatchChitGroup, + input: DispatchPlannerInput, + constraints: NormalizedRuntimeConstraints, +): GuidewayRoute { const endpoints = uniqueEndpoints(group.chits.flatMap((chit) => [chit.origin, chit.destination])); + const blockUnavailableLinks = (route: GuidewayRoute): GuidewayRoute => + route.linkIds.some((linkId) => + constraints.unavailableResourceIds.has(linkId) || + constraints.unavailableResourceIds.has(`guideway-link:${linkId}`) + ) + ? { ...route, reachable: false } + : route; if (endpoints.length === 0) { - return routeBetweenEndpoints(input.guideway, group.chits[0].origin, group.chits[0].destination); + return blockUnavailableLinks(routeBetweenEndpoints(input.guideway, group.chits[0].origin, group.chits[0].destination)); } if (endpoints.length === 1) { - return routeBetweenEndpoints(input.guideway, endpoints[0], endpoints[0]); + return blockUnavailableLinks(routeBetweenEndpoints(input.guideway, endpoints[0], endpoints[0])); } const segments = endpoints.slice(1).map((endpoint, index) => - routeBetweenEndpoints(input.guideway, endpoints[index], endpoint) + blockUnavailableLinks(routeBetweenEndpoints(input.guideway, endpoints[index], endpoint)) ); if (segments.some((route) => !route.reachable)) { return segments.find((route) => !route.reachable) as GuidewayRoute; @@ -359,11 +440,12 @@ function evaluateCandidate(input: { assets: readonly DispatchAsset[]; powerGate: PowerLaunchGate; input: DispatchPlannerInput; + runtimeConstraints: NormalizedRuntimeConstraints; }): CandidateEvaluation { const supportWorkers = supportWorkersForGroup(input.group, input.workers); const primaryWorkers = selectPrimaryWorkersForGroup(input.group, input.primaryWorker, input.vehicleWorkers); const match = matchWorkersToGroup(input.group, primaryWorkers, supportWorkers, input.assets); - const route = routeForGroup(input.group, input.input); + const route = routeForGroup(input.group, input.input, input.runtimeConstraints); const score = scoreCandidate(input.group, primaryWorkers, supportWorkers, route, input.powerGate, match); const supportWorkerIds = supportWorkers.map((worker) => worker.id).sort(); const primaryWorkerIds = primaryWorkers.map((worker) => worker.id).sort(); @@ -724,8 +806,12 @@ function reservationsAvailable( window: PlanningWindow, reservations: readonly DispatchReservation[], resourceCapacity: ReadonlyMap, + constraints: NormalizedRuntimeConstraints, ): boolean { return reservationResources(candidate, group).every((resourceId) => { + if (constraints.unavailableResourceIds.has(resourceId)) { + return false; + } const overlappingReservations = reservations.filter((reservation) => reservation.resourceId === resourceId && reservationsOverlap(window, reservation) ); diff --git a/packages/dispatch/src/types.ts b/packages/dispatch/src/types.ts index eef6c58..9ba8c8d 100644 --- a/packages/dispatch/src/types.ts +++ b/packages/dispatch/src/types.ts @@ -279,6 +279,13 @@ export type DispatchPlannerOptions = { generatedChits?: readonly DispatchChit[]; powerPreset?: PowerPresetId; demandSeed?: string; + runtimeConstraints?: { + allowedChitIds?: readonly StableId[]; + retainedReservations?: readonly DispatchReservation[]; + unavailableResourceIds?: readonly StableId[]; + unavailableAssetIds?: readonly StableId[]; + powerConstraintIds?: readonly StableId[]; + }; }; export type DispatchPlannerInput = { diff --git a/packages/operations/package.json b/packages/operations/package.json new file mode 100644 index 0000000..2a6286c --- /dev/null +++ b/packages/operations/package.json @@ -0,0 +1,22 @@ +{ + "name": "@atos/operations", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@atos/dispatch": "0.0.0", + "@atos/domain": "0.0.0", + "@atos/scenario": "0.0.0", + "@atos/simulation": "0.0.0" + } +} diff --git a/packages/operations/src/deficiency-carry-forward.ts b/packages/operations/src/deficiency-carry-forward.ts new file mode 100644 index 0000000..8ad2c0e --- /dev/null +++ b/packages/operations/src/deficiency-carry-forward.ts @@ -0,0 +1,119 @@ +import type { DeficiencyGate } from "@atos/dispatch"; +import type { + DeficiencyCarryForwardRecord, + DeficiencyCarryForwardResult, + InfrastructureFinding, +} from "./types"; + +const severityRank = { + info: 0, + warning: 1, + error: 2, +} as const; + +export function carryForwardDeficiencies( + previous: readonly DeficiencyGate[], + revised: readonly DeficiencyGate[], +): DeficiencyCarryForwardResult { + const revisedById = new Map(revised.map((gate) => [gate.id, gate])); + const revisedByChit = new Map(revised.map((gate) => [chitKey(gate), gate])); + const consumedRevised = new Set(); + const records: DeficiencyCarryForwardRecord[] = []; + + for (const oldGate of previous) { + const same = revisedById.get(oldGate.id); + if (same) { + consumedRevised.add(same.id); + records.push(recordFor(oldGate, same)); + continue; + } + const transformed = revisedByChit.get(chitKey(oldGate)); + if (transformed) { + consumedRevised.add(transformed.id); + records.push({ + id: `deficiency-carry:transformed:${oldGate.id}:${transformed.id}`, + previousDeficiencyId: oldGate.id, + revisedDeficiencyId: transformed.id, + status: transformed.kind === oldGate.kind ? "superseded" : "transformed", + kind: transformed.kind, + chitIds: transformed.chitIds.slice().sort(), + rationale: `Deficiency ${oldGate.id} changed to ${transformed.kind}.`, + }); + continue; + } + records.push({ + id: `deficiency-carry:resolved:${oldGate.id}`, + previousDeficiencyId: oldGate.id, + status: "resolved", + kind: oldGate.kind, + chitIds: oldGate.chitIds.slice().sort(), + rationale: `Deficiency ${oldGate.id} is absent from the revised plan.`, + }); + } + + for (const newGate of revised) { + if (!consumedRevised.has(newGate.id)) { + records.push({ + id: `deficiency-carry:new:${newGate.id}`, + revisedDeficiencyId: newGate.id, + status: "new", + kind: newGate.kind, + chitIds: newGate.chitIds.slice().sort(), + rationale: `Deficiency ${newGate.id} was introduced by the revised plan.`, + }); + } + } + + return { + records: records.sort((left, right) => left.id.localeCompare(right.id)), + infrastructureFindings: aggregateInfrastructureFindings([...previous, ...revised]), + }; +} + +function recordFor(previous: DeficiencyGate, revised: DeficiencyGate): DeficiencyCarryForwardRecord { + let status: DeficiencyCarryForwardRecord["status"] = "unchanged"; + if (severityRank[revised.severity] > severityRank[previous.severity]) { + status = "worsened"; + } + return { + id: `deficiency-carry:${status}:${previous.id}`, + previousDeficiencyId: previous.id, + revisedDeficiencyId: revised.id, + status, + kind: revised.kind, + chitIds: revised.chitIds.slice().sort(), + rationale: status === "worsened" + ? `Deficiency ${previous.id} increased from ${previous.severity} to ${revised.severity}.` + : `Deficiency ${previous.id} remains active with the same severity.`, + }; +} + +function aggregateInfrastructureFindings(gates: readonly DeficiencyGate[]): InfrastructureFinding[] { + const groups = new Map(); + for (const gate of gates) { + const key = `${gate.kind}:${gate.action}`; + const bucket = groups.get(key) ?? []; + bucket.push(gate); + groups.set(key, bucket); + } + return [...groups.entries()] + .filter(([, bucket]) => bucket.length > 1) + .map(([key, bucket]) => { + const [kind] = key.split(":"); + const deficiencyIds = [...new Set(bucket.map((gate) => gate.id))].sort(); + const affectedIds = [...new Set(bucket.flatMap((gate) => gate.affectedIds))].sort(); + return { + id: `infrastructure-finding:${key.replaceAll(" ", "-")}`, + kind: kind as DeficiencyGate["kind"], + recurrenceCount: bucket.length, + deficiencyIds, + affectedIds, + summary: `${bucket.length} recurring ${kind?.replaceAll("_", " ")} deficiencies point to ${bucket[0]?.action ?? "an infrastructure constraint"}.`, + }; + }) + .sort((left, right) => right.recurrenceCount - left.recurrenceCount || left.id.localeCompare(right.id)); +} + +function chitKey(gate: DeficiencyGate): string { + return gate.chitIds.slice().sort().join("|"); +} diff --git a/packages/operations/src/incident-correlation.ts b/packages/operations/src/incident-correlation.ts new file mode 100644 index 0000000..99c4e43 --- /dev/null +++ b/packages/operations/src/incident-correlation.ts @@ -0,0 +1,66 @@ +import type { + IncidentCorrelationInput, + OperationsIncident, +} from "./types"; + +export function correlateIncident(input: IncidentCorrelationInput): OperationsIncident { + const event = input.runtime.eventHistory.find((candidate) => candidate.id === input.request.triggeredByEventId); + const reservationResourceIds = resolveReservationResourceIds(input, [ + ...(event?.affectedResourceIds ?? []), + ...input.request.releasedReservationIds, + ...input.request.retainedReservationIds, + ]); + const deficiencyIds = [ + ...(input.request.deficiency ? [input.request.deficiency.id] : []), + ...(input.deficiencyCarryForward?.records.flatMap((record) => [ + record.previousDeficiencyId ?? "", + record.revisedDeficiencyId ?? "", + ]) ?? []), + ].filter(Boolean).sort(); + + return { + id: `incident:${input.sessionId}:${input.request.id}`, + requestId: input.request.id, + triggeringEventIds: [input.request.triggeredByEventId, event?.causalEventId ?? ""].filter(Boolean).sort(), + affectedMissionIds: [input.request.missionId ?? ""].filter(Boolean).sort(), + affectedChitIds: input.request.chitIds.slice().sort(), + affectedAssetIds: input.request.affectedAssetIds.slice().sort(), + affectedResourceIds: reservationResourceIds, + previousGenerationId: input.previousGenerationId, + revisedGenerationId: input.revisedGenerationId, + deficiencyIds: [...new Set(deficiencyIds)], + resolutionState: incidentState(input), + summary: incidentSummary(input), + }; +} + +function resolveReservationResourceIds( + input: IncidentCorrelationInput, + ids: readonly string[], +): string[] { + const reservationResourceById = new Map(input.runtime.reservations.map((reservation) => [ + reservation.reservation.id, + reservation.reservation.resourceId, + ])); + return [...new Set(ids.map((id) => reservationResourceById.get(id) ?? id))].sort(); +} + +function incidentState(input: IncidentCorrelationInput): OperationsIncident["resolutionState"] { + if (input.decision.mode === "none") { + return "no_action"; + } + if (input.decision.mode === "deferred") { + return "deferred"; + } + if (input.revisedGenerationId) { + return input.planDiff?.cancelledMissionIds.length === 0 && input.deficiencyCarryForward?.records.every((record) => record.status === "resolved") + ? "resolved" + : "replanned"; + } + return "open"; +} + +function incidentSummary(input: IncidentCorrelationInput): string { + const scope = input.request.missionId ? `mission ${input.request.missionId}` : `${input.request.chitIds.length} chits`; + return `${input.decision.mode} replan decision for ${scope}: ${input.decision.rationale}`; +} diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts new file mode 100644 index 0000000..de0a1eb --- /dev/null +++ b/packages/operations/src/index.ts @@ -0,0 +1,9 @@ +export * from "./deficiency-carry-forward"; +export * from "./incident-correlation"; +export * from "./metrics"; +export * from "./orchestration"; +export * from "./plan-diff"; +export * from "./replanning-policy"; +export * from "./reservation-reconciliation"; +export * from "./state-projection"; +export * from "./types"; diff --git a/packages/operations/src/metrics.ts b/packages/operations/src/metrics.ts new file mode 100644 index 0000000..ea4ca67 --- /dev/null +++ b/packages/operations/src/metrics.ts @@ -0,0 +1,157 @@ +import type { DispatchChit } from "@atos/dispatch"; +import type { SimulationRuntimeState } from "@atos/simulation"; +import type { OperationsMetrics, PlanningGeneration } from "./types"; + +export function calculateOperationsMetrics( + runtime: SimulationRuntimeState, + generations: readonly PlanningGeneration[], + replanCount: number, +): OperationsMetrics { + const elapsedMs = Math.max(0, Date.parse(runtime.clock.currentTime) - Date.parse(runtime.scenario.simulation.currentTime)); + const simulatedHours = Math.max(elapsedMs / 3_600_000, 1 / 60); + const completed = runtime.missions.filter((mission) => mission.state === "completed"); + const onTime = completed.filter((mission) => + mission.completedAt && Date.parse(mission.completedAt) <= Date.parse(mission.plan.endsAt) + ); + const chitIndex = dispatchChitIndex(generations); + const reservationConflictEvents = runtime.eventHistory.filter((event) => event.type === "reservation_conflict"); + const energyDelayCount = runtime.eventHistory.filter((event) => + event.type === "power_gate_failed" || event.type === "battery_reserve_violated" || String(event.payload.reason ?? "").toLowerCase().includes("energy") + ).length; + const queueWaits = runtime.missions.flatMap((mission) => + chitsForMission(mission.plan.chitIds, chitIndex).map((chit) => + mission.startedAt ? Math.max(0, Date.parse(mission.startedAt) - Date.parse(chit.readyAt)) / 60_000 : 0 + ) + ); + const passengerWaits = runtime.missions.flatMap((mission) => + chitsForMission(mission.plan.chitIds, chitIndex) + .filter(isPassengerChit) + .map((chit) => mission.startedAt ? Math.max(0, Date.parse(mission.startedAt) - Date.parse(chit.readyAt)) / 60_000 : 0) + ); + const cargoLateness = runtime.missions.flatMap((mission) => + chitsForMission(mission.plan.chitIds, chitIndex) + .filter(isCargoChit) + .map((chit) => mission.completedAt ? Math.max(0, Date.parse(mission.completedAt) - Date.parse(chit.dueAt)) / 60_000 : 0) + ); + + return { + simulatedHours, + replansPerSimulatedHour: replanCount / simulatedHours, + missionCompletionRate: rate(completed.length, runtime.missions.length), + onTimeCompletionRate: rate(onTime.length, completed.length), + averageQueueWaitMinutes: average(queueWaits), + averagePassengerWaitMinutes: average(passengerWaits), + averageCargoLatenessMinutes: average(cargoLateness), + assetUtilization: assetUtilization(runtime, elapsedMs), + emptyMovementShare: emptyMovementShare(runtime, chitIndex), + reservationConflictRate: rate(reservationConflictEvents.length, Math.max(runtime.reservations.length, 1)), + energyDelayCount, + missionFailuresByCause: failureCounts(runtime), + deficiencyRecurrence: deficiencyRecurrence(generations), + planningChurn: planningChurn(generations), + }; +} + +function rate(numerator: number, denominator: number): number { + return denominator === 0 ? 0 : numerator / denominator; +} + +function average(values: readonly number[]): number { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function dispatchChitIndex(generations: readonly PlanningGeneration[]): Map { + const index = new Map(); + for (const generation of generations) { + for (const chit of generation.dispatchResult.normalizedChits) { + index.set(chit.id, chit); + } + } + return index; +} + +function chitsForMission(chitIds: readonly string[], index: ReadonlyMap): DispatchChit[] { + return chitIds + .map((id) => index.get(id)) + .filter((chit): chit is DispatchChit => Boolean(chit)); +} + +function isPassengerChit(chit: DispatchChit): boolean { + return Boolean(chit.serviceMetadata.passengerClass) || chit.kind.endsWith("-passenger"); +} + +function isCargoChit(chit: DispatchChit): boolean { + return Boolean(chit.serviceMetadata.cargo) || + chit.kind.endsWith("-cargo") || + chit.kind === "maintenance-supplies"; +} + +function assetUtilization(runtime: SimulationRuntimeState, elapsedMs: number): number { + const vehicleIds = new Set(runtime.assets.filter((asset) => asset.kind === "vehicle").map((asset) => asset.assetId)); + if (vehicleIds.size === 0 || elapsedMs <= 0) { + return 0; + } + const busyMs = runtime.missions.reduce((sum, mission) => { + if (!mission.startedAt) { + return sum; + } + const startMs = Date.parse(mission.startedAt); + const endMs = Date.parse(mission.completedAt ?? runtime.clock.currentTime); + const durationMs = Math.max(0, endMs - startMs); + const vehicleCount = mission.plan.assetIds.filter((assetId) => vehicleIds.has(assetId)).length; + return sum + durationMs * vehicleCount; + }, 0); + return Math.min(1, busyMs / (vehicleIds.size * elapsedMs)); +} + +function emptyMovementShare( + runtime: SimulationRuntimeState, + chitIndex: ReadonlyMap, +): number { + const totalLinks = runtime.missions.reduce((sum, mission) => sum + mission.plan.route.linkIds.length, 0); + if (totalLinks === 0) { + return 0; + } + const emptyLinks = runtime.missions + .filter((mission) => { + const chits = chitsForMission(mission.plan.chitIds, chitIndex); + return chits.length === 0 || chits.every((chit) => chit.kind === "repositioning"); + }) + .reduce((sum, mission) => sum + mission.plan.route.linkIds.length, 0); + return emptyLinks / totalLinks; +} + +function planningChurn(generations: readonly PlanningGeneration[]): number { + if (generations.length <= 1) { + return 0; + } + const latest = generations.at(-1); + const previous = generations.at(-2); + if (latest?.planDiff && previous) { + const changed = latest.planDiff.records.filter((record) => record.status !== "unchanged").length; + return rate(changed, Math.max(previous.dispatchResult.missionPlans.length, 1)); + } + return rate( + Math.abs((latest?.dispatchResult.missionPlans.length ?? 0) - (previous?.dispatchResult.missionPlans.length ?? 0)), + Math.max(previous?.dispatchResult.missionPlans.length ?? 1, 1), + ); +} + +function failureCounts(runtime: SimulationRuntimeState): Record { + const counts: Record = {}; + for (const event of runtime.eventHistory.filter((candidate) => candidate.type === "mission_failed" || candidate.type === "replanning_requested")) { + const reason = String(event.payload.reason ?? event.type); + counts[reason] = (counts[reason] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); +} + +function deficiencyRecurrence(generations: readonly PlanningGeneration[]): Record { + const counts: Record = {}; + for (const generation of generations) { + for (const gate of generation.dispatchResult.deficiencyGates) { + counts[gate.kind] = (counts[gate.kind] ?? 0) + 1; + } + } + return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); +} diff --git a/packages/operations/src/operations.test.ts b/packages/operations/src/operations.test.ts new file mode 100644 index 0000000..ce5070c --- /dev/null +++ b/packages/operations/src/operations.test.ts @@ -0,0 +1,515 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createDispatchPlannerInput, planDispatch, type DeficiencyGate } from "@atos/dispatch"; +import { loadSixTileCityFixture } from "@atos/scenario"; +import { + createSimulationFixture, + initializeSimulation, + runSimulationToCompletion, + stepSimulationToNextEvent, + type SimulationRuntimeState, +} from "@atos/simulation"; +import { + calculateOperationsMetrics, + carryForwardDeficiencies, + createDefaultOperationsSession, + createOperationsSession, + diffDispatchPlans, + evaluateReplanningPolicy, + performOperationsReplan, + projectRuntimeStateToDispatchInput, + reconcileReservations, + requestManualReplan, +} from "."; + +describe("ATOS operations orchestration", () => { + it("classifies every replanning policy trigger deterministically", () => { + const cases = [ + ["mission_failure", "partial"], + ["route_blockage", "partial"], + ["reservation_conflict", "partial"], + ["asset_failure", "partial"], + ["battery_power_failure", "partial"], + ["power_launch_failure", "partial"], + ["service_outage", "partial"], + ["missed_deadline", "full"], + ["material_queue_growth", "full"], + ["released_high_priority_asset", "deferred"], + ["operator_request", "full"], + ["fault_cleared", "none"], + ["completed_mission", "none"], + ] as const; + + for (const [trigger, mode] of cases) { + expect(evaluateReplanningPolicy({ + requestId: `request:${trigger}`, + trigger, + missionId: "mission:fixture", + chitIds: ["chit-a"], + affectedAssetIds: ["asset-a"], + reason: trigger, + }).mode).toBe(mode); + } + }); + + it("projects runtime asset, chit, reservation, fault, power, and current-time state without mutating historical scenario input", () => { + const input = createSimulationFixture("asset-fault-replanning"); + const before = JSON.stringify(input.scenario); + const completedRuntime = runSimulationToCompletion(input); + const runtime: SimulationRuntimeState = { + ...completedRuntime, + assets: completedRuntime.assets.map((asset) => + asset.kind === "vehicle" && asset.assetId === completedRuntime.assets.find((candidate) => candidate.kind === "vehicle")?.assetId + ? { ...asset, health: "faulted", activeMissionId: "mission:projection-test" } + : asset + ), + }; + const projection = projectRuntimeStateToDispatchInput(runtime); + + expect(JSON.stringify(input.scenario)).toBe(before); + expect(projection.projectedScenario.simulation.currentTime).toBe(runtime.clock.currentTime); + expect(projection.dispatchInput.options?.currentTime).toBe(runtime.clock.currentTime); + expect(projection.runtimeAssetStates.map((asset) => asset.assetId)).toEqual( + [...projection.runtimeAssetStates.map((asset) => asset.assetId)].sort(), + ); + expect(projection.activeReservations.every((reservation) => + ["active", "held"].includes(reservation.status) + )).toBe(true); + expect(projection.unavailableResourceIds.length).toBeGreaterThan(0); + expect(projection.projectedScenario.inventory.vehicles.some((vehicle) => vehicle.state === "maintenance" || vehicle.state === "active")).toBe(true); + }); + + it("passes runtime constraints and partially fulfilled quantities into the revised dispatch input", () => { + const input = createSimulationFixture("simple-passenger"); + const initialized = initializeSimulation(input); + const passengerChit = input.dispatchResult.normalizedChits.find((chit) => (chit.quantity.passengers ?? 0) > 0); + const mission = initialized.missions.find((candidate) => + passengerChit && candidate.chitProgress.some((progress) => progress.chitId === passengerChit.id) + ); + const vehicle = initialized.assets.find((asset) => asset.kind === "vehicle"); + const linkId = initialized.scenario.guideway.links[0]?.id; + const chitId = passengerChit?.id; + if (!mission || !vehicle || !linkId || !chitId) { + throw new Error("fixture missing mission, vehicle, link, or chit"); + } + const runtime: SimulationRuntimeState = { + ...initialized, + clock: { ...initialized.clock, currentTime: "2026-07-10T00:02:00.000Z" }, + faults: [{ + id: "fault:vehicle:test", + type: "vehicle_unavailable", + targetId: vehicle.assetId, + startsAt: "2026-07-10T00:01:00.000Z", + behavior: "request_replanning", + severity: "error", + message: "Vehicle unavailable for projection test.", + raisedEventId: "event:fault:test", + }], + assets: initialized.assets.map((asset) => asset.assetId === vehicle.assetId + ? { ...asset, health: "faulted", faultIds: ["fault:vehicle:test"] } + : asset), + guidewayOccupancy: [{ + id: "occupancy:guideway:test", + linkId, + missionId: "mission:other", + enteredAt: "2026-07-10T00:01:00.000Z", + exitAt: "2026-07-10T00:04:00.000Z", + assetIds: [vehicle.assetId], + }], + serviceOccupancy: [{ + id: "occupancy:service:test", + resourceId: "station-zone:zone-passenger", + missionId: "mission:other", + action: "loading", + startTime: "2026-07-10T00:01:00.000Z", + endTime: "2026-07-10T00:04:00.000Z", + capacityUsed: 1, + }], + eventHistory: [{ + id: "event:power:test", + sequence: 100, + timestamp: "2026-07-10T00:01:30.000Z", + type: "battery_reserve_violated", + missionId: mission.plan.id, + affectedAssetIds: [vehicle.assetId], + affectedResourceIds: [linkId], + payload: { reason: "battery reserve test" }, + severity: "error", + status: "applied", + }], + missions: initialized.missions.map((candidate) => candidate.plan.id === mission.plan.id + ? { + ...candidate, + chitProgress: candidate.chitProgress.map((progress) => progress.chitId === chitId + ? { + ...progress, + unloaded: { passengers: 1 }, + status: "pending", + } + : progress), + } + : candidate), + }; + + const projection = projectRuntimeStateToDispatchInput(runtime); + const projectedChit = projection.projectedScenario.chits.find((chit) => chit.id === chitId); + const constraints = projection.dispatchInput.options?.runtimeConstraints; + + expect(projectedChit?.quantity.passengers).toBe((passengerChit.quantity.passengers ?? 0) - 1); + expect(constraints?.unavailableAssetIds).toContain(vehicle.assetId); + expect(constraints?.unavailableResourceIds).toEqual(expect.arrayContaining([ + `asset:${vehicle.assetId}`, + `guideway-link:${linkId}`, + "station-zone:zone-passenger", + ])); + expect(constraints?.powerConstraintIds).toContain(`guideway-link:${linkId}`); + }); + + it("marks already loaded chits active so they are not duplicated into a fresh dispatch plan", () => { + const input = createSimulationFixture("simple-passenger"); + const initialized = initializeSimulation(input); + const mission = initialized.missions[0]; + const chitId = mission?.chitProgress[0]?.chitId; + if (!mission || !chitId) { + throw new Error("fixture missing mission or chit"); + } + const runtime: SimulationRuntimeState = { + ...initialized, + missions: initialized.missions.map((candidate) => candidate.plan.id === mission.plan.id + ? { + ...candidate, + chitProgress: candidate.chitProgress.map((progress) => progress.chitId === chitId + ? { ...progress, loaded: { passengers: 6 }, status: "loaded" } + : progress), + } + : candidate), + }; + + const projection = projectRuntimeStateToDispatchInput(runtime); + expect(projection.projectedScenario.chits.find((chit) => chit.id === chitId)?.status).toBe("active"); + expect(planDispatch(projection.dispatchInput).missionPlans.some((plan) => plan.chitIds.includes(chitId))).toBe(false); + }); + + it("reconciles retained, released, superseded, new, historical, and active-occupancy reservations", () => { + let runtime = initializeSimulation(createSimulationFixture("consist-formation-split")); + runtime = stepUntil(runtime, (state) => state.serviceOccupancy.some((occupancy) => occupancy.action === "loading")); + const previousReservations = runtime.dispatchResult.reservations; + const active = runtime.reservations.find((reservation) => + reservation.status === "active" && reservation.reservation.resourceId.startsWith("station-zone:") + ); + const held = runtime.reservations.find((reservation) => reservation.status === "held" && reservation.reservation.id !== active?.reservation.id); + if (!active || !held) { + throw new Error("fixture did not produce active and held reservations"); + } + const revisedReservations = [ + { ...active.reservation, id: "reservation:retained:copy" }, + { ...held.reservation, id: "reservation:new:fixture", resourceId: "station-zone:new-zone" }, + ]; + + const result = reconcileReservations({ + previousReservations, + runtimeReservations: runtime.reservations.map((reservation) => + reservation.reservation.id === held.reservation.id ? { ...reservation, status: "released" } : reservation + ), + revisedReservations, + runtime, + }); + + expect(result.records.map((record) => record.status)).toEqual(expect.arrayContaining([ + "active_occupancy", + "historical", + "released", + "new", + ])); + expect(result.activeOccupancyResourceIds).toContain(active.reservation.resourceId); + }); + + it("detects duplicate reservation ownership across overlapping windows", () => { + const input = createSimulationFixture("conflicting-deterministic"); + const runtime = { + ...initializeSimulation(input), + reservations: initializeSimulation(input).reservations.map((reservation, index) => + index === 0 ? { ...reservation, status: "held" as const } : reservation + ), + }; + const first = runtime.reservations[0]?.reservation; + if (!first) { + throw new Error("fixture missing reservation"); + } + const duplicate = { + ...first, + id: `${first.id}:duplicate-owner`, + missionPlanId: "mission:duplicate-owner", + }; + + const result = reconcileReservations({ + previousReservations: [first], + runtimeReservations: runtime.reservations, + revisedReservations: [duplicate], + runtime, + }); + + expect(result.duplicateOwnershipConflicts.some((conflict) => + conflict.reservationIds.includes(first.id) && conflict.reservationIds.includes(duplicate.id) + )).toBe(true); + }); + + it("correlates incidents to reservation resources instead of reservation IDs", () => { + const input = createSimulationFixture("simple-passenger"); + const initialized = initializeSimulation(input); + const reservation = initialized.reservations[0]?.reservation; + if (!reservation) { + throw new Error("fixture missing reservation"); + } + const runtime: SimulationRuntimeState = { + ...initialized, + eventHistory: [{ + id: "event:reservation-resource:test", + sequence: 99, + timestamp: initialized.clock.currentTime, + type: "reservation_conflict", + missionId: reservation.missionPlanId, + affectedAssetIds: [], + affectedResourceIds: [reservation.id], + payload: {}, + severity: "warning", + status: "applied", + }], + }; + const session = createOperationsSession({ + scenario: input.scenario, + dispatchResult: input.dispatchResult, + runtime, + }); + const requested = requestManualReplan(session, { note: "resource correlation" }); + const manualRequest = requested.pendingRequests.find((request) => request.source === "operator"); + if (!manualRequest) { + throw new Error("manual request missing"); + } + const request = { + ...manualRequest, + triggeredByEventId: "event:reservation-resource:test", + retainedReservationIds: [reservation.id], + }; + const correlated = createOperationsSession({ + scenario: input.scenario, + dispatchResult: input.dispatchResult, + runtime: { ...runtime, replanningRequests: [request] }, + }); + + expect(correlated.incidents[0]?.affectedResourceIds).toContain(reservation.resourceId); + expect(correlated.incidents[0]?.affectedResourceIds).not.toContain(reservation.id); + }); + + it("creates meaningful plan diffs for unchanged, delayed, cancelled, and replacement missions", () => { + const scenario = loadSixTileCityFixture(); + const previous = planDispatch(createDispatchPlannerInput(scenario)); + const revised = { + ...previous, + scoreBreakdown: { ...previous.scoreBreakdown, total: previous.scoreBreakdown.total + 5 }, + missionPlans: [ + previous.missionPlans[0], + previous.missionPlans[1] ? { + ...previous.missionPlans[1], + startsAt: new Date(Date.parse(previous.missionPlans[1].startsAt) + 60_000).toISOString(), + endsAt: new Date(Date.parse(previous.missionPlans[1].endsAt) + 60_000).toISOString(), + } : undefined, + previous.missionPlans[0] ? { + ...previous.missionPlans[0], + id: "mission:replacement", + chitIds: ["chit-replacement"], + chitId: "chit-replacement", + } : undefined, + ].filter((mission): mission is NonNullable => Boolean(mission)), + }; + + const diff = diffDispatchPlans({ previous, revised }); + + expect(diff.unchangedMissionIds.length).toBeGreaterThan(0); + expect(diff.delayedMissionIds.length).toBeGreaterThan(0); + expect(diff.cancelledMissionIds.length).toBeGreaterThan(0); + expect(diff.replacementMissionIds.length).toBeGreaterThan(0); + expect(diff.rationale).toContain("score delta"); + }); + + it("carries deficiencies forward and aggregates recurring infrastructure findings", () => { + const previous = [ + deficiency("gate-a", "service_zone_full", "warning", ["chit-a"]), + deficiency("gate-b", "power_blocked", "error", ["chit-b"]), + ]; + const revised = [ + deficiency("gate-a", "service_zone_full", "error", ["chit-a"]), + deficiency("gate-c", "route_unreachable", "warning", ["chit-b"]), + deficiency("gate-d", "service_zone_full", "warning", ["chit-d"]), + ]; + + const carry = carryForwardDeficiencies(previous, revised); + + expect(carry.records.map((record) => record.status)).toEqual(expect.arrayContaining([ + "worsened", + "transformed", + "new", + ])); + expect(carry.infrastructureFindings.some((finding) => finding.kind === "service_zone_full")).toBe(true); + }); + + it("creates a deterministic operations session and performs partial replanning from a simulation request", () => { + const session = createDefaultOperationsSession(); + const repeated = createDefaultOperationsSession(); + + expect(session.pendingRequests.length).toBeGreaterThan(0); + expect(session.policyDecisions[0]?.mode).toBe("partial"); + expect(session.sessionId).toBe(repeated.sessionId); + + const replanned = performOperationsReplan(session); + expect(replanned.generations).toHaveLength(2); + expect(replanned.pendingRequests).toHaveLength(0); + expect(replanned.currentGenerationId).toBe(replanned.generations[1]?.id); + expect(replanned.planDiff.records.length).toBeGreaterThan(0); + expect(replanned.incidents.some((incident) => incident.resolutionState === "replanned" || incident.resolutionState === "resolved")).toBe(true); + expect(replanned.runtime.dispatchResult).toEqual(replanned.generations[1]?.dispatchResult); + expect(replanned.runtime.replanningRequests.some((request) => request.id === session.pendingRequests[0]?.id)).toBe(false); + + const previousPlans = session.generations[0]?.dispatchResult.missionPlans ?? []; + const revisedPlans = replanned.generations[1]?.dispatchResult.missionPlans ?? []; + const scopedChitIds = new Set(replanned.generations[1]?.policyDecision?.scopeChitIds ?? []); + const unaffectedPrevious = previousPlans.filter((plan) => !plan.chitIds.some((chitId) => scopedChitIds.has(chitId))); + for (const plan of unaffectedPrevious) { + expect(revisedPlans.find((candidate) => candidate.id === plan.id)).toEqual(plan); + } + expect(replanned.generations[1]?.projection?.dispatchInput.options?.runtimeConstraints?.allowedChitIds).toEqual([...scopedChitIds].sort()); + }); + + it("adds and executes a deterministic manual full replan request", () => { + const base = createOperationsSessionFromFixture("simple-passenger"); + const requested = requestManualReplan(base, { note: "Operator balancing test." }); + const manualRequest = requested.pendingRequests.find((request) => request.source === "operator"); + + expect(manualRequest).toBeDefined(); + expect(requested.policyDecisions.find((decision) => decision.requestId === manualRequest?.id)?.mode).toBe("full"); + + const replanned = performOperationsReplan(requested, manualRequest?.id); + expect(replanned.generations).toHaveLength(2); + expect(replanned.pendingRequests.some((request) => request.id === manualRequest?.id)).toBe(false); + }); + + it("calculates operations metrics for completion, conflicts, replans, energy delays, and planning churn", () => { + const session = performOperationsReplan(createDefaultOperationsSession()); + + expect(session.metrics.simulatedHours).toBeGreaterThan(0); + expect(session.metrics.replansPerSimulatedHour).toBeGreaterThan(0); + expect(session.metrics.missionCompletionRate).toBeGreaterThanOrEqual(0); + expect(session.metrics.reservationConflictRate).toBeGreaterThanOrEqual(0); + expect(session.metrics.planningChurn).toBeGreaterThanOrEqual(0); + expect(Object.keys(session.metrics.missionFailuresByCause).length).toBeGreaterThan(0); + }); + + it("classifies passenger and cargo metrics from chit metadata rather than ID substrings", () => { + const base = createOperationsSessionFromFixture("consist-formation-split"); + const generation = base.generations[0]; + const passenger = generation.dispatchResult.normalizedChits.find((chit) => chit.serviceMetadata.passengerClass); + const cargo = generation.dispatchResult.normalizedChits.find((chit) => chit.serviceMetadata.cargo); + if (!passenger || !cargo) { + throw new Error("fixture missing passenger or cargo chit"); + } + const renamed = new Map([ + [passenger.id, "demand-alpha"], + [cargo.id, "demand-beta"], + ]); + const rename = (id: string) => renamed.get(id) ?? id; + const dispatchResult = { + ...generation.dispatchResult, + normalizedChits: generation.dispatchResult.normalizedChits.map((chit) => + renamed.has(chit.id) ? { ...chit, id: rename(chit.id), sourceChitId: rename(chit.id) } : chit + ), + missionPlans: generation.dispatchResult.missionPlans.map((plan) => ({ + ...plan, + chitId: rename(plan.chitId), + chitIds: plan.chitIds.map(rename), + })), + reservations: generation.dispatchResult.reservations.map((reservation) => ({ + ...reservation, + chitIds: reservation.chitIds.map(rename), + })), + transientSuperWorkers: generation.dispatchResult.transientSuperWorkers.map((worker) => ({ + ...worker, + chitIds: worker.chitIds.map(rename), + })), + }; + const missionById = new Map(dispatchResult.missionPlans.map((plan) => [plan.id, plan])); + const runtime: SimulationRuntimeState = { + ...base.runtime, + dispatchResult, + missions: base.runtime.missions.map((mission) => ({ + ...mission, + plan: missionById.get(mission.plan.id) ?? mission.plan, + startedAt: "2026-07-10T00:05:00.000Z", + completedAt: "2026-07-10T01:00:00.000Z", + })), + }; + + const metrics = calculateOperationsMetrics(runtime, [{ ...generation, dispatchResult }], 0); + + expect(metrics.averagePassengerWaitMinutes).toBeGreaterThan(0); + expect(metrics.averageCargoLatenessMinutes).toBeGreaterThan(0); + }); + + it("keeps package source free of React imports", () => { + const files = sourceFiles(join(process.cwd(), "src")); + const reactPackage = "re" + "act"; + const reactImportPattern = new RegExp(String.raw`\bfrom\s+["']${reactPackage}["']|@types/${reactPackage}|${reactPackage}-dom`); + const offenders = files.filter((file) => reactImportPattern.test(readFileSync(file, "utf8"))); + + expect(offenders).toEqual([]); + }); +}); + +function createOperationsSessionFromFixture(id: Parameters[0]) { + const input = createSimulationFixture(id); + return createOperationsSession({ + scenario: input.scenario, + dispatchResult: input.dispatchResult, + runtime: initializeSimulation(input), + }); +} + +function stepUntil( + state: SimulationRuntimeState, + predicate: (state: SimulationRuntimeState) => boolean, +): SimulationRuntimeState { + let next = state; + for (let index = 0; index < 40 && !predicate(next); index += 1) { + next = stepSimulationToNextEvent(next); + } + return next; +} + +function deficiency( + id: string, + kind: DeficiencyGate["kind"], + severity: DeficiencyGate["severity"], + chitIds: string[], +): DeficiencyGate { + return { + id, + kind, + severity, + message: `${kind} ${severity}`, + action: `Resolve ${kind}`, + chitIds, + assetIds: [], + affectedIds: chitIds, + }; +} + +function sourceFiles(root: string): string[] { + return readdirSync(root).flatMap((entry) => { + const path = join(root, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + return sourceFiles(path); + } + return path.endsWith(".ts") ? [path] : []; + }); +} diff --git a/packages/operations/src/orchestration.ts b/packages/operations/src/orchestration.ts new file mode 100644 index 0000000..d60cef3 --- /dev/null +++ b/packages/operations/src/orchestration.ts @@ -0,0 +1,623 @@ +import { + planDispatch, + type DispatchChit, + type DispatchPlannerInput, + type DispatchPlannerResult, + type DispatchReservation, + type DispatchScoreBreakdown, + type MissionPlan, +} from "@atos/dispatch"; +import type { StableId } from "@atos/domain"; +import { + createSimulationFixture, + initializeSimulation, + orderedEventQueue, + stepSimulationToNextEvent, + type ChitFulfillmentProgress, + type RuntimeAssetState, + type RuntimeConsistState, + type RuntimeMission, + type RuntimeReservation, + type SimulationEvent, + type SimulationRuntimeState, +} from "@atos/simulation"; +import { carryForwardDeficiencies } from "./deficiency-carry-forward"; +import { correlateIncident } from "./incident-correlation"; +import { calculateOperationsMetrics } from "./metrics"; +import { diffDispatchPlans } from "./plan-diff"; +import { policyDecisionForRequest, triggerFromSimulationRequest } from "./replanning-policy"; +import { reconcileReservations } from "./reservation-reconciliation"; +import { projectRuntimeStateToDispatchInput } from "./state-projection"; +import type { + ManualReplanInput, + OperationsReplanningRequest, + OperationsSession, + OperationsSessionInput, + PlanningGeneration, + ReservationReconciliationResult, + PlanDiffResult, + DeficiencyCarryForwardResult, +} from "./types"; + +export function createOperationsSession(input: OperationsSessionInput): OperationsSession { + const sessionId = input.sessionId ?? `operations:${input.scenario.scenario.id}`; + const generation = generationFor(sessionId, 0, input.runtime.clock.currentTime, input.dispatchResult); + const pendingRequests = input.runtime.replanningRequests.map(simulationRequestToOperationsRequest); + const policyDecisions = pendingRequests.map(policyDecisionForRequest); + const reservationReconciliation = emptyReservationReconciliation(); + const planDiff = emptyPlanDiff(); + const deficiencyCarryForward = { + records: [], + infrastructureFindings: [], + }; + return { + schemaVersion: 1, + sessionId, + scenarioId: input.scenario.scenario.id, + scenarioSchemaVersion: input.scenario.schemaVersion, + currentGenerationId: generation.id, + generations: [generation], + runtime: input.runtime, + pendingRequests, + policyDecisions, + reservationReconciliation, + planDiff, + deficiencyCarryForward, + incidents: pendingRequests.map((request, index) => correlateIncident({ + sessionId, + request, + decision: policyDecisions[index] ?? policyDecisionForRequest(request), + runtime: input.runtime, + previousGenerationId: generation.id, + })), + metrics: calculateOperationsMetrics(input.runtime, [generation], pendingRequests.length), + }; +} + +export function createDefaultOperationsSession(): OperationsSession { + const input = createSimulationFixture("asset-fault-replanning"); + let runtime = initializeSimulation(input); + for (let index = 0; index < 24 && runtime.replanningRequests.length === 0; index += 1) { + runtime = stepSimulationToNextEvent(runtime); + } + return createOperationsSession({ + scenario: input.scenario, + dispatchResult: input.dispatchResult, + runtime, + }); +} + +export function requestManualReplan( + session: OperationsSession, + input: ManualReplanInput = {}, +): OperationsSession { + const request = createManualReplanningRequest(session, input); + const pendingRequests = [...session.pendingRequests, request].sort((left, right) => left.id.localeCompare(right.id)); + const policyDecision = policyDecisionForRequest(request); + return { + ...session, + pendingRequests, + policyDecisions: [...session.policyDecisions, policyDecision].sort((left, right) => left.id.localeCompare(right.id)), + incidents: [ + ...session.incidents, + correlateIncident({ + sessionId: session.sessionId, + request, + decision: policyDecision, + runtime: session.runtime, + previousGenerationId: session.currentGenerationId, + }), + ].sort((left, right) => left.id.localeCompare(right.id)), + }; +} + +export function performOperationsReplan( + session: OperationsSession, + requestId = session.pendingRequests[0]?.id, +): OperationsSession { + const request = session.pendingRequests.find((candidate) => candidate.id === requestId); + if (!request) { + return session; + } + const decision = policyDecisionForRequest(request); + if (decision.mode === "none" || decision.mode === "deferred") { + return { + ...session, + policyDecisions: upsertPolicyDecision(session.policyDecisions, decision), + incidents: upsertIncident(session, request, decision), + }; + } + + const previousGeneration = currentGeneration(session); + const projection = projectRuntimeStateToDispatchInput(session.runtime); + const plannerInput = dispatchInputForDecision(projection.dispatchInput, previousGeneration.dispatchResult, decision); + const planningProjection = { ...projection, dispatchInput: plannerInput }; + const plannedDispatch = planDispatch(plannerInput); + const revisedDispatch = decision.mode === "partial" + ? mergePartialDispatchResult(previousGeneration.dispatchResult, plannedDispatch, decision) + : plannedDispatch; + const generation = generationFor( + session.sessionId, + previousGeneration.generationNumber + 1, + session.runtime.clock.currentTime, + revisedDispatch, + decision, + planningProjection, + ); + const reservationReconciliation = reconcileReservations({ + previousReservations: previousGeneration.dispatchResult.reservations, + runtimeReservations: session.runtime.reservations, + revisedReservations: revisedDispatch.reservations, + runtime: session.runtime, + }); + const planDiff = diffDispatchPlans({ + previous: previousGeneration.dispatchResult, + revised: revisedDispatch, + }); + const deficiencyCarryForward = carryForwardDeficiencies( + previousGeneration.dispatchResult.deficiencyGates, + revisedDispatch.deficiencyGates, + ); + const finalizedGeneration: PlanningGeneration = { + ...generation, + reservationReconciliation, + planDiff, + deficiencyCarryForward, + }; + const generations = [...session.generations, finalizedGeneration].sort((left, right) => left.generationNumber - right.generationNumber); + const promotionBlocked = reservationReconciliation.duplicateOwnershipConflicts.length > 0; + const runtime = promotionBlocked + ? session.runtime + : handoffRuntimeToGeneration(session.runtime, projection.projectedScenario, revisedDispatch, request.id); + + return { + ...session, + currentGenerationId: promotionBlocked ? session.currentGenerationId : finalizedGeneration.id, + generations, + runtime, + pendingRequests: promotionBlocked + ? session.pendingRequests + : session.pendingRequests.filter((candidate) => candidate.id !== request.id), + policyDecisions: upsertPolicyDecision(session.policyDecisions, decision), + reservationReconciliation, + planDiff, + deficiencyCarryForward, + incidents: upsertIncident( + session, + request, + decision, + promotionBlocked ? undefined : finalizedGeneration.id, + planDiff, + deficiencyCarryForward, + ), + metrics: calculateOperationsMetrics(runtime, generations, session.policyDecisions.length + 1), + }; +} + +function dispatchInputForDecision( + input: DispatchPlannerInput, + previous: DispatchPlannerResult, + decision: import("./types").ReplanningPolicyDecision, +): DispatchPlannerInput { + const runtimeConstraints = input.options?.runtimeConstraints ?? {}; + if (decision.mode !== "partial") { + return input; + } + + const scopedChitIds = new Set(decision.scopeChitIds); + const scopedMissionIds = new Set(decision.scopeMissionIds); + const retainedPreviousReservations = previous.reservations.filter((reservation) => + !scopedMissionIds.has(reservation.missionPlanId) && + !reservation.chitIds.some((chitId) => scopedChitIds.has(chitId)) + ); + return { + ...input, + options: { + ...input.options, + runtimeConstraints: { + ...runtimeConstraints, + allowedChitIds: [...scopedChitIds].sort(), + retainedReservations: uniqueReservations([ + ...(runtimeConstraints.retainedReservations ?? []), + ...retainedPreviousReservations, + ]), + }, + }, + }; +} + +function mergePartialDispatchResult( + previous: DispatchPlannerResult, + partial: DispatchPlannerResult, + decision: import("./types").ReplanningPolicyDecision, +): DispatchPlannerResult { + const scopedChitIds = new Set(decision.scopeChitIds); + const scopedMissionIds = new Set(decision.scopeMissionIds); + const affectedPlan = (plan: MissionPlan) => + scopedMissionIds.has(plan.id) || plan.chitIds.some((chitId) => scopedChitIds.has(chitId)); + const preservedMissionPlans = previous.missionPlans.filter((plan) => !affectedPlan(plan)); + const preservedMissionIds = new Set(preservedMissionPlans.map((plan) => plan.id)); + const preservedSuperWorkerIds = new Set(preservedMissionPlans.map((plan) => plan.superWorkerId)); + const preservedDeficiencies = previous.deficiencyGates.filter((gate) => + !gate.chitIds.some((chitId) => scopedChitIds.has(chitId)) + ); + const normalizedChits = mergeChits(previous.normalizedChits, partial.normalizedChits, scopedChitIds); + const missionPlans = [...preservedMissionPlans, ...partial.missionPlans].sort(compareById); + const transientSuperWorkers = [ + ...previous.transientSuperWorkers.filter((worker) => preservedSuperWorkerIds.has(worker.id)), + ...partial.transientSuperWorkers, + ].filter(uniqueById).sort(compareById); + const reservations = uniqueReservations([ + ...previous.reservations.filter((reservation) => preservedMissionIds.has(reservation.missionPlanId)), + ...partial.reservations, + ]); + const deficiencyGates = [ + ...preservedDeficiencies, + ...partial.deficiencyGates, + ].filter(uniqueById).sort(compareById); + + return { + ...partial, + normalizedChits, + transientSuperWorkers, + reservations, + missionPlans, + deficiencyGates, + scoreBreakdown: aggregateScores(missionPlans), + powerGateSummary: { + ...partial.powerGateSummary, + status: aggregatePowerStatus(missionPlans, partial.powerGateSummary.status), + delayedCount: missionPlans.filter((plan) => plan.launchGate.status === "delayed").length, + blockedCount: missionPlans.filter((plan) => plan.launchGate.status === "blocked").length, + }, + }; +} + +function mergeChits( + previous: readonly DispatchChit[], + partial: readonly DispatchChit[], + scopedChitIds: ReadonlySet, +): DispatchChit[] { + const partialById = new Map(partial.map((chit) => [chit.id, chit])); + return [ + ...previous + .filter((chit) => !scopedChitIds.has(chit.id)) + .map((chit) => ({ ...chit })), + ...partialById.values(), + ].sort(compareById); +} + +function handoffRuntimeToGeneration( + runtime: SimulationRuntimeState, + scenario: import("@atos/scenario").ScenarioDocumentV1, + dispatchResult: DispatchPlannerResult, + resolvedRequestId: StableId, +): SimulationRuntimeState { + const fresh = initializeSimulation({ + scenario, + dispatchResult, + config: runtime.config, + faultSchedule: runtime.faultSchedule, + }); + const currentTimeMs = Date.parse(runtime.clock.currentTime); + const revisedMissionIds = new Set(dispatchResult.missionPlans.map((plan) => plan.id)); + const runtimeMissionById = new Map(runtime.missions.map((mission) => [mission.plan.id, mission])); + const runtimeConsistByMission = new Map(runtime.consists.map((consist) => [consist.missionId, consist])); + + return { + ...fresh, + clock: runtime.clock, + eventQueue: handoffEventQueue(runtime.eventQueue, fresh.eventQueue, revisedMissionIds, currentTimeMs), + eventHistory: runtime.eventHistory, + missions: fresh.missions.map((mission) => mergeRuntimeMission(mission, runtimeMissionById.get(mission.plan.id))).sort(compareMission), + assets: fresh.assets.map((asset) => mergeRuntimeAsset(asset, runtime.assets, revisedMissionIds)).sort(compareRuntimeAsset), + consists: fresh.consists.map((consist) => mergeRuntimeConsist(consist, runtimeConsistByMission.get(consist.missionId))).sort(compareConsist), + guidewayOccupancy: runtime.guidewayOccupancy + .filter((occupancy) => revisedMissionIds.has(occupancy.missionId)) + .sort(compareById), + serviceOccupancy: runtime.serviceOccupancy + .filter((occupancy) => revisedMissionIds.has(occupancy.missionId)) + .sort(compareById), + reservations: mergeRuntimeReservations(fresh.reservations, runtime.reservations, revisedMissionIds), + faults: runtime.faults, + faultSchedule: runtime.faultSchedule, + replanningRequests: runtime.replanningRequests.filter((request) => request.id !== resolvedRequestId), + }; +} + +function handoffEventQueue( + existing: readonly SimulationEvent[], + fresh: readonly SimulationEvent[], + revisedMissionIds: ReadonlySet, + currentTimeMs: number, +): SimulationEvent[] { + return orderedEventQueue([ + ...existing.filter((event) => + event.missionId && + revisedMissionIds.has(event.missionId) && + Date.parse(event.timestamp) >= currentTimeMs + ), + ...fresh.filter((event) => Date.parse(event.timestamp) >= currentTimeMs), + ].filter(uniqueById)); +} + +function mergeRuntimeMission(fresh: RuntimeMission, existing: RuntimeMission | undefined): RuntimeMission { + if (!existing || !preserveRuntimeMissionState(existing)) { + return fresh; + } + return { + ...existing, + plan: fresh.plan, + chitProgress: fresh.chitProgress.map((progress) => + mergeChitProgress(progress, existing.chitProgress.find((candidate) => candidate.chitId === progress.chitId)) + ), + }; +} + +function mergeChitProgress( + fresh: ChitFulfillmentProgress, + existing: ChitFulfillmentProgress | undefined, +): ChitFulfillmentProgress { + return existing ? { ...fresh, ...existing } : fresh; +} + +function mergeRuntimeAsset( + fresh: RuntimeAssetState, + existingAssets: readonly RuntimeAssetState[], + revisedMissionIds: ReadonlySet, +): RuntimeAssetState { + const existing = existingAssets.find((asset) => asset.assetId === fresh.assetId); + if (!existing) { + return fresh; + } + return { + ...fresh, + tileId: existing.tileId ?? fresh.tileId, + nodeId: existing.nodeId ?? fresh.nodeId, + battery: existing.battery ? { ...existing.battery } : fresh.battery, + health: existing.health, + faultIds: [...existing.faultIds].sort(), + activeMissionId: existing.activeMissionId && revisedMissionIds.has(existing.activeMissionId) + ? existing.activeMissionId + : undefined, + consistId: existing.activeMissionId && revisedMissionIds.has(existing.activeMissionId) + ? existing.consistId + : undefined, + }; +} + +function mergeRuntimeConsist( + fresh: RuntimeConsistState, + existing: RuntimeConsistState | undefined, +): RuntimeConsistState { + if (!existing || existing.status === "dissolved") { + return fresh; + } + return { + ...fresh, + status: existing.status, + formedAt: existing.formedAt, + dissolvedAt: existing.dissolvedAt, + }; +} + +function mergeRuntimeReservations( + fresh: readonly RuntimeReservation[], + existing: readonly RuntimeReservation[], + revisedMissionIds: ReadonlySet, +): RuntimeReservation[] { + const byId = new Map(fresh.map((reservation) => [reservation.reservation.id, reservation])); + for (const reservation of existing) { + if ( + revisedMissionIds.has(reservation.reservation.missionPlanId) && + (reservation.status === "active" || reservation.status === "held") + ) { + byId.set(reservation.reservation.id, reservation); + } + } + return [...byId.values()].sort((left, right) => left.reservation.id.localeCompare(right.reservation.id)); +} + +function uniqueReservations(reservations: readonly DispatchReservation[]): DispatchReservation[] { + return [...new Map(reservations.map((reservation) => [reservation.id, { ...reservation }])).values()] + .sort(compareById); +} + +function aggregateScores(missionPlans: readonly MissionPlan[]): DispatchScoreBreakdown { + if (missionPlans.length === 0) { + return { + priority: 0, + deadlineUrgency: 0, + routeEfficiency: 0, + capabilityFit: 0, + capacityHeadroom: 0, + powerReadiness: 0, + reservationPenalty: 0, + total: 0, + }; + } + return { + priority: average(missionPlans, (plan) => plan.score.priority), + deadlineUrgency: average(missionPlans, (plan) => plan.score.deadlineUrgency), + routeEfficiency: average(missionPlans, (plan) => plan.score.routeEfficiency), + capabilityFit: average(missionPlans, (plan) => plan.score.capabilityFit), + capacityHeadroom: average(missionPlans, (plan) => plan.score.capacityHeadroom), + powerReadiness: average(missionPlans, (plan) => plan.score.powerReadiness), + reservationPenalty: average(missionPlans, (plan) => plan.score.reservationPenalty), + total: average(missionPlans, (plan) => plan.score.total), + }; +} + +function aggregatePowerStatus( + missionPlans: readonly MissionPlan[], + fallback: DispatchPlannerResult["powerGateSummary"]["status"], +): DispatchPlannerResult["powerGateSummary"]["status"] { + if (missionPlans.some((plan) => plan.launchGate.status === "blocked")) { + return "blocked"; + } + if (missionPlans.some((plan) => plan.launchGate.status === "delayed")) { + return "delayed"; + } + return fallback; +} + +function preserveRuntimeMissionState(mission: RuntimeMission): boolean { + return [ + "queued", + "forming", + "loading", + "ready", + "departing", + "in_transit", + "dwelling", + "unloading", + "servicing", + "delayed", + ].includes(mission.state); +} + +function average(values: readonly T[], select: (value: T) => number): number { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + select(value), 0) / values.length; +} + +function uniqueById(value: T, index: number, values: readonly T[]): boolean { + return values.findIndex((candidate) => candidate.id === value.id) === index; +} + +function compareById(left: T, right: T): number { + return left.id.localeCompare(right.id); +} + +function compareMission(left: RuntimeMission, right: RuntimeMission): number { + return left.plan.id.localeCompare(right.plan.id); +} + +function compareRuntimeAsset(left: RuntimeAssetState, right: RuntimeAssetState): number { + return left.assetId.localeCompare(right.assetId); +} + +function compareConsist(left: RuntimeConsistState, right: RuntimeConsistState): number { + return left.id.localeCompare(right.id); +} + +function simulationRequestToOperationsRequest(request: import("@atos/simulation").ReplanningRequest): OperationsReplanningRequest { + return { + ...request, + source: "simulation", + trigger: triggerFromSimulationRequest({ + reason: request.reason, + deficiency: request.deficiency, + }), + }; +} + +function createManualReplanningRequest( + session: OperationsSession, + input: ManualReplanInput, +): OperationsReplanningRequest { + const generation = currentGeneration(session); + const currentTime = input.currentTime ?? session.runtime.clock.currentTime; + return { + id: `replan:${session.sessionId}:manual:${generation.generationNumber + 1}`, + source: "operator", + trigger: "operator_request", + status: "requested", + currentTime, + triggeredByEventId: `operator:manual:${generation.generationNumber + 1}`, + chitIds: generation.dispatchResult.normalizedChits + .filter((chit) => chit.status !== "satisfied") + .map((chit) => chit.id) + .sort(), + affectedAssetIds: [], + releasedReservationIds: [], + retainedReservationIds: session.runtime.reservations + .filter((reservation) => reservation.status === "active" || reservation.status === "held") + .map((reservation) => reservation.reservation.id) + .sort(), + assetStates: session.runtime.assets.slice().sort((left, right) => left.assetId.localeCompare(right.assetId)), + reason: input.note ?? "Operator requested a deterministic manual replan.", + operatorNote: input.note, + }; +} + +function generationFor( + sessionId: string, + generationNumber: number, + createdAt: string, + dispatchResult: import("@atos/dispatch").DispatchPlannerResult, + policyDecision?: import("./types").ReplanningPolicyDecision, + projection?: import("./types").ProjectedDispatchContext, +): PlanningGeneration { + return { + id: `planning-generation:${sessionId}:${generationNumber}`, + generationNumber, + createdAt, + dispatchResult, + policyDecision, + projection, + }; +} + +function currentGeneration(session: OperationsSession): PlanningGeneration { + return session.generations.find((generation) => generation.id === session.currentGenerationId) ?? session.generations.at(-1) as PlanningGeneration; +} + +function upsertPolicyDecision( + decisions: readonly import("./types").ReplanningPolicyDecision[], + decision: import("./types").ReplanningPolicyDecision, +): import("./types").ReplanningPolicyDecision[] { + return [ + ...decisions.filter((candidate) => candidate.id !== decision.id), + decision, + ].sort((left, right) => left.id.localeCompare(right.id)); +} + +function upsertIncident( + session: OperationsSession, + request: OperationsReplanningRequest, + decision: import("./types").ReplanningPolicyDecision, + revisedGenerationId?: string, + planDiff?: PlanDiffResult, + deficiencyCarryForward?: DeficiencyCarryForwardResult, +): import("./types").OperationsIncident[] { + const incident = correlateIncident({ + sessionId: session.sessionId, + request, + decision, + runtime: session.runtime, + previousGenerationId: session.currentGenerationId, + revisedGenerationId, + planDiff, + deficiencyCarryForward, + }); + return [ + ...session.incidents.filter((candidate) => candidate.id !== incident.id), + incident, + ].sort((left, right) => left.id.localeCompare(right.id)); +} + +function emptyReservationReconciliation(): ReservationReconciliationResult { + return { + records: [], + retainedReservationIds: [], + releasedReservationIds: [], + supersededReservationIds: [], + newReservationIds: [], + activeOccupancyResourceIds: [], + duplicateOwnershipConflicts: [], + }; +} + +function emptyPlanDiff(): PlanDiffResult { + return { + records: [], + unchangedMissionIds: [], + delayedMissionIds: [], + cancelledMissionIds: [], + replacementMissionIds: [], + newlySatisfiedChitIds: [], + newlyUnsatisfiedChitIds: [], + scoreDelta: 0, + rationale: "No revised planning generation has been produced yet.", + }; +} diff --git a/packages/operations/src/plan-diff.ts b/packages/operations/src/plan-diff.ts new file mode 100644 index 0000000..70628d4 --- /dev/null +++ b/packages/operations/src/plan-diff.ts @@ -0,0 +1,141 @@ +import type { MissionPlan } from "@atos/dispatch"; +import type { PlanDiffInput, PlanDiffResult, MissionPlanDiff } from "./types"; + +export function diffDispatchPlans(input: PlanDiffInput): PlanDiffResult { + const previousMissions = [...input.previous.missionPlans].sort(compareMission); + const revisedMissions = [...input.revised.missionPlans].sort(compareMission); + const revisedById = new Map(revisedMissions.map((mission) => [mission.id, mission])); + const revisedByChitKey = new Map(revisedMissions.map((mission) => [chitKey(mission), mission])); + const matchedRevisedIds = new Set(); + const records: MissionPlanDiff[] = []; + + for (const previous of previousMissions) { + const revised = revisedById.get(previous.id) ?? revisedByChitKey.get(chitKey(previous)); + if (!revised) { + records.push(diffRecord({ previous })); + continue; + } + matchedRevisedIds.add(revised.id); + records.push(diffRecord({ previous, revised })); + } + + for (const revised of revisedMissions) { + if (!matchedRevisedIds.has(revised.id)) { + records.push(diffRecord({ revised })); + } + } + + const previousSatisfied = new Set(input.previous.missionPlans.flatMap((mission) => mission.chitIds)); + const revisedSatisfied = new Set(input.revised.missionPlans.flatMap((mission) => mission.chitIds)); + const newlySatisfiedChitIds = [...revisedSatisfied].filter((id) => !previousSatisfied.has(id)).sort(); + const newlyUnsatisfiedChitIds = [...previousSatisfied].filter((id) => !revisedSatisfied.has(id)).sort(); + const sortedRecords = records.sort((left, right) => left.id.localeCompare(right.id)); + + return { + records: sortedRecords, + unchangedMissionIds: idsByStatus(sortedRecords, "unchanged"), + delayedMissionIds: idsByStatus(sortedRecords, "delayed"), + cancelledMissionIds: idsByStatus(sortedRecords, "cancelled"), + replacementMissionIds: idsByStatus(sortedRecords, "replacement"), + newlySatisfiedChitIds, + newlyUnsatisfiedChitIds, + scoreDelta: input.revised.scoreBreakdown.total - input.previous.scoreBreakdown.total, + rationale: `${sortedRecords.length} mission diff records; score delta ${(input.revised.scoreBreakdown.total - input.previous.scoreBreakdown.total).toFixed(2)}.`, + }; +} + +function diffRecord({ previous, revised }: { previous?: MissionPlan; revised?: MissionPlan }): MissionPlanDiff { + const routeChanged = Boolean(previous && revised && previous.route.linkIds.join("|") !== revised.route.linkIds.join("|")); + const consistChanged = Boolean(previous && revised && previous.assetIds.join("|") !== revised.assetIds.join("|")); + const reservationsChanged = Boolean(previous && revised && previous.reservationIds.join("|") !== revised.reservationIds.join("|")); + const scoreDelta = (revised?.score.total ?? 0) - (previous?.score.total ?? 0); + const deadlineImpactMinutes = previous && revised + ? (Date.parse(revised.endsAt) - Date.parse(previous.endsAt)) / 60_000 + : 0; + const powerChanged = Boolean(previous && revised && previous.launchGate.status !== revised.launchGate.status); + const energyChanged = Boolean(previous && revised && previous.launchGate.supportAssetIds.join("|") !== revised.launchGate.supportAssetIds.join("|")); + const status = missionStatus(previous, revised, routeChanged, consistChanged, reservationsChanged, deadlineImpactMinutes, powerChanged, scoreDelta); + return { + id: `plan-diff:${previous?.id ?? "new"}:${revised?.id ?? "cancelled"}`, + status, + previousMissionId: previous?.id, + revisedMissionId: revised?.id, + chitIds: [...(revised?.chitIds ?? previous?.chitIds ?? [])].sort(), + routeChanged, + consistChanged, + reservationsChanged, + deadlineImpactMinutes, + scoreDelta, + powerChanged, + energyChanged, + rationale: rationaleFor(status, { routeChanged, consistChanged, reservationsChanged, deadlineImpactMinutes, scoreDelta, powerChanged }), + }; +} + +function missionStatus( + previous: MissionPlan | undefined, + revised: MissionPlan | undefined, + routeChanged: boolean, + consistChanged: boolean, + reservationsChanged: boolean, + deadlineImpactMinutes: number, + powerChanged: boolean, + scoreDelta: number, +): MissionPlanDiff["status"] { + if (!previous && revised) { + return "replacement"; + } + if (previous && !revised) { + return "cancelled"; + } + if (deadlineImpactMinutes > 0) { + return "delayed"; + } + if (routeChanged || consistChanged || reservationsChanged || powerChanged || Math.abs(scoreDelta) > 0.001) { + return "changed"; + } + return "unchanged"; +} + +function rationaleFor(status: MissionPlanDiff["status"], facts: { + routeChanged: boolean; + consistChanged: boolean; + reservationsChanged: boolean; + deadlineImpactMinutes: number; + scoreDelta: number; + powerChanged: boolean; +}): string { + if (status === "unchanged") { + return "Mission route, consist, reservations, power status, deadline, and score are unchanged."; + } + if (status === "cancelled") { + return "Previous mission no longer appears in the revised planning generation."; + } + if (status === "replacement") { + return "Revised planning generation introduced a new mission."; + } + const changes = [ + facts.routeChanged ? "route" : "", + facts.consistChanged ? "consist" : "", + facts.reservationsChanged ? "reservations" : "", + facts.powerChanged ? "power gate" : "", + facts.deadlineImpactMinutes > 0 ? `${facts.deadlineImpactMinutes.toFixed(1)} minute delay` : "", + Math.abs(facts.scoreDelta) > 0.001 ? `score delta ${facts.scoreDelta.toFixed(2)}` : "", + ].filter(Boolean); + return `Mission changed: ${changes.join(", ")}.`; +} + +function idsByStatus(records: readonly MissionPlanDiff[], status: MissionPlanDiff["status"]): string[] { + return records + .filter((record) => record.status === status) + .map((record) => record.revisedMissionId ?? record.previousMissionId ?? record.id) + .sort(); +} + +function chitKey(mission: MissionPlan): string { + return mission.chitIds.slice().sort().join("|"); +} + +function compareMission(left: MissionPlan, right: MissionPlan): number { + return left.id.localeCompare(right.id); +} diff --git a/packages/operations/src/replanning-policy.ts b/packages/operations/src/replanning-policy.ts new file mode 100644 index 0000000..be8248c --- /dev/null +++ b/packages/operations/src/replanning-policy.ts @@ -0,0 +1,151 @@ +import type { + OperationsReplanningRequest, + PolicyTriggerInput, + ReplanningPolicyDecision, + ReplanningTriggerKind, +} from "./types"; + +const severityRank = { + info: 0, + warning: 1, + error: 2, +} as const; + +export function evaluateReplanningPolicy(input: PolicyTriggerInput): ReplanningPolicyDecision { + const trigger = normalizeTrigger(input); + const base = { + id: `policy:${input.requestId}`, + requestId: input.requestId, + trigger, + scopeMissionIds: input.missionId ? [input.missionId] : [], + scopeChitIds: [...(input.chitIds ?? [])].sort(), + scopeAssetIds: [...(input.affectedAssetIds ?? [])].sort(), + }; + + if (trigger === "fault_cleared" || trigger === "completed_mission") { + return { + ...base, + mode: "none", + priority: "low", + rationale: "The trigger does not invalidate open work or active reservations.", + }; + } + + if (trigger === "released_high_priority_asset") { + return { + ...base, + mode: "deferred", + priority: "normal", + deferUntil: "mission_boundary", + rationale: "A high-priority asset became available; defer replanning until the current mission boundary to avoid needless churn.", + }; + } + + if (trigger === "operator_request" || trigger === "material_queue_growth" || trigger === "missed_deadline") { + return { + ...base, + mode: "full", + priority: trigger === "operator_request" ? "normal" : "high", + rationale: fullReplanRationale(trigger), + }; + } + + if (trigger === "mission_failure" && !input.missionId) { + return { + ...base, + mode: "full", + priority: "critical", + rationale: "A mission failure without a bounded mission scope can invalidate the full open queue.", + }; + } + + return { + ...base, + mode: "partial", + priority: partialPriority(input), + rationale: partialReplanRationale(trigger, input.reason), + }; +} + +export function policyDecisionForRequest(request: OperationsReplanningRequest): ReplanningPolicyDecision { + return evaluateReplanningPolicy({ + requestId: request.id, + trigger: request.trigger, + missionId: request.missionId, + chitIds: request.chitIds, + affectedAssetIds: request.affectedAssetIds, + affectedResourceIds: [ + ...request.releasedReservationIds, + ...request.retainedReservationIds, + ], + reason: request.reason, + deficiency: request.deficiency, + currentTime: request.currentTime, + }); +} + +export function triggerFromSimulationRequest(request: Pick): ReplanningTriggerKind { + const reason = request.reason.toLowerCase(); + if (request.deficiency?.kind === "reservation_conflict" || reason.includes("reservation")) { + return "reservation_conflict"; + } + if (request.deficiency?.kind === "power_blocked" || request.deficiency?.kind === "power_delayed" || reason.includes("power")) { + return "power_launch_failure"; + } + if (request.deficiency?.kind === "state_of_charge" || reason.includes("battery") || reason.includes("reserve")) { + return "battery_power_failure"; + } + if (request.deficiency?.kind === "service_zone_full" || reason.includes("service") || reason.includes("charger")) { + return "service_outage"; + } + if (request.deficiency?.kind === "route_unreachable" || reason.includes("route") || reason.includes("guideway") || reason.includes("blocked")) { + return "route_blockage"; + } + if (request.deficiency?.kind === "asset_unavailable" || reason.includes("asset") || reason.includes("vehicle")) { + return "asset_failure"; + } + if (reason.includes("deadline") || reason.includes("late")) { + return "missed_deadline"; + } + if (reason.includes("failed") || reason.includes("failure")) { + return "mission_failure"; + } + return "mission_failure"; +} + +function normalizeTrigger(input: PolicyTriggerInput): ReplanningTriggerKind { + if (input.trigger === "material_queue_growth" && input.queueGrowthRatio !== undefined && input.queueGrowthRatio < 0.25) { + return "fault_cleared"; + } + return input.trigger; +} + +function partialPriority(input: PolicyTriggerInput): ReplanningPolicyDecision["priority"] { + if (input.deficiency?.severity === "error" || input.trigger === "mission_failure") { + return "critical"; + } + if (input.deficiency && severityRank[input.deficiency.severity] >= severityRank.warning) { + return "high"; + } + if (input.trigger === "route_blockage" || input.trigger === "battery_power_failure") { + return "high"; + } + return "normal"; +} + +function fullReplanRationale(trigger: ReplanningTriggerKind): string { + switch (trigger) { + case "operator_request": + return "The operator requested a deterministic full replan of the open queue."; + case "material_queue_growth": + return "Queue growth can alter global priority and grouping, so the full queue is replanned."; + case "missed_deadline": + return "A missed hard deadline invalidates ranking assumptions across the open queue."; + default: + return "The trigger invalidates the current planning generation globally."; + } +} + +function partialReplanRationale(trigger: ReplanningTriggerKind, reason = "runtime divergence"): string { + return `${trigger.replaceAll("_", " ")} affects a bounded mission or resource scope: ${reason}`; +} diff --git a/packages/operations/src/reservation-reconciliation.ts b/packages/operations/src/reservation-reconciliation.ts new file mode 100644 index 0000000..d458226 --- /dev/null +++ b/packages/operations/src/reservation-reconciliation.ts @@ -0,0 +1,164 @@ +import type { DispatchReservation } from "@atos/dispatch"; +import type { + ReservationConflictRecord, + ReservationReconciliationInput, + ReservationReconciliationRecord, + ReservationReconciliationResult, +} from "./types"; + +export function reconcileReservations(input: ReservationReconciliationInput): ReservationReconciliationResult { + const revisedByResource = new Map(); + for (const reservation of input.revisedReservations) { + const bucket = revisedByResource.get(reservation.resourceId) ?? []; + bucket.push(reservation); + revisedByResource.set(reservation.resourceId, bucket); + } + + const records: ReservationReconciliationRecord[] = []; + const activeOccupancyResourceIds = [ + ...input.runtime.guidewayOccupancy.map((occupancy) => `guideway-link:${occupancy.linkId}`), + ...input.runtime.serviceOccupancy.map((occupancy) => occupancy.resourceId), + ].sort(); + const activeOccupancySet = new Set(activeOccupancyResourceIds); + + for (const runtimeReservation of input.runtimeReservations) { + const reservation = runtimeReservation.reservation; + const matchingRevised = (revisedByResource.get(reservation.resourceId) ?? []) + .find((candidate) => reservationsOverlap(reservation, candidate)); + const terminalMission = input.runtime.missions.find((mission) => mission.plan.id === reservation.missionPlanId && + ["completed", "failed", "cancelled", "blocked"].includes(mission.state) + ); + + if (activeOccupancySet.has(reservation.resourceId) && runtimeReservation.status === "active") { + records.push(record(reservation, "active_occupancy", "Active in-transit or service occupancy is preserved until explicit release.")); + } else if (runtimeReservation.status === "released" || terminalMission) { + records.push(record(reservation, "historical", "Reservation belongs to completed or released execution history.")); + } else if (matchingRevised) { + records.push(record(reservation, "retained", `Still required by revised reservation ${matchingRevised.id}.`)); + } else if (runtimeReservation.status === "held" || runtimeReservation.status === "planned") { + records.push(record(reservation, "released", "Reservation is no longer required by the revised planning generation.")); + } else { + records.push(record(reservation, "superseded", "Reservation was replaced by a new resource or time window.")); + } + } + + const previousIds = new Set(input.previousReservations.map((reservation) => reservation.id)); + const retainedIds = new Set(records.filter((entry) => entry.status === "retained" || entry.status === "active_occupancy").map((entry) => entry.reservationId)); + for (const revised of input.revisedReservations) { + if (!previousIds.has(revised.id) && !retainedIds.has(revised.id)) { + records.push(record(revised, "new", "Reservation was introduced by the revised planning generation.")); + } + } + + const duplicateOwnershipConflicts = detectDuplicateOwnership([ + ...input.runtimeReservations + .filter((reservation) => reservation.status === "active" || reservation.status === "held") + .map((reservation) => reservation.reservation), + ...input.revisedReservations, + ], resourceCapacityIndex(input.runtime.scenario)); + + return { + records: records.sort(compareRecord), + retainedReservationIds: idsFor(records, "retained"), + releasedReservationIds: idsFor(records, "released"), + supersededReservationIds: idsFor(records, "superseded"), + newReservationIds: idsFor(records, "new"), + activeOccupancyResourceIds, + duplicateOwnershipConflicts, + }; +} + +function record( + reservation: DispatchReservation, + status: ReservationReconciliationRecord["status"], + reason: string, +): ReservationReconciliationRecord { + return { + id: `reservation-reconciliation:${status}:${reservation.id}`, + reservationId: reservation.id, + resourceId: reservation.resourceId, + missionPlanId: reservation.missionPlanId, + status, + reason, + }; +} + +function reservationsOverlap(left: DispatchReservation, right: DispatchReservation): boolean { + return left.resourceId === right.resourceId && + Date.parse(left.startTime) < Date.parse(right.endTime) && + Date.parse(right.startTime) < Date.parse(left.endTime); +} + +function detectDuplicateOwnership( + reservations: readonly DispatchReservation[], + resourceCapacity: ReadonlyMap, +): ReservationConflictRecord[] { + const conflicts: ReservationConflictRecord[] = []; + const resources = [...new Set(reservations.map((reservation) => reservation.resourceId))].sort(); + for (const resourceId of resources) { + const capacity = resourceCapacity.get(resourceId) ?? 1; + if (!Number.isFinite(capacity)) { + continue; + } + const resourceReservations = reservations + .filter((reservation) => reservation.resourceId === resourceId) + .sort((left, right) => left.id.localeCompare(right.id)); + for (const anchor of resourceReservations) { + const overlapping = resourceReservations.filter((candidate) => reservationsOverlap(anchor, candidate)); + const ownerIds = new Set(overlapping.map((reservation) => reservation.missionPlanId)); + if (ownerIds.size > capacity) { + const reservationIds = overlapping.map((reservation) => reservation.id).sort(); + const id = `reservation-conflict:${resourceId}:${reservationIds.join("+")}`; + if (!conflicts.some((conflict) => conflict.id === id)) { + conflicts.push({ + id, + resourceId, + reservationIds, + reason: `${ownerIds.size} mission owners overlap on ${resourceId}, exceeding capacity ${capacity}.`, + }); + } + } + } + } + return conflicts.sort((left, right) => left.id.localeCompare(right.id)); +} + +function resourceCapacityIndex(scenario: ReservationReconciliationInput["runtime"]["scenario"]): Map { + const capacity = new Map(); + for (const zone of scenario.serviceZones) { + capacity.set(`station-zone:${zone.id}`, zone.capacity); + } + for (const link of scenario.guideway.links) { + capacity.set(`guideway-link:${link.id}`, 1); + } + for (const vehicle of scenario.inventory.vehicles) { + capacity.set(`asset:${vehicle.id}`, 1); + } + for (const state of [ + "nominal", + "degraded", + "brownout", + "overloaded", + "source_limited", + "non_converged", + "islanded", + "invalid", + ]) { + capacity.set(`power-window:${state}`, Number.POSITIVE_INFINITY); + } + return capacity; +} + +function idsFor( + records: readonly ReservationReconciliationRecord[], + status: ReservationReconciliationRecord["status"], +): string[] { + return records + .filter((record) => record.status === status) + .map((record) => record.reservationId) + .sort(); +} + +function compareRecord(left: ReservationReconciliationRecord, right: ReservationReconciliationRecord): number { + return left.status.localeCompare(right.status) || left.reservationId.localeCompare(right.reservationId); +} diff --git a/packages/operations/src/state-projection.ts b/packages/operations/src/state-projection.ts new file mode 100644 index 0000000..d83de12 --- /dev/null +++ b/packages/operations/src/state-projection.ts @@ -0,0 +1,176 @@ +import { createDispatchPlannerInput } from "@atos/dispatch"; +import type { ChitQuantity, StableId, VehicleState } from "@atos/domain"; +import type { ScenarioDocumentV1 } from "@atos/scenario"; +import type { SimulationRuntimeState, RuntimeAssetState } from "@atos/simulation"; +import type { ProjectedDispatchContext } from "./types"; + +export function projectRuntimeStateToDispatchInput(runtime: SimulationRuntimeState): ProjectedDispatchContext { + const projectedScenario = cloneScenario(runtime.scenario); + const nodeTileIds = new Map(runtime.scenario.guideway.nodes.map((node) => [node.id, node.tileId])); + const runtimeAssets = new Map(runtime.assets.map((asset) => [asset.assetId, asset])); + const progressByChit = new Map( + runtime.missions.flatMap((mission) => mission.chitProgress.map((progress) => [progress.chitId, progress] as const)), + ); + + projectedScenario.simulation = { + ...projectedScenario.simulation, + currentTime: runtime.clock.currentTime, + }; + projectedScenario.inventory = { + ...projectedScenario.inventory, + vehicles: projectedScenario.inventory.vehicles.map((vehicle) => { + const runtimeAsset = runtimeAssets.get(vehicle.id); + if (!runtimeAsset) { + return vehicle; + } + return { + ...vehicle, + state: vehicleStateForRuntimeAsset(runtimeAsset), + currentTileId: runtimeAsset.tileId ?? (runtimeAsset.nodeId ? nodeTileIds.get(runtimeAsset.nodeId) : undefined) ?? vehicle.currentTileId, + battery: runtimeAsset.battery ? { ...runtimeAsset.battery } : vehicle.battery, + }; + }), + }; + projectedScenario.chits = projectedScenario.chits.map((chit) => { + const progress = progressByChit.get(chit.id); + if (!progress) { + return chit; + } + const quantity = remainingQuantity(chit.quantity, progress.unloaded); + const status = projectedChitStatus(chit.status, progress.status, quantity); + return { ...chit, status, quantity }; + }); + + const unavailableResourceIds = uniqueSorted([ + ...runtime.faults.flatMap((fault) => canonicalResourceIds(runtime.scenario, fault.targetId)), + ...runtime.guidewayOccupancy.map((occupancy) => `guideway-link:${occupancy.linkId}`), + ...runtime.serviceOccupancy.map((occupancy) => occupancy.resourceId), + ]); + const unavailableAssetIds = uniqueSorted(runtime.assets + .filter((asset) => asset.health === "faulted" || asset.health === "maintenance_due") + .map((asset) => asset.assetId)); + const activeReservations = runtime.reservations + .filter((reservation) => reservation.status === "active" || reservation.status === "held") + .sort((left, right) => left.reservation.id.localeCompare(right.reservation.id)); + const powerConstraintIds = uniqueSorted(runtime.eventHistory + .filter((event) => event.type === "power_gate_failed" || event.type === "battery_reserve_violated") + .flatMap((event) => event.affectedResourceIds.flatMap((resourceId) => canonicalResourceIds(runtime.scenario, resourceId)))); + + return { + projectedScenario, + dispatchInput: createDispatchPlannerInput(projectedScenario, { + currentTime: runtime.clock.currentTime, + runtimeConstraints: { + retainedReservations: activeReservations.map((reservation) => reservation.reservation), + unavailableResourceIds, + unavailableAssetIds, + powerConstraintIds, + }, + }), + runtimeAssetStates: [...runtime.assets].sort((left, right) => left.assetId.localeCompare(right.assetId)), + activeReservations, + unavailableResourceIds, + unavailableAssetIds, + activeConsistIds: runtime.consists + .filter((consist) => consist.status === "formed" || consist.status === "forming") + .map((consist) => consist.id) + .sort(), + currentTime: runtime.clock.currentTime, + powerConstraintIds, + }; +} + +function projectedChitStatus( + currentStatus: "unsatisfied" | "candidate" | "reserved" | "active" | "satisfied" | "failed", + progressStatus: "pending" | "loaded" | "satisfied" | "failed", + quantity: ChitQuantity, +): "unsatisfied" | "candidate" | "reserved" | "active" | "satisfied" | "failed" { + if (progressStatus === "failed") { + return "failed"; + } + if (progressStatus === "satisfied" || quantitySatisfied(quantity)) { + return "satisfied"; + } + if (progressStatus === "loaded") { + return "active"; + } + return currentStatus; +} + +function remainingQuantity(original: ChitQuantity, fulfilled: ChitQuantity): ChitQuantity { + return stripZeroQuantity({ + passengers: subtractQuantity(original.passengers, fulfilled.passengers), + massKg: subtractQuantity(original.massKg, fulfilled.massKg), + volumeLiters: subtractQuantity(original.volumeLiters, fulfilled.volumeLiters), + energyWh: subtractQuantity(original.energyWh, fulfilled.energyWh), + }); +} + +function subtractQuantity(original: number | undefined, fulfilled: number | undefined): number | undefined { + if (original === undefined) { + return undefined; + } + return Math.max(0, original - (fulfilled ?? 0)); +} + +function stripZeroQuantity(quantity: ChitQuantity): ChitQuantity { + return Object.fromEntries( + Object.entries(quantity).filter(([, value]) => typeof value === "number" && value > 0), + ) as ChitQuantity; +} + +function quantitySatisfied(quantity: ChitQuantity): boolean { + return Object.values(quantity).every((value) => (value ?? 0) <= 0); +} + +function canonicalResourceIds(scenario: ScenarioDocumentV1, resourceId: StableId): StableId[] { + if ( + resourceId.startsWith("asset:") || + resourceId.startsWith("guideway-link:") || + resourceId.startsWith("station-zone:") || + resourceId.startsWith("power-window:") + ) { + return [resourceId]; + } + if (scenario.guideway.links.some((link) => link.id === resourceId)) { + return [resourceId, `guideway-link:${resourceId}`].sort(); + } + if (scenario.serviceZones.some((zone) => zone.id === resourceId)) { + return [resourceId, `station-zone:${resourceId}`].sort(); + } + if (scenario.inventory.vehicles.some((vehicle) => vehicle.id === resourceId)) { + return [resourceId, `asset:${resourceId}`].sort(); + } + if ( + scenario.electrical.sources.some((source) => source.id === resourceId) || + scenario.electrical.loads.some((load) => load.id === resourceId) || + scenario.electrical.branches.some((branch) => branch.id === resourceId) + ) { + return [resourceId, `asset:${resourceId}`].sort(); + } + return [resourceId]; +} + +function uniqueSorted(values: readonly StableId[]): StableId[] { + return [...new Set(values)].sort(); +} + +function vehicleStateForRuntimeAsset(asset: RuntimeAssetState): VehicleState { + if (asset.health === "maintenance_due") { + return "maintenance"; + } + if (asset.health === "faulted") { + return "maintenance"; + } + if (asset.activeMissionId) { + return "active"; + } + if (asset.consistId) { + return "reserved"; + } + return "available"; +} + +function cloneScenario(scenario: ScenarioDocumentV1): ScenarioDocumentV1 { + return JSON.parse(JSON.stringify(scenario)) as ScenarioDocumentV1; +} diff --git a/packages/operations/src/types.ts b/packages/operations/src/types.ts new file mode 100644 index 0000000..debb9f6 --- /dev/null +++ b/packages/operations/src/types.ts @@ -0,0 +1,272 @@ +import type { + DeficiencyGate, + DispatchPlannerInput, + DispatchPlannerResult, + DispatchReservation, + MissionPlan, +} from "@atos/dispatch"; +import type { IsoDateTimeString, StableId } from "@atos/domain"; +import type { ScenarioDocumentV1 } from "@atos/scenario"; +import type { + ReplanningRequest, + RuntimeAssetState, + RuntimeReservation, + SimulationEvent, + SimulationRuntimeState, +} from "@atos/simulation"; + +export type ReplanningTriggerKind = + | "mission_failure" + | "route_blockage" + | "reservation_conflict" + | "asset_failure" + | "battery_power_failure" + | "power_launch_failure" + | "service_outage" + | "missed_deadline" + | "material_queue_growth" + | "released_high_priority_asset" + | "operator_request" + | "fault_cleared" + | "completed_mission"; + +export type ReplanningMode = "none" | "deferred" | "partial" | "full"; + +export type ReplanningPolicyDecision = { + id: StableId; + requestId: StableId; + trigger: ReplanningTriggerKind; + mode: ReplanningMode; + priority: "low" | "normal" | "high" | "critical"; + scopeMissionIds: StableId[]; + scopeChitIds: StableId[]; + scopeAssetIds: StableId[]; + deferUntil?: "mission_boundary" | "service_boundary"; + rationale: string; +}; + +export type OperationsReplanningRequest = ReplanningRequest & { + source: "simulation" | "operator"; + trigger: ReplanningTriggerKind; + operatorNote?: string; +}; + +export type ProjectedDispatchContext = { + projectedScenario: ScenarioDocumentV1; + dispatchInput: DispatchPlannerInput; + runtimeAssetStates: RuntimeAssetState[]; + activeReservations: RuntimeReservation[]; + unavailableResourceIds: StableId[]; + unavailableAssetIds: StableId[]; + activeConsistIds: StableId[]; + currentTime: IsoDateTimeString; + powerConstraintIds: StableId[]; +}; + +export type ReservationReconciliationRecord = { + id: StableId; + reservationId: StableId; + resourceId: StableId; + missionPlanId: StableId; + status: "historical" | "retained" | "released" | "superseded" | "new" | "active_occupancy"; + reason: string; +}; + +export type ReservationConflictRecord = { + id: StableId; + resourceId: StableId; + reservationIds: StableId[]; + reason: string; +}; + +export type ReservationReconciliationResult = { + records: ReservationReconciliationRecord[]; + retainedReservationIds: StableId[]; + releasedReservationIds: StableId[]; + supersededReservationIds: StableId[]; + newReservationIds: StableId[]; + activeOccupancyResourceIds: StableId[]; + duplicateOwnershipConflicts: ReservationConflictRecord[]; +}; + +export type MissionDiffStatus = "unchanged" | "delayed" | "cancelled" | "replacement" | "changed"; + +export type MissionPlanDiff = { + id: StableId; + status: MissionDiffStatus; + previousMissionId?: StableId; + revisedMissionId?: StableId; + chitIds: StableId[]; + routeChanged: boolean; + consistChanged: boolean; + reservationsChanged: boolean; + deadlineImpactMinutes: number; + scoreDelta: number; + powerChanged: boolean; + energyChanged: boolean; + rationale: string; +}; + +export type PlanDiffResult = { + records: MissionPlanDiff[]; + unchangedMissionIds: StableId[]; + delayedMissionIds: StableId[]; + cancelledMissionIds: StableId[]; + replacementMissionIds: StableId[]; + newlySatisfiedChitIds: StableId[]; + newlyUnsatisfiedChitIds: StableId[]; + scoreDelta: number; + rationale: string; +}; + +export type DeficiencyCarryForwardStatus = + | "resolved" + | "worsened" + | "unchanged" + | "superseded" + | "transformed" + | "new"; + +export type DeficiencyCarryForwardRecord = { + id: StableId; + previousDeficiencyId?: StableId; + revisedDeficiencyId?: StableId; + status: DeficiencyCarryForwardStatus; + kind: DeficiencyGate["kind"]; + chitIds: StableId[]; + rationale: string; +}; + +export type InfrastructureFinding = { + id: StableId; + kind: DeficiencyGate["kind"]; + recurrenceCount: number; + deficiencyIds: StableId[]; + affectedIds: StableId[]; + summary: string; +}; + +export type DeficiencyCarryForwardResult = { + records: DeficiencyCarryForwardRecord[]; + infrastructureFindings: InfrastructureFinding[]; +}; + +export type OperationsIncident = { + id: StableId; + requestId: StableId; + triggeringEventIds: StableId[]; + affectedMissionIds: StableId[]; + affectedChitIds: StableId[]; + affectedAssetIds: StableId[]; + affectedResourceIds: StableId[]; + previousGenerationId: StableId; + revisedGenerationId?: StableId; + deficiencyIds: StableId[]; + resolutionState: "open" | "deferred" | "replanned" | "no_action" | "resolved"; + summary: string; +}; + +export type OperationsMetrics = { + simulatedHours: number; + replansPerSimulatedHour: number; + missionCompletionRate: number; + onTimeCompletionRate: number; + averageQueueWaitMinutes: number; + averagePassengerWaitMinutes: number; + averageCargoLatenessMinutes: number; + assetUtilization: number; + emptyMovementShare: number; + reservationConflictRate: number; + energyDelayCount: number; + missionFailuresByCause: Record; + deficiencyRecurrence: Record; + planningChurn: number; +}; + +export type PlanningGeneration = { + id: StableId; + generationNumber: number; + createdAt: IsoDateTimeString; + dispatchResult: DispatchPlannerResult; + policyDecision?: ReplanningPolicyDecision; + projection?: ProjectedDispatchContext; + reservationReconciliation?: ReservationReconciliationResult; + planDiff?: PlanDiffResult; + deficiencyCarryForward?: DeficiencyCarryForwardResult; +}; + +export type OperationsSession = { + schemaVersion: 1; + sessionId: StableId; + scenarioId: StableId; + scenarioSchemaVersion: number; + currentGenerationId: StableId; + generations: PlanningGeneration[]; + runtime: SimulationRuntimeState; + pendingRequests: OperationsReplanningRequest[]; + policyDecisions: ReplanningPolicyDecision[]; + reservationReconciliation: ReservationReconciliationResult; + planDiff: PlanDiffResult; + deficiencyCarryForward: DeficiencyCarryForwardResult; + incidents: OperationsIncident[]; + metrics: OperationsMetrics; +}; + +export type OperationsSessionInput = { + scenario: ScenarioDocumentV1; + dispatchResult: DispatchPlannerResult; + runtime: SimulationRuntimeState; + sessionId?: StableId; +}; + +export type ManualReplanInput = { + currentTime?: IsoDateTimeString; + note?: string; +}; + +export type IncidentCorrelationInput = { + sessionId: StableId; + request: OperationsReplanningRequest; + decision: ReplanningPolicyDecision; + runtime: SimulationRuntimeState; + previousGenerationId: StableId; + revisedGenerationId?: StableId; + planDiff?: PlanDiffResult; + deficiencyCarryForward?: DeficiencyCarryForwardResult; +}; + +export type PlanDiffInput = { + previous: DispatchPlannerResult; + revised: DispatchPlannerResult; +}; + +export type ReservationReconciliationInput = { + previousReservations: readonly DispatchReservation[]; + runtimeReservations: readonly RuntimeReservation[]; + revisedReservations: readonly DispatchReservation[]; + runtime: SimulationRuntimeState; +}; + +export type PolicyTriggerInput = { + requestId: StableId; + trigger: ReplanningTriggerKind; + missionId?: StableId; + chitIds?: readonly StableId[]; + affectedAssetIds?: readonly StableId[]; + affectedResourceIds?: readonly StableId[]; + reason?: string; + deficiency?: DeficiencyGate; + currentTime?: IsoDateTimeString; + queueGrowthRatio?: number; + priority?: number; +}; + +export type MissionPlanPair = { + previous?: MissionPlan; + revised?: MissionPlan; +}; + +export type EventCorrelationSource = Pick< + SimulationEvent, + "id" | "missionId" | "affectedAssetIds" | "affectedResourceIds" | "causalEventId" +>; diff --git a/packages/operations/tsconfig.json b/packages/operations/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/operations/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +}