From 66a56eb2c4f72160f560570a2b185ad63452c0fc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:28:30 +0200 Subject: [PATCH 1/3] FE-1500: add Petrinaut examples --- apps/petrinaut-website/.gitignore | 1 + apps/petrinaut-website/package.json | 3 +- .../scripts/generate-example-artifacts.ts | 86 + .../src/examples/catalog-metadata.ts | 111 + .../src/examples/catalog.test.ts | 57 + .../petrinaut-website/src/examples/catalog.ts | 112 + .../gases-1-pn-consumption-trigger.json | 486 ++++ .../src/examples/models/gases-1-pn.json | 472 ++++ .../src/examples/models/gases-2-spn.json | 978 +++++++ .../src/examples/models/gases-3-cpn.json | 1300 ++++++++++ .../src/examples/models/gases-4-dcpn.json | 1619 ++++++++++++ .../models/semiconductor-fab-drift.json | 1545 +++++++++++ .../truck-fleet-predictive-maintenance.json | 2265 +++++++++++++++++ .../src/examples/normalize-example.test.ts | 85 + .../src/examples/normalize-example.ts | 83 + apps/petrinaut-website/turbo.json | 22 +- oxfmt.config.ts | 1 + 17 files changed, 9224 insertions(+), 2 deletions(-) create mode 100644 apps/petrinaut-website/scripts/generate-example-artifacts.ts create mode 100644 apps/petrinaut-website/src/examples/catalog-metadata.ts create mode 100644 apps/petrinaut-website/src/examples/catalog.test.ts create mode 100644 apps/petrinaut-website/src/examples/catalog.ts create mode 100644 apps/petrinaut-website/src/examples/models/gases-1-pn-consumption-trigger.json create mode 100644 apps/petrinaut-website/src/examples/models/gases-1-pn.json create mode 100644 apps/petrinaut-website/src/examples/models/gases-2-spn.json create mode 100644 apps/petrinaut-website/src/examples/models/gases-3-cpn.json create mode 100644 apps/petrinaut-website/src/examples/models/gases-4-dcpn.json create mode 100644 apps/petrinaut-website/src/examples/models/semiconductor-fab-drift.json create mode 100644 apps/petrinaut-website/src/examples/models/truck-fleet-predictive-maintenance.json create mode 100644 apps/petrinaut-website/src/examples/normalize-example.test.ts create mode 100644 apps/petrinaut-website/src/examples/normalize-example.ts diff --git a/apps/petrinaut-website/.gitignore b/apps/petrinaut-website/.gitignore index 7a1c098f648..fdeb34c0e4d 100644 --- a/apps/petrinaut-website/.gitignore +++ b/apps/petrinaut-website/.gitignore @@ -1 +1,2 @@ +src/examples/generated/ styled-system diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 679a30a3c6d..6d9302cce24 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -7,8 +7,9 @@ "brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts", "build": "vite build", "codegen": "node --experimental-strip-types scripts/generate-route-tree.ts", - "dev": "vite", + "dev": "yarn examples:generate && vite", "dev:optimization": "node scripts/optimization-dev.mjs", + "examples:generate": "node --experimental-strip-types scripts/generate-example-artifacts.ts", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "generate:icons": "node scripts/generate-favicons.mjs", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", diff --git a/apps/petrinaut-website/scripts/generate-example-artifacts.ts b/apps/petrinaut-website/scripts/generate-example-artifacts.ts new file mode 100644 index 00000000000..5e90f483621 --- /dev/null +++ b/apps/petrinaut-website/scripts/generate-example-artifacts.ts @@ -0,0 +1,86 @@ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + compileScenario, + parseSDCPNFile, + type SDCPN, +} from "@hashintel/petrinaut-core"; +import { + compileHirArtifacts, + lowerScenarioToHir, + type ScenarioHir, +} from "@hashintel/petrinaut-core/hir"; + +import { + exampleSlugs, + type ExampleSlug, +} from "../src/examples/catalog-metadata.ts"; +import { normalizeExampleDefinition } from "../src/examples/normalize-example.ts"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const examplesDirectory = resolve(scriptDirectory, "../src/examples"); +const modelsDirectory = resolve(examplesDirectory, "models"); +const generatedDirectory = resolve(examplesDirectory, "generated"); + +const parseDefinition = async (slug: ExampleSlug): Promise => { + const input = JSON.parse( + await readFile(resolve(modelsDirectory, `${slug}.json`), "utf8"), + ) as unknown; + const parsed = parseSDCPNFile(input); + if (!parsed.ok) { + throw new Error(`${slug}: ${parsed.error}`); + } + const { title: _title, ...rawDefinition } = parsed.sdcpn; + return normalizeExampleDefinition(slug, rawDefinition); +}; + +const generateRuntime = async (slug: ExampleSlug) => { + const definition = await parseDefinition(slug); + const { artifacts, failures } = compileHirArtifacts(definition); + if (failures.length > 0) { + throw new Error( + `${slug}: model HIR compilation failed:\n${failures + .map( + (failure) => `${failure.kind}:${failure.itemId}: ${failure.message}`, + ) + .join("\n")}`, + ); + } + + const scenarioHirById: Record = Object.create( + null, + ) as Record; + for (const scenario of definition.scenarios ?? []) { + const hir = lowerScenarioToHir({ + parameterOverrides: scenario.parameterOverrides, + initialState: scenario.initialState, + }); + const outcome = compileScenario( + scenario, + hir, + definition.parameters, + definition.places, + definition.types, + ); + if (!outcome.ok) { + throw new Error( + `${slug}/${scenario.id}: scenario compilation failed:\n${outcome.errors + .map((error) => error.message) + .join("\n")}`, + ); + } + scenarioHirById[scenario.id] = hir; + } + + return `${JSON.stringify({ hirArtifacts: artifacts, scenarioHirById }, null, 2)}\n`; +}; + +await mkdir(generatedDirectory, { recursive: true }); + +for (const slug of exampleSlugs) { + const outputPath = resolve(generatedDirectory, `${slug}.json`); + await writeFile(outputPath, await generateRuntime(slug), "utf8"); + process.stdout.write(`generated ${outputPath}\n`); +} diff --git a/apps/petrinaut-website/src/examples/catalog-metadata.ts b/apps/petrinaut-website/src/examples/catalog-metadata.ts new file mode 100644 index 00000000000..911ccc0778f --- /dev/null +++ b/apps/petrinaut-website/src/examples/catalog-metadata.ts @@ -0,0 +1,111 @@ +/** + * Static example metadata shared by the browser catalog and server endpoints. + * + * Keep this module free of Petrinaut runtime imports and model loaders: Vercel + * functions that only need to validate a public URL should not bundle every + * example model and generated simulation artifact. + */ +export const exampleSlugs = [ + "gases-1-pn-consumption-trigger", + "gases-1-pn", + "gases-2-spn", + "gases-3-cpn", + "gases-4-dcpn", + "semiconductor-fab-drift", + "truck-fleet-predictive-maintenance", +] as const; + +export type ExampleSlug = (typeof exampleSlugs)[number]; + +export type ExampleSimulationParameterBounds = Readonly<{ + min: number; + max: number; + step: number; +}>; + +export type ExampleCatalogEntry = Readonly<{ + slug: ExampleSlug; + title: string; + /** Safe UI ranges for scenario parameters, keyed by identifier. */ + parameterBounds: Readonly>; +}>; + +const catalog = [ + { + slug: "gases-1-pn-consumption-trigger", + title: "Gases 1 — Consumption Trigger", + parameterBounds: { + draw_enabled: { min: 0, max: 1, step: 1 }, + }, + }, + { + slug: "gases-1-pn", + title: "Gases 1 — One Customer", + parameterBounds: { + draw_enabled: { min: 0, max: 1, step: 1 }, + }, + }, + { + slug: "gases-2-spn", + title: "Gases 2 — Shared Tanker", + parameterBounds: { + draw_enabled: { min: 0, max: 1, step: 1 }, + route_scale: { min: 0.5, max: 2, step: 0.1 }, + }, + }, + { + slug: "gases-3-cpn", + title: "Gases 3 — Mixed Fleet", + parameterBounds: { + route_scale: { min: 0.5, max: 2, step: 0.1 }, + }, + }, + { + slug: "gases-4-dcpn", + title: "Gases 4 — Dynamic Coloured Net", + parameterBounds: { + hire_enabled: { min: 0, max: 1, step: 1 }, + route_scale: { min: 0.5, max: 2, step: 0.1 }, + slow_draw: { min: 0, max: 0.1, step: 0.005 }, + }, + }, + { + slug: "semiconductor-fab-drift", + title: "Semiconductor Fab Drift", + parameterBounds: { + demand_rate: { min: 0.02, max: 0.3, step: 0.01 }, + maintenance_threshold: { min: 0.4, max: 0.99, step: 0.01 }, + wip_cap: { min: 10, max: 100, step: 5 }, + }, + }, + { + slug: "truck-fleet-predictive-maintenance", + title: "Truck Fleet Predictive Maintenance", + parameterBounds: { + base_severity_mean: { min: 0.5, max: 2, step: 0.05 }, + base_speed_mean: { min: 0.5, max: 1.5, step: 0.05 }, + bays: { min: 1, max: 4, step: 1 }, + drivers: { min: 1, max: 16, step: 1 }, + motorway_rate: { min: 0.01, max: 0.3, step: 0.005 }, + mountain_rate: { min: 0.01, max: 0.3, step: 0.005 }, + parts_lead_time: { min: 12, max: 168, step: 12 }, + recovery_units: { min: 0, max: 4, step: 1 }, + service_wear_limit: { min: 0.1, max: 10, step: 0.05 }, + severe_route_wear_limit: { min: 0.1, max: 1, step: 0.05 }, + spares: { min: 0, max: 30, step: 1 }, + technicians: { min: 1, max: 8, step: 1 }, + trucks: { min: 1, max: 20, step: 1 }, + urban_rate: { min: 0.01, max: 0.3, step: 0.005 }, + }, + }, +] as const satisfies readonly ExampleCatalogEntry[]; + +export const exampleCatalog: readonly ExampleCatalogEntry[] = catalog; + +export const isExampleSlug = (value: string): value is ExampleSlug => + exampleSlugs.some((slug) => slug === value); + +export const getExampleCatalogEntry = ( + slug: string, +): ExampleCatalogEntry | null => + catalog.find((entry) => entry.slug === slug) ?? null; diff --git a/apps/petrinaut-website/src/examples/catalog.test.ts b/apps/petrinaut-website/src/examples/catalog.test.ts new file mode 100644 index 00000000000..37fc3de3802 --- /dev/null +++ b/apps/petrinaut-website/src/examples/catalog.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + exampleCatalog, + exampleSlugs, + loadExample, + loadExampleRuntime, +} from "./catalog"; + +describe("example catalog", () => { + it("has a catalog entry for every slug", () => { + // isExampleSlug gates the routes on exampleSlugs while loadExample + // resolves the entry with a non-null assertion; this pins the two lists + // to each other so a slug without an entry fails here, not at render. + expect(exampleCatalog.map((entry) => entry.slug).toSorted()).toEqual( + [...exampleSlugs].toSorted(), + ); + }); + + it.each(exampleCatalog)( + "loads $slug with bounded scenario parameters and matching generated HIR", + async (entry) => { + const [example, runtime] = await Promise.all([ + loadExample(entry.slug), + loadExampleRuntime(entry.slug), + ]); + + expect(example.catalog).toBe(entry); + + for (const scenario of example.definition.scenarios ?? []) { + expect(runtime.scenarioHirById).toHaveProperty(scenario.id); + + for (const parameter of scenario.scenarioParameters) { + const bounds = entry.parameterBounds[parameter.identifier]; + expect(bounds, parameter.identifier).toBeDefined(); + expect(bounds!.min).toBeLessThanOrEqual(parameter.default); + expect(bounds!.max).toBeGreaterThanOrEqual(parameter.default); + expect(bounds!.step).toBeGreaterThan(0); + } + } + }, + ); + + it("caches one model/runtime load per example", async () => { + const entry = exampleCatalog[0]!; + const [firstExample, secondExample, firstRuntime, secondRuntime] = + await Promise.all([ + loadExample(entry.slug), + loadExample(entry.slug), + loadExampleRuntime(entry.slug), + loadExampleRuntime(entry.slug), + ]); + + expect(secondExample).toBe(firstExample); + expect(secondRuntime).toBe(firstRuntime); + }); +}); diff --git a/apps/petrinaut-website/src/examples/catalog.ts b/apps/petrinaut-website/src/examples/catalog.ts new file mode 100644 index 00000000000..6e6c3807622 --- /dev/null +++ b/apps/petrinaut-website/src/examples/catalog.ts @@ -0,0 +1,112 @@ +import { + parseSDCPNFile, + type HirArtifacts, + type ScenarioHir, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +import { + getExampleCatalogEntry, + type ExampleCatalogEntry, + type ExampleSlug, +} from "./catalog-metadata"; +import { normalizeExampleDefinition } from "./normalize-example"; + +export { + exampleCatalog, + exampleSlugs, + getExampleCatalogEntry, + isExampleSlug, +} from "./catalog-metadata"; +export type { + ExampleCatalogEntry, + ExampleSimulationParameterBounds, + ExampleSlug, +} from "./catalog-metadata"; + +export type LoadedExample = Readonly<{ + catalog: ExampleCatalogEntry; + definition: SDCPN; +}>; + +export type GeneratedExampleRuntime = Readonly<{ + hirArtifacts: HirArtifacts; + scenarioHirById: Readonly>; +}>; + +const modelLoaders: Record Promise<{ default: unknown }>> = { + "gases-1-pn-consumption-trigger": () => + import("./models/gases-1-pn-consumption-trigger.json"), + "gases-1-pn": () => import("./models/gases-1-pn.json"), + "gases-2-spn": () => import("./models/gases-2-spn.json"), + "gases-3-cpn": () => import("./models/gases-3-cpn.json"), + "gases-4-dcpn": () => import("./models/gases-4-dcpn.json"), + "semiconductor-fab-drift": () => + import("./models/semiconductor-fab-drift.json"), + "truck-fleet-predictive-maintenance": () => + import("./models/truck-fleet-predictive-maintenance.json"), +}; + +const runtimeLoaders: Record Promise<{ default: unknown }>> = + { + "gases-1-pn-consumption-trigger": () => + import("./generated/gases-1-pn-consumption-trigger.json"), + "gases-1-pn": () => import("./generated/gases-1-pn.json"), + "gases-2-spn": () => import("./generated/gases-2-spn.json"), + "gases-3-cpn": () => import("./generated/gases-3-cpn.json"), + "gases-4-dcpn": () => import("./generated/gases-4-dcpn.json"), + "semiconductor-fab-drift": () => + import("./generated/semiconductor-fab-drift.json"), + "truck-fleet-predictive-maintenance": () => + import("./generated/truck-fleet-predictive-maintenance.json"), + }; + +const loadedExamples = new Map>(); +const loadedRuntimes = new Map>(); + +export const loadExample = async ( + slug: ExampleSlug, +): Promise => { + const existing = loadedExamples.get(slug); + if (existing) { + return existing; + } + + const loaded = modelLoaders[slug]().then((module) => { + const parsed = parseSDCPNFile(module.default); + if (!parsed.ok) { + throw new Error(parsed.error); + } + + const { title: _title, ...rawDefinition } = parsed.sdcpn; + return { + catalog: getExampleCatalogEntry(slug)!, + definition: normalizeExampleDefinition(slug, rawDefinition), + }; + }); + // A rejected import (a transient network failure, a stale chunk after a + // redeploy) must not poison the cache: evict so the next navigation retries. + loaded.catch(() => { + loadedExamples.delete(slug); + }); + loadedExamples.set(slug, loaded); + return loaded; +}; + +export const loadExampleRuntime = async ( + slug: ExampleSlug, +): Promise => { + const existing = loadedRuntimes.get(slug); + if (existing) { + return existing; + } + + const loaded = runtimeLoaders[slug]().then( + (module) => module.default as GeneratedExampleRuntime, + ); + loaded.catch(() => { + loadedRuntimes.delete(slug); + }); + loadedRuntimes.set(slug, loaded); + return loaded; +}; diff --git a/apps/petrinaut-website/src/examples/models/gases-1-pn-consumption-trigger.json b/apps/petrinaut-website/src/examples/models/gases-1-pn-consumption-trigger.json new file mode 100644 index 00000000000..fd8d6e3fadd --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/gases-1-pn-consumption-trigger.json @@ -0,0 +1,486 @@ +{ + "places": [ + { + "id": "place__idle_tankers", + "name": "IdleTankers", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1470, + "y": 555 + }, + { + "id": "place__loads_delivered", + "name": "LoadsDelivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1230, + "y": 435 + }, + { + "id": "place__s1_order_placed", + "name": "SteadyNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1785, + "y": 780 + }, + { + "id": "place__s1_order_permits", + "name": "SteadyNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1230, + "y": 660 + }, + { + "id": "place__s1_on_route", + "name": "SteadyNitrogenOnRoute", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2325, + "y": 780 + }, + { + "id": "place__s1_vented", + "name": "SteadyNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1230, + "y": 225 + }, + { + "id": "place__s1_line_running", + "name": "SteadyNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 75, + "y": 1065 + }, + { + "id": "place__s1_line_stopped", + "name": "SteadyNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 645, + "y": 1260 + }, + { + "id": "place__s1_stockouts", + "name": "SteadyNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 645, + "y": 960 + }, + { + "id": "place__s1_consumed", + "name": "SteadyNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 645, + "y": 765 + }, + { + "id": "place__s1_evaporated", + "name": "SteadyNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 630, + "y": 75 + }, + { + "id": "place__s1_contents", + "name": "SteadyNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 60, + "y": 360 + }, + { + "id": "place__s1_ullage", + "name": "SteadyNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 630, + "y": 375 + }, + { + "id": "place__s1_units_drawn_since_order", + "name": "SteadyNitrogenUnitsDrawnSinceOrder", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1050, + "y": 780 + } + ], + "transitions": [ + { + "id": "transition__s1_draw", + "name": "Draw a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_consumed", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + }, + { + "placeId": "place__s1_units_drawn_since_order", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Switched off in the scenario where the customer's plant is shut. The tank\n// still boils off while they draw nothing, which is the point of the level.\nexport default Lambda((input, parameters) => {\n return parameters.draw_enabled > 0;\n});", + "transitionKernelCode": "", + "x": 360, + "y": 555 + }, + { + "id": "transition__s1_boil_off", + "name": "Boil off a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_evaporated", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 345, + "y": 165 + }, + { + "id": "transition__s1_raise_order", + "name": "Raise an order, 8 units drawn (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_units_drawn_since_order", + "weight": 8, + "type": "standard" + }, + { + "placeId": "place__s1_order_permits", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 1530, + "y": 780 + }, + { + "id": "transition__s1_dispatch", + "name": "Dispatch a tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 2055, + "y": 780 + }, + { + "id": "transition__s1_arrive", + "name": "Unload the tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 12 + }, + { + "placeId": "place__s1_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__idle_tankers", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 900, + "y": 555 + }, + { + "id": "transition__s1_vent", + "name": "Vent through the relief valve (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_vented", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 900, + "y": 225 + }, + { + "id": "transition__s1_stop_line", + "name": "Stop the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s1_stockouts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 360, + "y": 1065 + }, + { + "id": "transition__s1_resume_line", + "name": "Resume the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 930, + "y": 1260 + } + ], + "types": [], + "differentialEquations": [], + "parameters": [ + { + "id": "param__draw_enabled", + "name": "Draw enabled", + "variableName": "draw_enabled", + "type": "real", + "defaultValue": "1" + } + ], + "scenarios": [ + { + "id": "scenario__drawing", + "name": "Customer drawing normally", + "description": "The customer is using product, so consumption events happen and either ordering policy has something to work with.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 1 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SteadyNitrogenUnitsDrawnSinceOrder: 0,\n};" + } + }, + { + "id": "scenario__shut", + "name": "Customer shut, tank still evaporating", + "description": "The customer's plant is down for maintenance and draws nothing. The tank still loses product to boil-off. A real operating condition, and where the two ordering policies come apart.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 0 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SteadyNitrogenUnitsDrawnSinceOrder: 0,\n};" + } + } + ], + "metrics": [ + { + "id": "metric__deliveries", + "name": "Loads delivered", + "description": "Tanker drops made.", + "code": "return state.places.LoadsDelivered.count;" + }, + { + "id": "metric__stockouts", + "name": "Stockouts", + "description": "Times a customer line stopped for want of product.", + "code": "return state.places.SteadyNitrogenStockouts.count;" + }, + { + "id": "metric__vented", + "name": "Vented through relief", + "description": "Units lost through a relief valve, in units at every level. Below the continuous levels the Vented place holds one token per unit, because the tank is a pile of one-unit tokens. From the continuous levels it holds one token per valve opening, each worth vent_loss units, so the count is scaled to keep this metric comparable down the sequence. Structurally reachable everywhere, and unreachable in practice below the continuous levels: under a level trigger the tank never fills completely, and with no pressure nothing else opens the valve.", + "code": "return state.places.SteadyNitrogenVented.count;" + }, + { + "id": "metric__evaporated", + "name": "Evaporated", + "description": "Units lost to boil-off.", + "code": "return state.places.SteadyNitrogenEvaporated.count;" + }, + { + "id": "metric__consumed", + "name": "Consumed", + "description": "Units the customers actually used.", + "code": "return state.places.SteadyNitrogenConsumed.count;" + }, + { + "id": "metric__envelope", + "name": "Contents plus ullage", + "description": "The place invariant, summed over the three sites. Every transition that moves product moves it between Contents and Ullage, so this is total capacity in every reachable marking and can be checked without simulating anything.", + "code": "return state.places.SteadyNitrogenContents.count + state.places.SteadyNitrogenUllage.count;" + }, + { + "id": "metric__stranded", + "name": "Stranded customers", + "description": "Customers that ended stopped with no order outstanding: the line is down and nothing is on its way to fix it.", + "code": "return (state.places.SteadyNitrogenLineStopped.count > 0 && state.places.SteadyNitrogenOrderPlaced.count === 0 ? 1 : 0);" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Gases 1 — plain net, one customer (consumption trigger)" +} diff --git a/apps/petrinaut-website/src/examples/models/gases-1-pn.json b/apps/petrinaut-website/src/examples/models/gases-1-pn.json new file mode 100644 index 00000000000..d4b42b96572 --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/gases-1-pn.json @@ -0,0 +1,472 @@ +{ + "places": [ + { + "id": "place__idle_tankers", + "name": "IdleTankers", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1455, + "y": 450 + }, + { + "id": "place__loads_delivered", + "name": "LoadsDelivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1230, + "y": 360 + }, + { + "id": "place__s1_order_placed", + "name": "SteadyNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1755, + "y": 675 + }, + { + "id": "place__s1_order_permits", + "name": "SteadyNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1245, + "y": 570 + }, + { + "id": "place__s1_on_route", + "name": "SteadyNitrogenOnRoute", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2280, + "y": 675 + }, + { + "id": "place__s1_vented", + "name": "SteadyNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1230, + "y": 195 + }, + { + "id": "place__s1_line_running", + "name": "SteadyNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 105, + "y": 900 + }, + { + "id": "place__s1_line_stopped", + "name": "SteadyNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 675, + "y": 1155 + }, + { + "id": "place__s1_stockouts", + "name": "SteadyNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 675, + "y": 810 + }, + { + "id": "place__s1_consumed", + "name": "SteadyNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 675, + "y": 585 + }, + { + "id": "place__s1_evaporated", + "name": "SteadyNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 675, + "y": 45 + }, + { + "id": "place__s1_contents", + "name": "SteadyNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 105, + "y": 330 + }, + { + "id": "place__s1_ullage", + "name": "SteadyNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 675, + "y": 315 + } + ], + "transitions": [ + { + "id": "transition__s1_draw", + "name": "Draw a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_consumed", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Switched off in the scenario where the customer's plant is shut. The tank\n// still boils off while they draw nothing, which is the point of the level.\nexport default Lambda((input, parameters) => {\n return parameters.draw_enabled > 0;\n});", + "transitionKernelCode": "", + "x": 405, + "y": 510 + }, + { + "id": "transition__s1_boil_off", + "name": "Boil off a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_evaporated", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 390, + "y": 135 + }, + { + "id": "transition__s1_raise_order", + "name": "Raise an order, level below trigger (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 16, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 1500, + "y": 675 + }, + { + "id": "transition__s1_dispatch", + "name": "Dispatch a tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 2025, + "y": 675 + }, + { + "id": "transition__s1_arrive", + "name": "Unload the tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 12 + }, + { + "placeId": "place__s1_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__idle_tankers", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 945, + "y": 450 + }, + { + "id": "transition__s1_vent", + "name": "Vent through the relief valve (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_vented", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 945, + "y": 195 + }, + { + "id": "transition__s1_stop_line", + "name": "Stop the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s1_stockouts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 405, + "y": 900 + }, + { + "id": "transition__s1_resume_line", + "name": "Resume the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => {\n return true;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 1155 + } + ], + "types": [], + "differentialEquations": [], + "parameters": [ + { + "id": "param__draw_enabled", + "name": "Draw enabled", + "variableName": "draw_enabled", + "type": "real", + "defaultValue": "1" + } + ], + "scenarios": [ + { + "id": "scenario__drawing", + "name": "Customer drawing normally", + "description": "The customer is using product, so consumption events happen and either ordering policy has something to work with.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 1 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n};" + } + }, + { + "id": "scenario__shut", + "name": "Customer shut, tank still evaporating", + "description": "The customer's plant is down for maintenance and draws nothing. The tank still loses product to boil-off. A real operating condition, and where the two ordering policies come apart.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 0 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n};" + } + } + ], + "metrics": [ + { + "id": "metric__deliveries", + "name": "Loads delivered", + "description": "Tanker drops made.", + "code": "return state.places.LoadsDelivered.count;" + }, + { + "id": "metric__stockouts", + "name": "Stockouts", + "description": "Times a customer line stopped for want of product.", + "code": "return state.places.SteadyNitrogenStockouts.count;" + }, + { + "id": "metric__vented", + "name": "Vented through relief", + "description": "Units lost through a relief valve, in units at every level. Below the continuous levels the Vented place holds one token per unit, because the tank is a pile of one-unit tokens. From the continuous levels it holds one token per valve opening, each worth vent_loss units, so the count is scaled to keep this metric comparable down the sequence. Structurally reachable everywhere, and unreachable in practice below the continuous levels: under a level trigger the tank never fills completely, and with no pressure nothing else opens the valve.", + "code": "return state.places.SteadyNitrogenVented.count;" + }, + { + "id": "metric__evaporated", + "name": "Evaporated", + "description": "Units lost to boil-off.", + "code": "return state.places.SteadyNitrogenEvaporated.count;" + }, + { + "id": "metric__consumed", + "name": "Consumed", + "description": "Units the customers actually used.", + "code": "return state.places.SteadyNitrogenConsumed.count;" + }, + { + "id": "metric__envelope", + "name": "Contents plus ullage", + "description": "The place invariant, summed over the three sites. Every transition that moves product moves it between Contents and Ullage, so this is total capacity in every reachable marking and can be checked without simulating anything.", + "code": "return state.places.SteadyNitrogenContents.count + state.places.SteadyNitrogenUllage.count;" + }, + { + "id": "metric__stranded", + "name": "Stranded customers", + "description": "Customers that ended stopped with no order outstanding: the line is down and nothing is on its way to fix it.", + "code": "return (state.places.SteadyNitrogenLineStopped.count > 0 && state.places.SteadyNitrogenOrderPlaced.count === 0 ? 1 : 0);" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Gases 1 — plain net, one customer" +} diff --git a/apps/petrinaut-website/src/examples/models/gases-2-spn.json b/apps/petrinaut-website/src/examples/models/gases-2-spn.json new file mode 100644 index 00000000000..31eb77a1c1a --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/gases-2-spn.json @@ -0,0 +1,978 @@ +{ + "places": [ + { + "id": "place__idle_tankers", + "name": "IdleTankers", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2325, + "y": 2145 + }, + { + "id": "place__loads_delivered", + "name": "LoadsDelivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1905, + "y": 1935 + }, + { + "id": "place__returning", + "name": "Returning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1905, + "y": 2145 + }, + { + "id": "place__s1_order_placed", + "name": "SteadyNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2325, + "y": 1575 + }, + { + "id": "place__s1_order_permits", + "name": "SteadyNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1845, + "y": 1455 + }, + { + "id": "place__s1_on_route", + "name": "SteadyNitrogenOnRoute", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2835, + "y": 1575 + }, + { + "id": "place__s1_vented", + "name": "SteadyNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1815, + "y": 1020 + }, + { + "id": "place__s1_line_running", + "name": "SteadyNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 675, + "y": 1845 + }, + { + "id": "place__s1_line_stopped", + "name": "SteadyNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1275, + "y": 2055 + }, + { + "id": "place__s1_stockouts", + "name": "SteadyNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1260, + "y": 1740 + }, + { + "id": "place__s1_consumed", + "name": "SteadyNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1260, + "y": 1560 + }, + { + "id": "place__s1_evaporated", + "name": "SteadyNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1275, + "y": 900 + }, + { + "id": "place__s1_contents", + "name": "SteadyNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 675, + "y": 1245 + }, + { + "id": "place__s1_ullage", + "name": "SteadyNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1275, + "y": 1140 + }, + { + "id": "place__s2_order_placed", + "name": "SlowNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2355, + "y": 3015 + }, + { + "id": "place__s2_order_permits", + "name": "SlowNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1860, + "y": 2910 + }, + { + "id": "place__s2_on_route", + "name": "SlowNitrogenOnRoute", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2895, + "y": 3015 + }, + { + "id": "place__s2_vented", + "name": "SlowNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1860, + "y": 2385 + }, + { + "id": "place__s2_line_running", + "name": "SlowNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 705, + "y": 3150 + }, + { + "id": "place__s2_line_stopped", + "name": "SlowNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1305, + "y": 3300 + }, + { + "id": "place__s2_stockouts", + "name": "SlowNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1305, + "y": 3030 + }, + { + "id": "place__s2_consumed", + "name": "SlowNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1305, + "y": 2865 + }, + { + "id": "place__s2_evaporated", + "name": "SlowNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1275, + "y": 2235 + }, + { + "id": "place__s2_contents", + "name": "SlowNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 705, + "y": 2565 + }, + { + "id": "place__s2_ullage", + "name": "SlowNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1290, + "y": 2550 + } + ], + "transitions": [ + { + "id": "transition__s1_draw", + "name": "Draw a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_consumed", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return Math.max(parameters.draw_1 * parameters.draw_enabled, 1e-9);\n});", + "transitionKernelCode": "", + "x": 990, + "y": 1410 + }, + { + "id": "transition__s1_boil_off", + "name": "Boil off a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_evaporated", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.boiloff_rate;\n});", + "transitionKernelCode": "", + "x": 990, + "y": 1020 + }, + { + "id": "transition__s1_raise_order", + "name": "Raise an order, level below trigger (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 16, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.review_rate;\n});", + "transitionKernelCode": "", + "x": 2100, + "y": 1575 + }, + { + "id": "transition__s1_dispatch", + "name": "Dispatch a tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.loading_rate;\n});", + "transitionKernelCode": "", + "x": 2580, + "y": 1575 + }, + { + "id": "transition__s1_arrive", + "name": "Unload the tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 12 + }, + { + "placeId": "place__s1_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / (6.0 * parameters.route_scale);\n});", + "transitionKernelCode": "", + "x": 1530, + "y": 1335 + }, + { + "id": "transition__s1_vent", + "name": "Vent through the relief valve (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_vented", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1530, + "y": 1020 + }, + { + "id": "transition__s1_stop_line", + "name": "Stop the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s1_stockouts", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 990, + "y": 1845 + }, + { + "id": "transition__s1_resume_line", + "name": "Resume the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1545, + "y": 2055 + }, + { + "id": "transition__s2_draw", + "name": "Draw a unit (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_consumed", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return Math.max(parameters.draw_2 * parameters.draw_enabled, 1e-9);\n});", + "transitionKernelCode": "", + "x": 1005, + "y": 2775 + }, + { + "id": "transition__s2_boil_off", + "name": "Boil off a unit (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_evaporated", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.boiloff_rate;\n});", + "transitionKernelCode": "", + "x": 1005, + "y": 2385 + }, + { + "id": "transition__s2_raise_order", + "name": "Raise an order, level below trigger (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 6, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.review_rate;\n});", + "transitionKernelCode": "", + "x": 2130, + "y": 3015 + }, + { + "id": "transition__s2_dispatch", + "name": "Dispatch a tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.loading_rate;\n});", + "transitionKernelCode": "", + "x": 2640, + "y": 3015 + }, + { + "id": "transition__s2_arrive", + "name": "Unload the tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 12 + }, + { + "placeId": "place__s2_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / (9.0 * parameters.route_scale);\n});", + "transitionKernelCode": "", + "x": 1560, + "y": 2700 + }, + { + "id": "transition__s2_vent", + "name": "Vent through the relief valve (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_vented", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1560, + "y": 2385 + }, + { + "id": "transition__s2_stop_line", + "name": "Stop the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s2_stockouts", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1020, + "y": 3165 + }, + { + "id": "transition__s2_resume_line", + "name": "Resume the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_line_running", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1560, + "y": 3300 + }, + { + "id": "transition__return_to_depot", + "name": "Return a tanker to the depot", + "inputArcs": [ + { + "placeId": "place__returning", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__idle_tankers", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / parameters.return_time;\n});", + "transitionKernelCode": "", + "x": 2130, + "y": 2145 + } + ], + "types": [], + "differentialEquations": [], + "parameters": [ + { + "id": "param__boiloff_rate", + "name": "Boil-off rate", + "variableName": "boiloff_rate", + "type": "real", + "defaultValue": "0.16" + }, + { + "id": "param__draw_1", + "name": "SteadyNitrogen draw rate", + "variableName": "draw_1", + "type": "real", + "defaultValue": "0.8" + }, + { + "id": "param__draw_2", + "name": "SlowNitrogen draw rate", + "variableName": "draw_2", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__draw_enabled", + "name": "Draw enabled", + "variableName": "draw_enabled", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__route_scale", + "name": "Route scale", + "variableName": "route_scale", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__return_time", + "name": "Mean hours on the return leg", + "variableName": "return_time", + "type": "real", + "defaultValue": "4.0" + }, + { + "id": "param__review_rate", + "name": "Telemetry reviews per hour", + "variableName": "review_rate", + "type": "real", + "defaultValue": "4.0" + }, + { + "id": "param__loading_rate", + "name": "Loadings per hour", + "variableName": "loading_rate", + "type": "real", + "defaultValue": "2.0" + }, + { + "id": "param__instant_rate", + "name": "Rate standing for an immediate event", + "variableName": "instant_rate", + "type": "real", + "defaultValue": "20.0" + } + ], + "scenarios": [ + { + "id": "scenario__drawing", + "name": "Customers drawing normally", + "description": "The customers are using product, so consumption events happen and either ordering policy has something to work with.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 1 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n Returning: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: 0,\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n};" + } + }, + { + "id": "scenario__shut", + "name": "Customers shut, tanks still evaporating", + "description": "The customers' plants are down for maintenance and draw nothing. Their tanks still lose product to boil-off. A real operating condition, and where the two ordering policies come apart.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 0 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n Returning: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: 0,\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n};" + } + }, + { + "id": "scenario__two_tankers", + "name": "A second tanker on the depot", + "description": "The same two customers with two trailers instead of one, so neither has to wait for the other's delivery to finish. What contention costs.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 1 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 2,\n LoadsDelivered: 0,\n Returning: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: 0,\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n};" + } + }, + { + "id": "scenario__slow_routes", + "name": "Routes half again as long", + "description": "Winter roads. Every mean journey stretches by half, which lengthens the tail as well as the mean because the journey is exponential.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "draw_enabled", + "default": 1 + }, + { + "type": "real", + "identifier": "route_scale", + "default": 1.5 + } + ], + "parameterOverrides": { + "param__draw_enabled": "scenario.draw_enabled", + "param__route_scale": "scenario.route_scale" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: 1,\n LoadsDelivered: 0,\n Returning: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: 0,\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: 0,\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n};" + } + } + ], + "metrics": [ + { + "id": "metric__deliveries", + "name": "Loads delivered", + "description": "Tanker drops made.", + "code": "return state.places.LoadsDelivered.count;" + }, + { + "id": "metric__stockouts", + "name": "Stockouts", + "description": "Times a customer line stopped for want of product.", + "code": "return state.places.SteadyNitrogenStockouts.count + state.places.SlowNitrogenStockouts.count;" + }, + { + "id": "metric__weighted_stockouts", + "name": "Criticality-weighted stockouts", + "description": "Stockouts weighted by how much the customer matters: the freezing plant counts 2, the laser shop 1. A total count cannot say whether the outages landed on the customer you could least afford to lose.", + "code": "return 2 * state.places.SteadyNitrogenStockouts.count + 1 * state.places.SlowNitrogenStockouts.count;" + }, + { + "id": "metric__vented", + "name": "Vented through relief", + "description": "Units lost through a relief valve, in units at every level. Below the continuous levels the Vented place holds one token per unit, because the tank is a pile of one-unit tokens. From the continuous levels it holds one token per valve opening, each worth vent_loss units, so the count is scaled to keep this metric comparable down the sequence. Structurally reachable everywhere, and unreachable in practice below the continuous levels: under a level trigger the tank never fills completely, and with no pressure nothing else opens the valve.", + "code": "return state.places.SteadyNitrogenVented.count + state.places.SlowNitrogenVented.count;" + }, + { + "id": "metric__evaporated", + "name": "Evaporated", + "description": "Units lost to boil-off.", + "code": "return state.places.SteadyNitrogenEvaporated.count + state.places.SlowNitrogenEvaporated.count;" + }, + { + "id": "metric__consumed", + "name": "Consumed", + "description": "Units the customers actually used.", + "code": "return state.places.SteadyNitrogenConsumed.count + state.places.SlowNitrogenConsumed.count;" + }, + { + "id": "metric__boiloff_share", + "name": "Share of outflow lost to boil-off", + "description": "Evaporated over everything that left the tanks. The quantity a consumption trigger is blind to, as a fraction.", + "code": "const consumed = state.places.SteadyNitrogenConsumed.count + state.places.SlowNitrogenConsumed.count;\nconst evaporated = state.places.SteadyNitrogenEvaporated.count + state.places.SlowNitrogenEvaporated.count;\nreturn consumed + evaporated > 0 ? evaporated / (consumed + evaporated) : 0;" + }, + { + "id": "metric__stockouts_per_hundred", + "name": "Stockouts per 100 units consumed", + "description": "Stockouts against the volume the customers actually drew. Safe to compare across this level's scenarios, which a raw count is not, because a scenario that delivers more has more chances to fail.", + "code": "const consumed = state.places.SteadyNitrogenConsumed.count + state.places.SlowNitrogenConsumed.count;\nreturn consumed > 0 ? 100 * (state.places.SteadyNitrogenStockouts.count + state.places.SlowNitrogenStockouts.count) / consumed : 0;" + }, + { + "id": "metric__stockouts_1", + "name": "SteadyNitrogen stockouts", + "description": "Times the food freezing plant stopped.", + "code": "return state.places.SteadyNitrogenStockouts.count;" + }, + { + "id": "metric__stockouts_2", + "name": "SlowNitrogen stockouts", + "description": "Times the laser cutting shop stopped.", + "code": "return state.places.SlowNitrogenStockouts.count;" + }, + { + "id": "metric__envelope", + "name": "Contents plus ullage", + "description": "The place invariant, summed over both sites. Every transition that moves product moves it between Contents and Ullage, so this is total capacity in every reachable marking and can be checked without simulating anything.", + "code": "return state.places.SteadyNitrogenContents.count + state.places.SteadyNitrogenUllage.count + state.places.SlowNitrogenContents.count + state.places.SlowNitrogenUllage.count;" + }, + { + "id": "metric__stranded", + "name": "Stranded customers", + "description": "Customers that ended stopped with no order outstanding: the line is down and nothing is on its way to fix it.", + "code": "return (state.places.SteadyNitrogenLineStopped.count > 0 && state.places.SteadyNitrogenOrderPlaced.count === 0 ? 1 : 0) + (state.places.SlowNitrogenLineStopped.count > 0 && state.places.SlowNitrogenOrderPlaced.count === 0 ? 1 : 0);" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Gases 2 — stochastic net, two customers on one tanker" +} diff --git a/apps/petrinaut-website/src/examples/models/gases-3-cpn.json b/apps/petrinaut-website/src/examples/models/gases-3-cpn.json new file mode 100644 index 00000000000..6c0226d031e --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/gases-3-cpn.json @@ -0,0 +1,1300 @@ +{ + "places": [ + { + "id": "place__idle_tankers", + "name": "IdleTankers", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2535, + "y": 1830 + }, + { + "id": "place__loads_delivered", + "name": "LoadsDelivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2085, + "y": 2115 + }, + { + "id": "place__returning", + "name": "Returning", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2085, + "y": 1830 + }, + { + "id": "place__s1_order_placed", + "name": "SteadyNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2235, + "y": 2340 + }, + { + "id": "place__s1_order_permits", + "name": "SteadyNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1785, + "y": 2220 + }, + { + "id": "place__s1_on_route", + "name": "SteadyNitrogenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2655, + "y": 2340 + }, + { + "id": "place__s1_vented", + "name": "SteadyNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1785, + "y": 1770 + }, + { + "id": "place__s1_line_running", + "name": "SteadyNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 645, + "y": 2625 + }, + { + "id": "place__s1_line_stopped", + "name": "SteadyNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 2715 + }, + { + "id": "place__s1_stockouts", + "name": "SteadyNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 2490 + }, + { + "id": "place__s1_consumed", + "name": "SteadyNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 2325 + }, + { + "id": "place__s1_evaporated", + "name": "SteadyNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 1665 + }, + { + "id": "place__s1_contents", + "name": "SteadyNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 645, + "y": 2070 + }, + { + "id": "place__s1_ullage", + "name": "SteadyNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1245, + "y": 1950 + }, + { + "id": "place__s2_order_placed", + "name": "SlowNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2280, + "y": 3585 + }, + { + "id": "place__s2_order_permits", + "name": "SlowNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1800, + "y": 3465 + }, + { + "id": "place__s2_on_route", + "name": "SlowNitrogenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2715, + "y": 3585 + }, + { + "id": "place__s2_vented", + "name": "SlowNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1740, + "y": 2985 + }, + { + "id": "place__s2_line_running", + "name": "SlowNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 675, + "y": 3795 + }, + { + "id": "place__s2_line_stopped", + "name": "SlowNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1260, + "y": 3930 + }, + { + "id": "place__s2_stockouts", + "name": "SlowNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 3675 + }, + { + "id": "place__s2_consumed", + "name": "SlowNitrogenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 3495 + }, + { + "id": "place__s2_evaporated", + "name": "SlowNitrogenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 2895 + }, + { + "id": "place__s2_contents", + "name": "SlowNitrogenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 660, + "y": 3210 + }, + { + "id": "place__s2_ullage", + "name": "SlowNitrogenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1245, + "y": 3165 + }, + { + "id": "place__s3_order_placed", + "name": "CriticalOxygenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2280, + "y": 1110 + }, + { + "id": "place__s3_order_permits", + "name": "CriticalOxygenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1785, + "y": 975 + }, + { + "id": "place__s3_on_route", + "name": "CriticalOxygenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2745, + "y": 1110 + }, + { + "id": "place__s3_vented", + "name": "CriticalOxygenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1800, + "y": 555 + }, + { + "id": "place__s3_line_running", + "name": "CriticalOxygenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 615, + "y": 1350 + }, + { + "id": "place__s3_line_stopped", + "name": "CriticalOxygenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 1485 + }, + { + "id": "place__s3_stockouts", + "name": "CriticalOxygenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 1200 + }, + { + "id": "place__s3_consumed", + "name": "CriticalOxygenConsumed", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 1050 + }, + { + "id": "place__s3_evaporated", + "name": "CriticalOxygenEvaporated", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1245, + "y": 450 + }, + { + "id": "place__s3_contents", + "name": "CriticalOxygenContents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 615, + "y": 795 + }, + { + "id": "place__s3_ullage", + "name": "CriticalOxygenUllage", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1230, + "y": 735 + } + ], + "transitions": [ + { + "id": "transition__s1_draw", + "name": "Draw a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_consumed", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.draw_1;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 2295 + }, + { + "id": "transition__s1_boil_off", + "name": "Boil off a unit (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_evaporated", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.boiloff_rate;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 1815 + }, + { + "id": "transition__s1_raise_order", + "name": "Raise an order, level below trigger (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 16, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.review_rate;\n});", + "transitionKernelCode": "", + "x": 2025, + "y": 2340 + }, + { + "id": "transition__s1_dispatch", + "name": "Dispatch a tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.IdleTankers[0].product === \"nitrogen\" ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n return { SteadyNitrogenOnRoute: [{ product: input.IdleTankers[0].product }] };\n});", + "x": 2445, + "y": 2340 + }, + { + "id": "transition__s1_arrive", + "name": "Unload the tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 12 + }, + { + "placeId": "place__s1_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / (6.0 * parameters.route_scale);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n Returning: [{ product: input.SteadyNitrogenOnRoute[0].product }],\n };\n});", + "x": 1485, + "y": 2115 + }, + { + "id": "transition__s1_vent", + "name": "Vent through the relief valve (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_vented", + "weight": 1 + }, + { + "placeId": "place__s1_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1485, + "y": 1770 + }, + { + "id": "transition__s1_stop_line", + "name": "Stop the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s1_stockouts", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 2625 + }, + { + "id": "transition__s1_resume_line", + "name": "Resume the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_line_running", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1470, + "y": 2715 + }, + { + "id": "transition__s2_draw", + "name": "Draw a unit (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_consumed", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.draw_2;\n});", + "transitionKernelCode": "", + "x": 975, + "y": 3435 + }, + { + "id": "transition__s2_boil_off", + "name": "Boil off a unit (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_evaporated", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.boiloff_rate;\n});", + "transitionKernelCode": "", + "x": 975, + "y": 3015 + }, + { + "id": "transition__s2_raise_order", + "name": "Raise an order, level below trigger (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 6, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.review_rate;\n});", + "transitionKernelCode": "", + "x": 2040, + "y": 3585 + }, + { + "id": "transition__s2_dispatch", + "name": "Dispatch a tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.IdleTankers[0].product === \"nitrogen\" ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n return { SlowNitrogenOnRoute: [{ product: input.IdleTankers[0].product }] };\n});", + "x": 2505, + "y": 3585 + }, + { + "id": "transition__s2_arrive", + "name": "Unload the tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 12 + }, + { + "placeId": "place__s2_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / (9.0 * parameters.route_scale);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n Returning: [{ product: input.SlowNitrogenOnRoute[0].product }],\n };\n});", + "x": 1530, + "y": 3330 + }, + { + "id": "transition__s2_vent", + "name": "Vent through the relief valve (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_vented", + "weight": 1 + }, + { + "placeId": "place__s2_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1515, + "y": 2985 + }, + { + "id": "transition__s2_stop_line", + "name": "Stop the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s2_stockouts", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 975, + "y": 3795 + }, + { + "id": "transition__s2_resume_line", + "name": "Resume the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_line_running", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1530, + "y": 3930 + }, + { + "id": "transition__s3_draw", + "name": "Draw a unit (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_consumed", + "weight": 1 + }, + { + "placeId": "place__s3_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.draw_3;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 1020 + }, + { + "id": "transition__s3_boil_off", + "name": "Boil off a unit (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_contents", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_evaporated", + "weight": 1 + }, + { + "placeId": "place__s3_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.boiloff_rate;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 540 + }, + { + "id": "transition__s3_raise_order", + "name": "Raise an order, level below trigger (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_order_permits", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_contents", + "weight": 20, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_order_placed", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.review_rate;\n});", + "transitionKernelCode": "", + "x": 2055, + "y": 1110 + }, + { + "id": "transition__s3_dispatch", + "name": "Dispatch a tanker (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.IdleTankers[0].product === \"oxygen\" ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n return { CriticalOxygenOnRoute: [{ product: input.IdleTankers[0].product }] };\n});", + "x": 2505, + "y": 1110 + }, + { + "id": "transition__s3_arrive", + "name": "Unload the tanker (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_ullage", + "weight": 12, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_contents", + "weight": 12 + }, + { + "placeId": "place__s3_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / (12.0 * parameters.route_scale);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n Returning: [{ product: input.CriticalOxygenOnRoute[0].product }],\n };\n});", + "x": 1485, + "y": 870 + }, + { + "id": "transition__s3_vent", + "name": "Vent through the relief valve (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_contents", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_ullage", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_vented", + "weight": 1 + }, + { + "placeId": "place__s3_ullage", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1500, + "y": 555 + }, + { + "id": "transition__s3_stop_line", + "name": "Stop the line (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_line_running", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_contents", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s3_stockouts", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 960, + "y": 1350 + }, + { + "id": "transition__s3_resume_line", + "name": "Resume the line (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_line_stopped", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_contents", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_line_running", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.instant_rate;\n});", + "transitionKernelCode": "", + "x": 1485, + "y": 1485 + }, + { + "id": "transition__return_to_depot", + "name": "Return a tanker to the depot", + "inputArcs": [ + { + "placeId": "place__returning", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__idle_tankers", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / parameters.return_time;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n return { IdleTankers: [{ product: input.Returning[0].product }] };\n});", + "x": 2295, + "y": 1830 + } + ], + "types": [ + { + "id": "type__tanker", + "name": "Tanker", + "iconSlug": "circle", + "displayColor": "#0ea5e9", + "elements": [ + { + "elementId": "type__tanker__product", + "name": "product", + "type": "string" + } + ] + } + ], + "differentialEquations": [], + "parameters": [ + { + "id": "param__boiloff_rate", + "name": "Boil-off rate", + "variableName": "boiloff_rate", + "type": "real", + "defaultValue": "0.16" + }, + { + "id": "param__draw_1", + "name": "SteadyNitrogen draw rate", + "variableName": "draw_1", + "type": "real", + "defaultValue": "0.8" + }, + { + "id": "param__draw_2", + "name": "SlowNitrogen draw rate", + "variableName": "draw_2", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__draw_3", + "name": "CriticalOxygen draw rate", + "variableName": "draw_3", + "type": "real", + "defaultValue": "0.6" + }, + { + "id": "param__route_scale", + "name": "Route scale", + "variableName": "route_scale", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__return_time", + "name": "Return leg (hours)", + "variableName": "return_time", + "type": "real", + "defaultValue": "4.0" + }, + { + "id": "param__instant_rate", + "name": "Instant rate (pseudo-immediate)", + "variableName": "instant_rate", + "type": "real", + "defaultValue": "1000" + }, + { + "id": "param__review_rate", + "name": "Order review rate", + "variableName": "review_rate", + "type": "real", + "defaultValue": "10" + }, + { + "id": "param__loading_rate", + "name": "Depot loading rate", + "variableName": "loading_rate", + "type": "real", + "defaultValue": "4" + } + ], + "scenarios": [ + { + "id": "scenario__base", + "name": "Three tankers, normal routes", + "description": "The reference case: three tankers on the depot, routes at their nominal length.", + "scenarioParameters": [], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ product: \"nitrogen\" }, { product: \"nitrogen\" }, { product: \"oxygen\" }],\n LoadsDelivered: 0,\n Returning: [],\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenConsumed: 0,\n CriticalOxygenEvaporated: 0,\n CriticalOxygenContents: 46,\n CriticalOxygenUllage: 12,\n};" + } + }, + { + "id": "scenario__slow_routes", + "name": "Three tankers, routes half again as long", + "description": "Winter roads. Every route stretches by half, which is a question about the tail of the delay rather than its mean.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "route_scale", + "default": 1.5 + } + ], + "parameterOverrides": { + "param__route_scale": "scenario.route_scale" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ product: \"nitrogen\" }, { product: \"nitrogen\" }, { product: \"oxygen\" }],\n LoadsDelivered: 0,\n Returning: [],\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenConsumed: 0,\n CriticalOxygenEvaporated: 0,\n CriticalOxygenContents: 46,\n CriticalOxygenUllage: 12,\n};" + } + }, + { + "id": "scenario__two_tankers", + "name": "Two tankers, normal routes", + "description": "One trailer off the road. What the fleet can absorb.", + "scenarioParameters": [], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ product: \"nitrogen\" }, { product: \"oxygen\" }],\n LoadsDelivered: 0,\n Returning: [],\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenConsumed: 0,\n CriticalOxygenEvaporated: 0,\n CriticalOxygenContents: 46,\n CriticalOxygenUllage: 12,\n};" + } + }, + { + "id": "scenario__second_oxygen_tanker", + "name": "Three tankers, two of them oxygen", + "description": "The same fleet size, re-specified so two trailers can serve the metals plant. Only a coloured net can tell this apart from the base case.", + "scenarioParameters": [], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ product: \"nitrogen\" }, { product: \"oxygen\" }, { product: \"oxygen\" }],\n LoadsDelivered: 0,\n Returning: [],\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenConsumed: 0,\n SteadyNitrogenEvaporated: 0,\n SteadyNitrogenContents: 42,\n SteadyNitrogenUllage: 12,\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenConsumed: 0,\n SlowNitrogenEvaporated: 0,\n SlowNitrogenContents: 18,\n SlowNitrogenUllage: 12,\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenConsumed: 0,\n CriticalOxygenEvaporated: 0,\n CriticalOxygenContents: 46,\n CriticalOxygenUllage: 12,\n};" + } + } + ], + "metrics": [ + { + "id": "metric__deliveries", + "name": "Loads delivered", + "description": "Tanker drops made.", + "code": "return state.places.LoadsDelivered.count;" + }, + { + "id": "metric__stockouts", + "name": "Stockouts", + "description": "Times a customer line stopped for want of product.", + "code": "return state.places.SteadyNitrogenStockouts.count + state.places.SlowNitrogenStockouts.count + state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__weighted_stockouts", + "name": "Criticality-weighted stockouts", + "description": "Stockouts weighted by how much the customer matters: the metals plant counts 3, the freezing plant 2, the laser shop 1. A total count cannot say whether the outages landed on the customer you could least afford to lose.", + "code": "return 2 * state.places.SteadyNitrogenStockouts.count + 1 * state.places.SlowNitrogenStockouts.count + 3 * state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__vented", + "name": "Vented through relief", + "description": "Units lost through a relief valve, in units at every level. Below the continuous levels the Vented place holds one token per unit, because the tank is a pile of one-unit tokens. From the continuous levels it holds one token per valve opening, each worth vent_loss units, so the count is scaled to keep this metric comparable down the sequence. Structurally reachable everywhere, and unreachable in practice below the continuous levels: under a level trigger the tank never fills completely, and with no pressure nothing else opens the valve.", + "code": "return state.places.SteadyNitrogenVented.count + state.places.SlowNitrogenVented.count + state.places.CriticalOxygenVented.count;" + }, + { + "id": "metric__evaporated", + "name": "Evaporated", + "description": "Units lost to boil-off.", + "code": "return state.places.SteadyNitrogenEvaporated.count + state.places.SlowNitrogenEvaporated.count + state.places.CriticalOxygenEvaporated.count;" + }, + { + "id": "metric__consumed", + "name": "Consumed", + "description": "Units the customers actually used.", + "code": "return state.places.SteadyNitrogenConsumed.count + state.places.SlowNitrogenConsumed.count + state.places.CriticalOxygenConsumed.count;" + }, + { + "id": "metric__stockouts_1", + "name": "SteadyNitrogen stockouts", + "description": "Times the food freezing plant stopped.", + "code": "return state.places.SteadyNitrogenStockouts.count;" + }, + { + "id": "metric__stockouts_2", + "name": "SlowNitrogen stockouts", + "description": "Times the laser cutting shop stopped.", + "code": "return state.places.SlowNitrogenStockouts.count;" + }, + { + "id": "metric__stockouts_3", + "name": "CriticalOxygen stockouts", + "description": "Times the metals plant stopped.", + "code": "return state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__envelope", + "name": "Contents plus ullage", + "description": "The place invariant, summed over the three sites. Every transition that moves product moves it between Contents and Ullage, so this is total capacity in every reachable marking and can be checked without simulating anything.", + "code": "return state.places.SteadyNitrogenContents.count + state.places.SteadyNitrogenUllage.count + state.places.SlowNitrogenContents.count + state.places.SlowNitrogenUllage.count + state.places.CriticalOxygenContents.count + state.places.CriticalOxygenUllage.count;" + }, + { + "id": "metric__stranded", + "name": "Stranded customers", + "description": "Customers that ended stopped with no order outstanding: the line is down and nothing is on its way to fix it.", + "code": "return (state.places.SteadyNitrogenLineStopped.count > 0 && state.places.SteadyNitrogenOrderPlaced.count === 0 ? 1 : 0) + (state.places.SlowNitrogenLineStopped.count > 0 && state.places.SlowNitrogenOrderPlaced.count === 0 ? 1 : 0) + (state.places.CriticalOxygenLineStopped.count > 0 && state.places.CriticalOxygenOrderPlaced.count === 0 ? 1 : 0);" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Gases 3 \u2014 coloured net, three customers and a mixed fleet" +} diff --git a/apps/petrinaut-website/src/examples/models/gases-4-dcpn.json b/apps/petrinaut-website/src/examples/models/gases-4-dcpn.json new file mode 100644 index 00000000000..cf0b2b1947f --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/gases-4-dcpn.json @@ -0,0 +1,1619 @@ +{ + "places": [ + { + "id": "place__idle_tankers", + "name": "IdleTankers", + "colorId": "type__tanker", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2190, + "y": 1875 + }, + { + "id": "place__loads_delivered", + "name": "LoadsDelivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1890, + "y": 2100 + }, + { + "id": "place__returning", + "name": "Returning", + "colorId": "type__tanker", + "dynamicsEnabled": true, + "differentialEquationId": "de__returning", + "showAsInitialState": false, + "x": 1725, + "y": 1875 + }, + { + "id": "place__open_orders", + "name": "OpenOrders", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2160, + "y": 2580 + }, + { + "id": "place__hires", + "name": "Hires", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2880, + "y": 2010 + }, + { + "id": "place__plant", + "name": "Plant", + "colorId": "type__plant", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1755, + "y": 2565 + }, + { + "id": "place__outages", + "name": "Outages", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2115, + "y": 2760 + }, + { + "id": "place__s1_order_placed", + "name": "SteadyNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2100, + "y": 2325 + }, + { + "id": "place__s1_order_permits", + "name": "SteadyNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1545, + "y": 2190 + }, + { + "id": "place__s1_on_route", + "name": "SteadyNitrogenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": true, + "differentialEquationId": "de__on_route", + "showAsInitialState": false, + "x": 2940, + "y": 2310 + }, + { + "id": "place__s1_vented", + "name": "SteadyNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1440, + "y": 1845 + }, + { + "id": "place__s1_line_running", + "name": "SteadyNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 615, + "y": 2565 + }, + { + "id": "place__s1_line_stopped", + "name": "SteadyNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1170, + "y": 2700 + }, + { + "id": "place__s1_stockouts", + "name": "SteadyNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1170, + "y": 2415 + }, + { + "id": "place__s1_tank", + "name": "SteadyNitrogenTank", + "colorId": "type__tank", + "dynamicsEnabled": true, + "differentialEquationId": "de__tank", + "showAsInitialState": true, + "x": 615, + "y": 2010 + }, + { + "id": "place__s2_order_placed", + "name": "SlowNitrogenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2100, + "y": 3540 + }, + { + "id": "place__s2_order_permits", + "name": "SlowNitrogenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1530, + "y": 3375 + }, + { + "id": "place__s2_on_route", + "name": "SlowNitrogenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": true, + "differentialEquationId": "de__on_route", + "showAsInitialState": false, + "x": 2955, + "y": 3540 + }, + { + "id": "place__s2_vented", + "name": "SlowNitrogenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1455, + "y": 2970 + }, + { + "id": "place__s2_line_running", + "name": "SlowNitrogenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 600, + "y": 3720 + }, + { + "id": "place__s2_line_stopped", + "name": "SlowNitrogenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1185, + "y": 3855 + }, + { + "id": "place__s2_stockouts", + "name": "SlowNitrogenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1185, + "y": 3570 + }, + { + "id": "place__s2_tank", + "name": "SlowNitrogenTank", + "colorId": "type__tank", + "dynamicsEnabled": true, + "differentialEquationId": "de__tank", + "showAsInitialState": true, + "x": 585, + "y": 3165 + }, + { + "id": "place__s3_order_placed", + "name": "CriticalOxygenOrderPlaced", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2115, + "y": 1350 + }, + { + "id": "place__s3_order_permits", + "name": "CriticalOxygenOrderPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1545, + "y": 1245 + }, + { + "id": "place__s3_on_route", + "name": "CriticalOxygenOnRoute", + "colorId": "type__tanker", + "dynamicsEnabled": true, + "differentialEquationId": "de__on_route", + "showAsInitialState": false, + "x": 2955, + "y": 1335 + }, + { + "id": "place__s3_vented", + "name": "CriticalOxygenVented", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1455, + "y": 780 + }, + { + "id": "place__s3_line_running", + "name": "CriticalOxygenLineRunning", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 615, + "y": 1440 + }, + { + "id": "place__s3_line_stopped", + "name": "CriticalOxygenLineStopped", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1170, + "y": 1575 + }, + { + "id": "place__s3_stockouts", + "name": "CriticalOxygenStockouts", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1170, + "y": 1290 + }, + { + "id": "place__s3_tank", + "name": "CriticalOxygenTank", + "colorId": "type__tank", + "dynamicsEnabled": true, + "differentialEquationId": "de__tank", + "showAsInitialState": true, + "x": 645, + "y": 960 + } + ], + "transitions": [ + { + "id": "transition__s1_raise_order", + "name": "Raise an order (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_order_permits", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1 + }, + { + "placeId": "place__s1_order_placed", + "weight": 1 + }, + { + "placeId": "place__open_orders", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The same threshold the plain net wrote as an inhibitor arc, now reading a\n// continuous level. Crossing it forces an order: a boundary jump, not a poll.\nexport default Lambda((input, parameters) => {\n return input.SteadyNitrogenTank[0].level < parameters.trigger_1;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SteadyNitrogenTank[0];\n return { SteadyNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1845, + "y": 2325 + }, + { + "id": "transition__s1_dispatch", + "name": "Dispatch a tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"nitrogen\" && input.Plant[0].up === 1) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n SteadyNitrogenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(6.0 * parameters.route_scale), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2685, + "y": 2190 + }, + { + "id": "transition__s1_dispatch_resourced", + "name": "Dispatch a re-sourced tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"nitrogen\" && input.Plant[0].up === 0) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n SteadyNitrogenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(6.0 * parameters.route_scale * parameters.outage_route_penalty), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2685, + "y": 2415 + }, + { + "id": "transition__s1_arrive", + "name": "Unload the tanker (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1 + }, + { + "placeId": "place__s1_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SteadyNitrogenOnRoute[0].remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.SteadyNitrogenOnRoute[0];\n const unit = input.SteadyNitrogenTank[0];\n const taken = Math.min(truck.payload, Math.max(unit.capacity - unit.level, 0));\n return {\n SteadyNitrogenTank: [\n {\n level: unit.level + taken, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled + (truck.payload - taken)\n },\n ],\n Returning: [\n { remaining: parameters.return_time, product: truck.product, payload: truck.payload, hired: truck.hired },\n ],\n };\n});", + "x": 1170, + "y": 2100 + }, + { + "id": "transition__s1_vent", + "name": "Vent through the relief valve (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1 + }, + { + "placeId": "place__s1_vented", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The boundary jump no lower rung can express. Pressure reaching the setpoint\n// forces a discrete loss of product, and the valve reseats below the setpoint\n// so it cycles rather than firing once.\nexport default Lambda((input, parameters) => {\n return input.SteadyNitrogenTank[0].pressure >= parameters.relief_setpoint;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.SteadyNitrogenTank[0];\n return {\n SteadyNitrogenTank: [\n {\n level: Math.max(unit.level - parameters.vent_loss, 0), pressure: parameters.relief_setpoint - parameters.relief_reseat, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled\n },\n ],\n };\n});", + "x": 1170, + "y": 1845 + }, + { + "id": "transition__s1_stop_line", + "name": "Stop the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_line_running", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1 + }, + { + "placeId": "place__s1_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s1_stockouts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SteadyNitrogenTank[0].level <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SteadyNitrogenTank[0];\n return { SteadyNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 885, + "y": 2565 + }, + { + "id": "transition__s1_resume_line", + "name": "Resume the line (SteadyNitrogen)", + "inputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s1_line_stopped", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s1_tank", + "weight": 1 + }, + { + "placeId": "place__s1_line_running", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SteadyNitrogenTank[0].level > 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SteadyNitrogenTank[0];\n return { SteadyNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1410, + "y": 2700 + }, + { + "id": "transition__s2_raise_order", + "name": "Raise an order (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_order_permits", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1 + }, + { + "placeId": "place__s2_order_placed", + "weight": 1 + }, + { + "placeId": "place__open_orders", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The same threshold the plain net wrote as an inhibitor arc, now reading a\n// continuous level. Crossing it forces an order: a boundary jump, not a poll.\nexport default Lambda((input, parameters) => {\n return input.SlowNitrogenTank[0].level < parameters.trigger_2;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SlowNitrogenTank[0];\n return { SlowNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1830, + "y": 3540 + }, + { + "id": "transition__s2_dispatch", + "name": "Dispatch a tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"nitrogen\" && input.Plant[0].up === 1) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n SlowNitrogenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(9.0 * parameters.route_scale), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2670, + "y": 3450 + }, + { + "id": "transition__s2_dispatch_resourced", + "name": "Dispatch a re-sourced tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"nitrogen\" && input.Plant[0].up === 0) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n SlowNitrogenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(9.0 * parameters.route_scale * parameters.outage_route_penalty), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2670, + "y": 3630 + }, + { + "id": "transition__s2_arrive", + "name": "Unload the tanker (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1 + }, + { + "placeId": "place__s2_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SlowNitrogenOnRoute[0].remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.SlowNitrogenOnRoute[0];\n const unit = input.SlowNitrogenTank[0];\n const taken = Math.min(truck.payload, Math.max(unit.capacity - unit.level, 0));\n return {\n SlowNitrogenTank: [\n {\n level: unit.level + taken, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled + (truck.payload - taken)\n },\n ],\n Returning: [\n { remaining: parameters.return_time, product: truck.product, payload: truck.payload, hired: truck.hired },\n ],\n };\n});", + "x": 1170, + "y": 3240 + }, + { + "id": "transition__s2_vent", + "name": "Vent through the relief valve (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1 + }, + { + "placeId": "place__s2_vented", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The boundary jump no lower rung can express. Pressure reaching the setpoint\n// forces a discrete loss of product, and the valve reseats below the setpoint\n// so it cycles rather than firing once.\nexport default Lambda((input, parameters) => {\n return input.SlowNitrogenTank[0].pressure >= parameters.relief_setpoint;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.SlowNitrogenTank[0];\n return {\n SlowNitrogenTank: [\n {\n level: Math.max(unit.level - parameters.vent_loss, 0), pressure: parameters.relief_setpoint - parameters.relief_reseat, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled\n },\n ],\n };\n});", + "x": 1170, + "y": 2970 + }, + { + "id": "transition__s2_stop_line", + "name": "Stop the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_line_running", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1 + }, + { + "placeId": "place__s2_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s2_stockouts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SlowNitrogenTank[0].level <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SlowNitrogenTank[0];\n return { SlowNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 870, + "y": 3720 + }, + { + "id": "transition__s2_resume_line", + "name": "Resume the line (SlowNitrogen)", + "inputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s2_line_stopped", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s2_tank", + "weight": 1 + }, + { + "placeId": "place__s2_line_running", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.SlowNitrogenTank[0].level > 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.SlowNitrogenTank[0];\n return { SlowNitrogenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1425, + "y": 3855 + }, + { + "id": "transition__s3_raise_order", + "name": "Raise an order (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_order_permits", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1 + }, + { + "placeId": "place__s3_order_placed", + "weight": 1 + }, + { + "placeId": "place__open_orders", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The same threshold the plain net wrote as an inhibitor arc, now reading a\n// continuous level. Crossing it forces an order: a boundary jump, not a poll.\nexport default Lambda((input, parameters) => {\n return input.CriticalOxygenTank[0].level < parameters.trigger_3;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.CriticalOxygenTank[0];\n return { CriticalOxygenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1845, + "y": 1350 + }, + { + "id": "transition__s3_dispatch", + "name": "Dispatch a tanker (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"oxygen\" && input.Plant[0].up === 1) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n CriticalOxygenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(12.0 * parameters.route_scale), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2700, + "y": 1260 + }, + { + "id": "transition__s3_dispatch_resourced", + "name": "Dispatch a re-sourced tanker (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_order_placed", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__plant", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_on_route", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return (input.IdleTankers[0].product === \"oxygen\" && input.Plant[0].up === 0) ? parameters.loading_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.IdleTankers[0];\n return {\n CriticalOxygenOnRoute: [{ remaining: Distribution.Lognormal(Math.log(12.0 * parameters.route_scale * parameters.outage_route_penalty), parameters.route_spread), product: unit.product, payload: unit.payload, hired: unit.hired }],\n };\n});", + "x": 2715, + "y": 1440 + }, + { + "id": "transition__s3_arrive", + "name": "Unload the tanker (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_on_route", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1 + }, + { + "placeId": "place__s3_order_permits", + "weight": 1 + }, + { + "placeId": "place__loads_delivered", + "weight": 1 + }, + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.CriticalOxygenOnRoute[0].remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.CriticalOxygenOnRoute[0];\n const unit = input.CriticalOxygenTank[0];\n const taken = Math.min(truck.payload, Math.max(unit.capacity - unit.level, 0));\n return {\n CriticalOxygenTank: [\n {\n level: unit.level + taken, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled + (truck.payload - taken)\n },\n ],\n Returning: [\n { remaining: parameters.return_time, product: truck.product, payload: truck.payload, hired: truck.hired },\n ],\n };\n});", + "x": 1170, + "y": 1080 + }, + { + "id": "transition__s3_vent", + "name": "Vent through the relief valve (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1 + }, + { + "placeId": "place__s3_vented", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The boundary jump no lower rung can express. Pressure reaching the setpoint\n// forces a discrete loss of product, and the valve reseats below the setpoint\n// so it cycles rather than firing once.\nexport default Lambda((input, parameters) => {\n return input.CriticalOxygenTank[0].pressure >= parameters.relief_setpoint;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const unit = input.CriticalOxygenTank[0];\n return {\n CriticalOxygenTank: [\n {\n level: Math.max(unit.level - parameters.vent_loss, 0), pressure: parameters.relief_setpoint - parameters.relief_reseat, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled\n },\n ],\n };\n});", + "x": 1170, + "y": 780 + }, + { + "id": "transition__s3_stop_line", + "name": "Stop the line (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_line_running", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1 + }, + { + "placeId": "place__s3_line_stopped", + "weight": 1 + }, + { + "placeId": "place__s3_stockouts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.CriticalOxygenTank[0].level <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.CriticalOxygenTank[0];\n return { CriticalOxygenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 885, + "y": 1440 + }, + { + "id": "transition__s3_resume_line", + "name": "Resume the line (CriticalOxygen)", + "inputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__s3_line_stopped", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__s3_tank", + "weight": 1 + }, + { + "placeId": "place__s3_line_running", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.CriticalOxygenTank[0].level > 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.CriticalOxygenTank[0];\n return { CriticalOxygenTank: [{ level: unit.level, pressure: unit.pressure, capacity: unit.capacity, draw: unit.draw, drawn: unit.drawn, boiled: unit.boiled, criticality: unit.criticality, product: unit.product, spilled: unit.spilled }] };\n});", + "x": 1425, + "y": 1575 + }, + { + "id": "transition__return_to_depot", + "name": "Return a tanker to the depot", + "inputArcs": [ + { + "placeId": "place__returning", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__idle_tankers", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.Returning[0].remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const unit = input.Returning[0];\n return { IdleTankers: [{ remaining: 0, product: unit.product, payload: unit.payload, hired: unit.hired }] };\n});", + "x": 1950, + "y": 1875 + }, + { + "id": "transition__hire_tanker", + "name": "Hire a tanker", + "inputArcs": [ + { + "placeId": "place__open_orders", + "weight": 3, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__idle_tankers", + "weight": 1 + }, + { + "placeId": "place__hires", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.hire_enabled > 0 ? parameters.hire_rate : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n IdleTankers: [\n {\n remaining: 0,\n product: parameters.hire_oxygen > 0 ? \"oxygen\" : \"nitrogen\",\n payload: parameters.hired_payload,\n hired: 1,\n },\n ],\n };\n});", + "x": 2580, + "y": 2010 + }, + { + "id": "transition__release_tanker", + "name": "Release a hired tanker", + "inputArcs": [ + { + "placeId": "place__idle_tankers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__open_orders", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.IdleTankers[0].hired === 1 ? 1000 : 1e-9;\n});", + "transitionKernelCode": "", + "x": 2565, + "y": 2580 + }, + { + "id": "transition__plant_trips", + "name": "Trip the air separation plant", + "inputArcs": [ + { + "placeId": "place__plant", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__plant", + "weight": 1 + }, + { + "placeId": "place__outages", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.Plant[0].up === 1 ? 1 / parameters.uptime_hours : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel(() => {\n return { Plant: [{ up: 0 }] };\n});", + "x": 1755, + "y": 2760 + }, + { + "id": "transition__plant_recovers", + "name": "Restart the air separation plant", + "inputArcs": [ + { + "placeId": "place__plant", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__plant", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.Plant[0].up === 0 ? 1 / parameters.repair_hours : 1e-9;\n});", + "transitionKernelCode": "export default TransitionKernel(() => {\n return { Plant: [{ up: 1 }] };\n});", + "x": 1755, + "y": 2970 + } + ], + "types": [ + { + "id": "type__tanker", + "name": "Tanker", + "iconSlug": "circle", + "displayColor": "#0ea5e9", + "elements": [ + { + "elementId": "type__tanker__remaining", + "name": "remaining", + "type": "real" + }, + { + "elementId": "type__tanker__product", + "name": "product", + "type": "string" + }, + { + "elementId": "type__tanker__payload", + "name": "payload", + "type": "real" + }, + { + "elementId": "type__tanker__hired", + "name": "hired", + "type": "integer" + } + ] + }, + { + "id": "type__tank", + "name": "Tank", + "iconSlug": "circle", + "displayColor": "#f97316", + "elements": [ + { + "elementId": "type__tank__level", + "name": "level", + "type": "real" + }, + { + "elementId": "type__tank__pressure", + "name": "pressure", + "type": "real" + }, + { + "elementId": "type__tank__capacity", + "name": "capacity", + "type": "real" + }, + { + "elementId": "type__tank__draw", + "name": "draw", + "type": "real" + }, + { + "elementId": "type__tank__drawn", + "name": "drawn", + "type": "real" + }, + { + "elementId": "type__tank__boiled", + "name": "boiled", + "type": "real" + }, + { + "elementId": "type__tank__criticality", + "name": "criticality", + "type": "integer" + }, + { + "elementId": "type__tank__product", + "name": "product", + "type": "string" + }, + { + "elementId": "type__tank__spilled", + "name": "spilled", + "type": "real" + } + ] + }, + { + "id": "type__plant", + "name": "Plant", + "iconSlug": "circle", + "displayColor": "#ef4444", + "elements": [ + { + "elementId": "type__plant__up", + "name": "up", + "type": "integer" + } + ] + } + ], + "differentialEquations": [ + { + "id": "de__tank", + "name": "Tank", + "colorId": "type__tank", + "code": "// The rung the whole domain is built for. Level falls from the customer's draw\n// and from boil-off together, and stops at empty so Euler cannot drive it\n// negative. Pressure rises as boil-off gas fills whatever ullage is left, so a\n// nearly full tank pressurises fastest, and falls as liquid is drawn off. Empty\n// stops the customer's line and full opens the relief valve, so the safe region\n// is an interval and \"hold more stock\" is not a safe default.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map((unit) => ({\n level: unit.level > 0 ? -(unit.draw + parameters.boiloff_rate) : 0, pressure: Math.max(parameters.pressure_gain * parameters.boiloff_rate / Math.max(unit.capacity - unit.level, 1) - parameters.pressure_vented_by_draw * unit.draw, unit.pressure > 1 ? -1 : 0), capacity: 0, draw: 0, drawn: unit.level > 0 ? unit.draw : 0, boiled: unit.level > 0 ? parameters.boiloff_rate : 0, spilled: 0\n }));\n});" + }, + { + "id": "de__on_route", + "name": "Journey clock (on route)", + "colorId": "type__tanker", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ remaining: -1, payload: 0 }));\n});" + }, + { + "id": "de__returning", + "name": "Journey clock (returning)", + "colorId": "type__tanker", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ remaining: -1, payload: 0 }));\n});" + } + ], + "parameters": [ + { + "id": "param__boiloff_rate", + "name": "Boil-off rate", + "variableName": "boiloff_rate", + "type": "real", + "defaultValue": "0.16" + }, + { + "id": "param__draw_1", + "name": "SteadyNitrogen draw rate", + "variableName": "draw_1", + "type": "real", + "defaultValue": "0.8" + }, + { + "id": "param__draw_2", + "name": "SlowNitrogen draw rate", + "variableName": "draw_2", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__draw_3", + "name": "CriticalOxygen draw rate", + "variableName": "draw_3", + "type": "real", + "defaultValue": "0.6" + }, + { + "id": "param__route_scale", + "name": "Route scale", + "variableName": "route_scale", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__route_spread", + "name": "Route spread", + "variableName": "route_spread", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param__return_time", + "name": "Return leg (hours)", + "variableName": "return_time", + "type": "real", + "defaultValue": "4.0" + }, + { + "id": "param__trigger_1", + "name": "SteadyNitrogen trigger level", + "variableName": "trigger_1", + "type": "real", + "defaultValue": "16" + }, + { + "id": "param__trigger_2", + "name": "SlowNitrogen trigger level", + "variableName": "trigger_2", + "type": "real", + "defaultValue": "6" + }, + { + "id": "param__trigger_3", + "name": "CriticalOxygen trigger level", + "variableName": "trigger_3", + "type": "real", + "defaultValue": "20" + }, + { + "id": "param__pressure_gain", + "name": "Pressure gain", + "variableName": "pressure_gain", + "type": "real", + "defaultValue": "20" + }, + { + "id": "param__pressure_vented_by_draw", + "name": "Pressure vented by draw", + "variableName": "pressure_vented_by_draw", + "type": "real", + "defaultValue": "3" + }, + { + "id": "param__relief_setpoint", + "name": "Relief setpoint", + "variableName": "relief_setpoint", + "type": "real", + "defaultValue": "8" + }, + { + "id": "param__relief_reseat", + "name": "Relief reseat margin", + "variableName": "relief_reseat", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__vent_loss", + "name": "Units lost per valve opening", + "variableName": "vent_loss", + "type": "real", + "defaultValue": "0.4" + }, + { + "id": "param__hire_enabled", + "name": "Hire enabled", + "variableName": "hire_enabled", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__hire_oxygen", + "name": "Hire oxygen trailers (0 nitrogen, 1 oxygen)", + "variableName": "hire_oxygen", + "type": "integer", + "defaultValue": "0" + }, + { + "id": "param__hired_payload", + "name": "Hired payload", + "variableName": "hired_payload", + "type": "real", + "defaultValue": "12" + }, + { + "id": "param__uptime_hours", + "name": "Mean hours between trips", + "variableName": "uptime_hours", + "type": "real", + "defaultValue": "90" + }, + { + "id": "param__repair_hours", + "name": "Mean hours to recover", + "variableName": "repair_hours", + "type": "real", + "defaultValue": "24" + }, + { + "id": "param__outage_route_penalty", + "name": "Re-sourced route penalty", + "variableName": "outage_route_penalty", + "type": "real", + "defaultValue": "2.2" + }, + { + "id": "param__loading_rate", + "name": "Depot loading rate", + "variableName": "loading_rate", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param__hire_rate", + "name": "Spot hire arrival rate", + "variableName": "hire_rate", + "type": "real", + "defaultValue": "0.1" + } + ], + "scenarios": [ + { + "id": "scenario__base", + "name": "Three tankers, normal routes", + "description": "The reference case: three tankers on the depot, routes at their nominal length.", + "scenarioParameters": [], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"oxygen\", payload: 12, hired: 0 }],\n LoadsDelivered: 0,\n Returning: [],\n OpenOrders: 0,\n Hires: 0,\n Plant: [{ up: 1 }],\n Outages: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenTank: [{ level: 42, pressure: 2, drawn: 0, boiled: 0, capacity: 54, draw: parameters.draw_1, criticality: 2, product: \"nitrogen\", spilled: 0 }],\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenTank: [{ level: 18, pressure: 2, drawn: 0, boiled: 0, capacity: 30, draw: parameters.draw_2, criticality: 1, product: \"nitrogen\", spilled: 0 }],\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenTank: [{ level: 46, pressure: 2, drawn: 0, boiled: 0, capacity: 58, draw: parameters.draw_3, criticality: 3, product: \"oxygen\", spilled: 0 }],\n};" + } + }, + { + "id": "scenario__slow_routes", + "name": "Three tankers, routes half again as long", + "description": "Winter roads. Every route stretches by half, which is a question about the tail of the delay rather than its mean.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "route_scale", + "default": 1.5 + } + ], + "parameterOverrides": { + "param__route_scale": "scenario.route_scale" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"oxygen\", payload: 12, hired: 0 }],\n LoadsDelivered: 0,\n Returning: [],\n OpenOrders: 0,\n Hires: 0,\n Plant: [{ up: 1 }],\n Outages: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenTank: [{ level: 42, pressure: 2, drawn: 0, boiled: 0, capacity: 54, draw: parameters.draw_1, criticality: 2, product: \"nitrogen\", spilled: 0 }],\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenTank: [{ level: 18, pressure: 2, drawn: 0, boiled: 0, capacity: 30, draw: parameters.draw_2, criticality: 1, product: \"nitrogen\", spilled: 0 }],\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenTank: [{ level: 46, pressure: 2, drawn: 0, boiled: 0, capacity: 58, draw: parameters.draw_3, criticality: 3, product: \"oxygen\", spilled: 0 }],\n};" + } + }, + { + "id": "scenario__two_tankers", + "name": "Two tankers, normal routes", + "description": "One trailer off the road. What the fleet can absorb.", + "scenarioParameters": [], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"oxygen\", payload: 12, hired: 0 }],\n LoadsDelivered: 0,\n Returning: [],\n OpenOrders: 0,\n Hires: 0,\n Plant: [{ up: 1 }],\n Outages: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenTank: [{ level: 42, pressure: 2, drawn: 0, boiled: 0, capacity: 54, draw: parameters.draw_1, criticality: 2, product: \"nitrogen\", spilled: 0 }],\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenTank: [{ level: 18, pressure: 2, drawn: 0, boiled: 0, capacity: 30, draw: parameters.draw_2, criticality: 1, product: \"nitrogen\", spilled: 0 }],\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenTank: [{ level: 46, pressure: 2, drawn: 0, boiled: 0, capacity: 58, draw: parameters.draw_3, criticality: 3, product: \"oxygen\", spilled: 0 }],\n};" + } + }, + { + "id": "scenario__slow_customer_throttled", + "name": "SlowNitrogen throttled back", + "description": "The slow-drawing customer cuts to a fifth of its usual draw. Its tank now sits nearly full with boil-off gas filling a small ullage, so the relief valve starts to cycle. This is the case where filling a tank up is the wrong thing to do.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "slow_draw", + "default": 0.01 + } + ], + "parameterOverrides": { + "param__draw_2": "scenario.slow_draw" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"oxygen\", payload: 12, hired: 0 }],\n LoadsDelivered: 0,\n Returning: [],\n OpenOrders: 0,\n Hires: 0,\n Plant: [{ up: 1 }],\n Outages: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenTank: [{ level: 42, pressure: 2, drawn: 0, boiled: 0, capacity: 54, draw: parameters.draw_1, criticality: 2, product: \"nitrogen\", spilled: 0 }],\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenTank: [{ level: 18, pressure: 2, drawn: 0, boiled: 0, capacity: 30, draw: parameters.draw_2, criticality: 1, product: \"nitrogen\", spilled: 0 }],\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenTank: [{ level: 46, pressure: 2, drawn: 0, boiled: 0, capacity: 58, draw: parameters.draw_3, criticality: 3, product: \"oxygen\", spilled: 0 }],\n};" + } + }, + { + "id": "scenario__no_hire", + "name": "No spot hire", + "description": "The same net with hiring switched off, so the fleet is fixed at three. The difference against the base case is what the ability to hire is worth.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "hire_enabled", + "default": 0 + } + ], + "parameterOverrides": { + "param__hire_enabled": "scenario.hire_enabled" + }, + "initialState": { + "type": "code", + "content": "return {\n IdleTankers: [{ remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"nitrogen\", payload: 12, hired: 0 }, { remaining: 0, product: \"oxygen\", payload: 12, hired: 0 }],\n LoadsDelivered: 0,\n Returning: [],\n OpenOrders: 0,\n Hires: 0,\n Plant: [{ up: 1 }],\n Outages: 0,\n SteadyNitrogenOrderPlaced: 0,\n SteadyNitrogenOrderPermits: 2,\n SteadyNitrogenOnRoute: [],\n SteadyNitrogenVented: 0,\n SteadyNitrogenLineRunning: 1,\n SteadyNitrogenLineStopped: 0,\n SteadyNitrogenStockouts: 0,\n SteadyNitrogenTank: [{ level: 42, pressure: 2, drawn: 0, boiled: 0, capacity: 54, draw: parameters.draw_1, criticality: 2, product: \"nitrogen\", spilled: 0 }],\n SlowNitrogenOrderPlaced: 0,\n SlowNitrogenOrderPermits: 1,\n SlowNitrogenOnRoute: [],\n SlowNitrogenVented: 0,\n SlowNitrogenLineRunning: 1,\n SlowNitrogenLineStopped: 0,\n SlowNitrogenStockouts: 0,\n SlowNitrogenTank: [{ level: 18, pressure: 2, drawn: 0, boiled: 0, capacity: 30, draw: parameters.draw_2, criticality: 1, product: \"nitrogen\", spilled: 0 }],\n CriticalOxygenOrderPlaced: 0,\n CriticalOxygenOrderPermits: 2,\n CriticalOxygenOnRoute: [],\n CriticalOxygenVented: 0,\n CriticalOxygenLineRunning: 1,\n CriticalOxygenLineStopped: 0,\n CriticalOxygenStockouts: 0,\n CriticalOxygenTank: [{ level: 46, pressure: 2, drawn: 0, boiled: 0, capacity: 58, draw: parameters.draw_3, criticality: 3, product: \"oxygen\", spilled: 0 }],\n};" + } + } + ], + "metrics": [ + { + "id": "metric__deliveries", + "name": "Loads delivered", + "description": "Tanker drops made.", + "code": "return state.places.LoadsDelivered.count;" + }, + { + "id": "metric__stockouts", + "name": "Stockouts", + "description": "Times a customer line stopped for want of product.", + "code": "return state.places.SteadyNitrogenStockouts.count + state.places.SlowNitrogenStockouts.count + state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__weighted_stockouts", + "name": "Criticality-weighted stockouts", + "description": "Stockouts weighted by how much the customer matters: the metals plant counts 3, the freezing plant 2, the laser shop 1. A total count cannot say whether the outages landed on the customer you could least afford to lose.", + "code": "return 2 * state.places.SteadyNitrogenStockouts.count + 1 * state.places.SlowNitrogenStockouts.count + 3 * state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__vented", + "name": "Vented through relief", + "description": "Units lost through a relief valve, in units at every level. Below the continuous levels the Vented place holds one token per unit, because the tank is a pile of one-unit tokens. From the continuous levels it holds one token per valve opening, each worth vent_loss units, so the count is scaled to keep this metric comparable down the sequence. Structurally reachable everywhere, and unreachable in practice below the continuous levels: under a level trigger the tank never fills completely, and with no pressure nothing else opens the valve.", + "code": "return parameters.vent_loss * (state.places.SteadyNitrogenVented.count + state.places.SlowNitrogenVented.count + state.places.CriticalOxygenVented.count);" + }, + { + "id": "metric__evaporated", + "name": "Evaporated", + "description": "Units lost to boil-off.", + "code": "const boiled1 = state.places.SteadyNitrogenTank.tokens.reduce((sum, tank) => sum + tank.boiled, 0);\nconst boiled2 = state.places.SlowNitrogenTank.tokens.reduce((sum, tank) => sum + tank.boiled, 0);\nconst boiled3 = state.places.CriticalOxygenTank.tokens.reduce((sum, tank) => sum + tank.boiled, 0);\nreturn boiled1 + boiled2 + boiled3;" + }, + { + "id": "metric__spilled", + "name": "Surplus lost on delivery", + "description": "Units a tanker could not fit into the tank. From this level the arrival kernel fills the tank to capacity and drops the remainder, where levels 1 to 3 held the delivery back until it fit. It reads 0 in every scenario here, because the reorder trigger plus the largest possible outstanding order stays at least 14 units below capacity in all three tanks (40 of 54, 18 of 30, 44 of 58), so a load always fits. The metric guards that headroom against a change in trigger, payload or permit count.", + "code": "const spilled1 = state.places.SteadyNitrogenTank.tokens.reduce((sum, tank) => sum + tank.spilled, 0);\nconst spilled2 = state.places.SlowNitrogenTank.tokens.reduce((sum, tank) => sum + tank.spilled, 0);\nconst spilled3 = state.places.CriticalOxygenTank.tokens.reduce((sum, tank) => sum + tank.spilled, 0);\nreturn spilled1 + spilled2 + spilled3;" + }, + { + "id": "metric__consumed", + "name": "Consumed", + "description": "Units the customers actually used.", + "code": "const drawn1 = state.places.SteadyNitrogenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nconst drawn2 = state.places.SlowNitrogenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nconst drawn3 = state.places.CriticalOxygenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nreturn drawn1 + drawn2 + drawn3;" + }, + { + "id": "metric__stockouts_per_hundred", + "name": "Stockouts per 100 units delivered to customers", + "description": "Stockouts against the volume actually drawn. A demand process that cannot go negative consumes more when it is more volatile, so a raw count would credit a volatile scenario for being busier as well as worse.", + "code": "const drawn1 = state.places.SteadyNitrogenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nconst drawn2 = state.places.SlowNitrogenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nconst drawn3 = state.places.CriticalOxygenTank.tokens.reduce((sum, tank) => sum + tank.drawn, 0);\nconst drawnTotal = drawn1 + drawn2 + drawn3;\nreturn drawnTotal > 0 ? 100 * (state.places.SteadyNitrogenStockouts.count + state.places.SlowNitrogenStockouts.count + state.places.CriticalOxygenStockouts.count) / drawnTotal : 0;" + }, + { + "id": "metric__stockouts_1", + "name": "SteadyNitrogen stockouts", + "description": "Times the food freezing plant stopped.", + "code": "return state.places.SteadyNitrogenStockouts.count;" + }, + { + "id": "metric__stockouts_2", + "name": "SlowNitrogen stockouts", + "description": "Times the laser cutting shop stopped.", + "code": "return state.places.SlowNitrogenStockouts.count;" + }, + { + "id": "metric__stockouts_3", + "name": "CriticalOxygen stockouts", + "description": "Times the metals plant stopped.", + "code": "return state.places.CriticalOxygenStockouts.count;" + }, + { + "id": "metric__level", + "name": "Level in tanks", + "description": "Units left across the three customer tanks at the end.", + "code": "const level1 = state.places.SteadyNitrogenTank.tokens.reduce((sum, tank) => sum + tank.level, 0);\nconst level2 = state.places.SlowNitrogenTank.tokens.reduce((sum, tank) => sum + tank.level, 0);\nconst level3 = state.places.CriticalOxygenTank.tokens.reduce((sum, tank) => sum + tank.level, 0);\nreturn level1 + level2 + level3;" + }, + { + "id": "metric__vent_openings", + "name": "Relief valve openings", + "description": "Times a relief valve lifted. The valve reseats below the setpoint, so a tank that stays near it cycles, and the count of openings says how hard the valve is working where the units vented say what it cost.", + "code": "return state.places.SteadyNitrogenVented.count + state.places.SlowNitrogenVented.count + state.places.CriticalOxygenVented.count;" + }, + { + "id": "metric__pressure", + "name": "Pressure at SlowNitrogen", + "description": "Tank pressure at the slow-drawing customer, where boil-off gas has the least ullage to fill.", + "code": "const tanks = state.places.SlowNitrogenTank.tokens;\nif (tanks.length === 0) return 0;\nreturn tanks[0].pressure;" + }, + { + "id": "metric__hires", + "name": "Tankers hired in", + "description": "Trailers created on demand. A fixed-population net cannot represent this at all.", + "code": "return state.places.Hires.count;" + }, + { + "id": "metric__outages", + "name": "Plant outages", + "description": "Times the air separation plant tripped and its customers were re-sourced onto longer routes.", + "code": "return state.places.Outages.count;" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Gases 4 \u2014 dynamic coloured net" +} diff --git a/apps/petrinaut-website/src/examples/models/semiconductor-fab-drift.json b/apps/petrinaut-website/src/examples/models/semiconductor-fab-drift.json new file mode 100644 index 00000000000..a6ced807e89 --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/semiconductor-fab-drift.json @@ -0,0 +1,1545 @@ +{ + "places": [ + { + "id": "place__fab_entrance", + "name": "FabEntrance", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__aging", + "showAsInitialState": false, + "x": 2360, + "y": 423.5 + }, + { + "id": "place__wip_queue", + "name": "WIPQueue", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__aging", + "showAsInitialState": false, + "x": -255, + "y": 1515 + }, + { + "id": "place__batch_queue", + "name": "BatchQueue", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__batch_wait", + "showAsInitialState": false, + "x": 375, + "y": 1515 + }, + { + "id": "place__in_process", + "name": "InProcess", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__processing", + "showAsInitialState": false, + "x": 975, + "y": 1515 + }, + { + "id": "place__post_process", + "name": "PostProcess", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__aging", + "showAsInitialState": false, + "x": 1800, + "y": 569.3333333333334 + }, + { + "id": "place__in_inspection", + "name": "InInspection", + "colorId": "type__lot", + "dynamicsEnabled": true, + "differentialEquationId": "de__processing", + "showAsInitialState": false, + "x": 930, + "y": 585 + }, + { + "id": "place__finished", + "name": "Finished", + "colorId": "type__lot", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2360, + "y": 256 + }, + { + "id": "place__scrapped", + "name": "Scrapped", + "colorId": "type__lot", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2355, + "y": 885 + }, + { + "id": "place__wip_permits", + "name": "WIPPermits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2355, + "y": 1110 + }, + { + "id": "place__chambers_available", + "name": "ChambersAvailable", + "colorId": "type__chamber", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 0, + "y": 1020 + }, + { + "id": "place__chambers_processing", + "name": "ChambersProcessing", + "colorId": "type__chamber", + "dynamicsEnabled": true, + "differentialEquationId": "de__chamber_processing", + "showAsInitialState": false, + "x": 930, + "y": 1020 + }, + { + "id": "place__chambers_in_maintenance", + "name": "ChambersInMaintenance", + "colorId": "type__chamber", + "dynamicsEnabled": true, + "differentialEquationId": "de__chamber_maintenance", + "showAsInitialState": false, + "x": 1875, + "y": 1515 + }, + { + "id": "place__chambers_broken", + "name": "ChambersBroken", + "colorId": "type__chamber", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1800, + "y": 930 + }, + { + "id": "place__maintenance_crew", + "name": "MaintenanceCrew", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 1260, + "y": 1230 + }, + { + "id": "place__maintenance_events", + "name": "MaintenanceEvents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1800, + "y": 1080 + }, + { + "id": "place__breakdown_events", + "name": "BreakdownEvents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1800, + "y": 1231 + }, + { + "id": "place__lots_released", + "name": "LotsReleased", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2925, + "y": 1110 + }, + { + "id": "place__lots_completed", + "name": "LotsCompleted", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2360, + "y": 106 + }, + { + "id": "place__calibrations", + "name": "Calibrations", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2505, + "y": 1515 + } + ], + "transitions": [ + { + "id": "transition__demand_logic", + "name": "Demand arrives (logic)", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__fab_entrance", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.demand_rate * 0.5;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n FabEntrance: [{ product_type: 0, layer: 0, priority: 1.0, age: 0, defect_count: 0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0, processing_chamber: -1, batch_wait: 0 }],\n };\n});", + "x": 2080, + "y": 456 + }, + { + "id": "transition__demand_memory", + "name": "Demand arrives (memory)", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__fab_entrance", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.demand_rate * 0.35;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n FabEntrance: [{ product_type: 1, layer: 0, priority: 1.0, age: 0, defect_count: 0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0, processing_chamber: -1, batch_wait: 0 }],\n };\n});", + "x": 2080, + "y": 156 + }, + { + "id": "transition__demand_analog", + "name": "Demand arrives (analog)", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__fab_entrance", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.demand_rate * 0.15;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n FabEntrance: [{ product_type: 2, layer: 0, priority: 1.0, age: 0, defect_count: 0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0, processing_chamber: -1, batch_wait: 0 }],\n };\n});", + "x": 2080, + "y": 356 + }, + { + "id": "transition__release_lot", + "name": "Release lot into fab", + "inputArcs": [ + { + "placeId": "place__fab_entrance", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__wip_permits", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1 + }, + { + "placeId": "place__lots_released", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => true);", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.FabEntrance[0];\n return {\n WIPQueue: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 2595, + "y": 1110 + }, + { + "id": "transition__priority_update", + "name": "Update lot priority toward deadline", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.WIPQueue[0];\n return lot.wait_time >= parameters.priority_update_interval;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.WIPQueue[0];\n const remaining = lot.due_date - lot.age;\n const urgency = remaining <= 0 ? 10 : parameters.target_cycle_time / remaining;\n const newPriority = Math.max(urgency, 1.0);\n return {\n WIPQueue: [{ product_type: lot.product_type, layer: lot.layer, priority: newPriority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": -255, + "y": 1110 + }, + { + "id": "transition__extend_deadline", + "name": "Renegotiate deadline (lot past due)", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.WIPQueue[0];\n return lot.age > lot.due_date + parameters.deadline_grace_period;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.WIPQueue[0];\n return {\n WIPQueue: [{ product_type: lot.product_type, layer: lot.layer, priority: 1.0, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: 0, due_date: lot.age + parameters.target_cycle_time, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": -255, + "y": 1290 + }, + { + "id": "transition__dispatch_litho", + "name": "Dispatch lot to litho chamber", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_process", + "weight": 1 + }, + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n const qualOk = chamber.qualification === 0\n || (chamber.qualification === 1 && lot.product_type <= 1)\n || (chamber.qualification === 2 && lot.product_type === 2);\n return chamber.machine_group === 0 && qualOk && (lot.layer === 0 || lot.layer === 4 || lot.layer === 9 || lot.layer === 12 || lot.layer === 16 || lot.layer === 20 || lot.layer === 24);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n const productFactor = lot.product_type === 0 ? 1.0\n : lot.product_type === 1 ? 0.85 : 1.15;\n return {\n InProcess: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: Distribution.Lognormal(Math.log(parameters.litho_time * productFactor), parameters.process_sigma), wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: chamber.tool_id * 100 + chamber.chamber_idx, batch_wait: 0 }],\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 465, + "y": 1125 + }, + { + "id": "transition__dispatch_etch", + "name": "Dispatch lot to etch chamber", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_process", + "weight": 1 + }, + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n const qualOk = chamber.qualification === 0\n || (chamber.qualification === 1 && lot.product_type <= 1)\n || (chamber.qualification === 2 && lot.product_type === 2);\n return chamber.machine_group === 1 && qualOk && (lot.layer === 1 || lot.layer === 5 || lot.layer === 8 || lot.layer === 10 || lot.layer === 13 || lot.layer === 15 || lot.layer === 17 || lot.layer === 21 || lot.layer === 25 || lot.layer === 27);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n const productFactor = lot.product_type === 0 ? 1.0\n : lot.product_type === 1 ? 0.85 : 1.15;\n return {\n InProcess: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: Distribution.Lognormal(Math.log(parameters.etch_time * productFactor), parameters.process_sigma), wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: chamber.tool_id * 100 + chamber.chamber_idx, batch_wait: 0 }],\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 465, + "y": 915 + }, + { + "id": "transition__enter_batch_queue", + "name": "Lot enters furnace batch queue", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__batch_queue", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const lot = input.WIPQueue[0];\n return lot.layer === 2 || lot.layer === 7 || lot.layer === 14 || lot.layer === 19 || lot.layer === 22 || lot.layer === 26;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.WIPQueue[0];\n return {\n BatchQueue: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: 0 }],\n };\n});", + "x": 105, + "y": 1515 + }, + { + "id": "transition__load_furnace", + "name": "Load lot into furnace batch", + "inputArcs": [ + { + "placeId": "place__batch_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_process", + "weight": 1 + }, + { + "placeId": "place__chambers_available", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.BatchQueue[0];\n const chamber = input.ChambersAvailable[0];\n const qualOk = chamber.qualification === 0\n || (chamber.qualification === 1 && lot.product_type <= 1)\n || (chamber.qualification === 2 && lot.product_type === 2);\n return chamber.machine_group === 2 && qualOk\n && chamber.batch_count < parameters.batch_size;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.BatchQueue[0];\n const chamber = input.ChambersAvailable[0];\n const productFactor = lot.product_type === 0 ? 1.0\n : lot.product_type === 1 ? 0.85 : 1.15;\n return {\n InProcess: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: Distribution.Lognormal(Math.log(parameters.furnace_time * productFactor), parameters.process_sigma), wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: chamber.tool_id * 100 + chamber.chamber_idx, batch_wait: 0 }],\n ChambersAvailable: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: chamber.diffusion_clock, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count + 1 }],\n };\n});", + "x": 660, + "y": 1515 + }, + { + "id": "transition__start_furnace_full", + "name": "Start furnace (batch full)", + "inputArcs": [ + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n return chamber.machine_group === 2\n && chamber.batch_count >= parameters.batch_size;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n return {\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 465, + "y": 1020 + }, + { + "id": "transition__start_furnace_timeout", + "name": "Start furnace (batch timeout)", + "inputArcs": [ + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__in_process", + "weight": 1, + "type": "read" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n const lot = input.InProcess[0];\n return chamber.machine_group === 2\n && chamber.batch_count > 0\n && chamber.batch_count < parameters.batch_size\n && lot.processing_chamber === chamber.tool_id * 100 + chamber.chamber_idx\n && lot.batch_wait >= parameters.batch_timeout;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n return {\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 465, + "y": 1215 + }, + { + "id": "transition__dispatch_inspect", + "name": "Dispatch lot to inspection", + "inputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_inspection", + "weight": 1 + }, + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n return chamber.machine_group === 3 && (lot.layer === 3 || lot.layer === 6 || lot.layer === 11 || lot.layer === 18 || lot.layer === 23);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.WIPQueue[0];\n const chamber = input.ChambersAvailable[0];\n return {\n InInspection: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: Distribution.Lognormal(Math.log(parameters.inspect_time), parameters.process_sigma), wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: chamber.tool_id * 100 + chamber.chamber_idx, batch_wait: lot.batch_wait }],\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 465, + "y": 810 + }, + { + "id": "transition__process_complete_ok", + "name": "Processing complete, chamber ok", + "inputArcs": [ + { + "placeId": "place__in_process", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__post_process", + "weight": 1 + }, + { + "placeId": "place__chambers_available", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n return lot.process_remaining <= 0 && lot.processing_chamber === chamber.tool_id * 100 + chamber.chamber_idx && (chamber.machine_group !== 2 || chamber.batch_count <= 1) && chamber.condition < parameters.maintenance_threshold;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n const defectRate = parameters.base_defect_rate\n * (1 + parameters.condition_sensitivity * chamber.condition)\n * (1 + parameters.particle_sensitivity * chamber.particle_count)\n * (1 + parameters.drift_defect_factor * Math.abs(chamber.process_drift));\n return {\n PostProcess: [{ product_type: lot.product_type, layer: lot.layer + 1, priority: lot.priority, age: lot.age, defect_count: Distribution.Lognormal(Math.log(Math.max(lot.defect_count + defectRate, 0.001)), 0.5), process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias + chamber.process_drift, processing_chamber: -1, batch_wait: lot.batch_wait }],\n ChambersAvailable: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: chamber.diffusion_clock, lots_processed: chamber.lots_processed + 1, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: 0 }],\n };\n});", + "x": 1530, + "y": 690 + }, + { + "id": "transition__furnace_batch_continues", + "name": "Furnace lot completes (batch continues)", + "inputArcs": [ + { + "placeId": "place__in_process", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__post_process", + "weight": 1 + }, + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n return lot.process_remaining <= 0\n && chamber.machine_group === 2\n && chamber.batch_count > 1\n && lot.processing_chamber === chamber.tool_id * 100 + chamber.chamber_idx;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n const defectRate = parameters.base_defect_rate\n * (1 + parameters.condition_sensitivity * chamber.condition)\n * (1 + parameters.particle_sensitivity * chamber.particle_count)\n * (1 + parameters.drift_defect_factor * Math.abs(chamber.process_drift));\n return {\n PostProcess: [{ product_type: lot.product_type, layer: lot.layer + 1, priority: lot.priority, age: lot.age, defect_count: Distribution.Lognormal(Math.log(Math.max(lot.defect_count + defectRate, 0.001)), 0.5), process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias + chamber.process_drift, processing_chamber: -1, batch_wait: lot.batch_wait }],\n ChambersProcessing: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: chamber.diffusion_clock, lots_processed: chamber.lots_processed + 1, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count - 1 }],\n };\n});", + "x": 1755, + "y": 690 + }, + { + "id": "transition__process_complete_maintenance", + "name": "Processing complete, chamber needs maintenance", + "inputArcs": [ + { + "placeId": "place__in_process", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__post_process", + "weight": 1 + }, + { + "placeId": "place__chambers_broken", + "weight": 1 + }, + { + "placeId": "place__maintenance_events", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n return lot.process_remaining <= 0 && lot.processing_chamber === chamber.tool_id * 100 + chamber.chamber_idx && (chamber.machine_group !== 2 || chamber.batch_count <= 1) && chamber.condition >= parameters.maintenance_threshold;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const lot = input.InProcess[0];\n const chamber = input.ChambersProcessing[0];\n const defectRate = parameters.base_defect_rate\n * (1 + parameters.condition_sensitivity * chamber.condition)\n * (1 + parameters.particle_sensitivity * chamber.particle_count)\n * (1 + parameters.drift_defect_factor * Math.abs(chamber.process_drift));\n return {\n PostProcess: [{ product_type: lot.product_type, layer: lot.layer + 1, priority: lot.priority, age: lot.age, defect_count: Distribution.Lognormal(Math.log(Math.max(lot.defect_count + defectRate, 0.001)), 0.5), process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias + chamber.process_drift, processing_chamber: -1, batch_wait: lot.batch_wait }],\n ChambersBroken: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: Distribution.Lognormal(Math.log(parameters.maintenance_duration), parameters.maintenance_sigma), diffusion_clock: 0, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 1530, + "y": 915 + }, + { + "id": "transition__route_to_queue", + "name": "Route lot back to WIP queue", + "inputArcs": [ + { + "placeId": "place__post_process", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const lot = input.PostProcess[0];\n return lot.layer < 28;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.PostProcess[0];\n return {\n WIPQueue: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 2085, + "y": 570 + }, + { + "id": "transition__lot_passes", + "name": "Lot passes final test", + "inputArcs": [ + { + "placeId": "place__post_process", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__finished", + "weight": 1 + }, + { + "placeId": "place__wip_permits", + "weight": 1 + }, + { + "placeId": "place__lots_completed", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.PostProcess[0];\n return lot.layer >= 28 && lot.defect_count < parameters.scrap_threshold;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.PostProcess[0];\n return {\n Finished: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 2080, + "y": 256 + }, + { + "id": "transition__lot_fails", + "name": "Lot fails final test", + "inputArcs": [ + { + "placeId": "place__post_process", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__scrapped", + "weight": 1 + }, + { + "placeId": "place__wip_permits", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const lot = input.PostProcess[0];\n return lot.layer >= 28 && lot.defect_count >= parameters.scrap_threshold;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.PostProcess[0];\n return {\n Scrapped: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 2080, + "y": 772 + }, + { + "id": "transition__inspection_complete", + "name": "Inspection complete", + "inputArcs": [ + { + "placeId": "place__in_inspection", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__wip_queue", + "weight": 1 + }, + { + "placeId": "place__chambers_available", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const lot = input.InInspection[0];\n const chamber = input.ChambersProcessing[0];\n return lot.process_remaining <= 0 && lot.processing_chamber === chamber.tool_id * 100 + chamber.chamber_idx;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const lot = input.InInspection[0];\n const chamber = input.ChambersProcessing[0];\n return {\n WIPQueue: [{ product_type: lot.product_type, layer: lot.layer + 1, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: 0, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: -1, batch_wait: lot.batch_wait }],\n ChambersAvailable: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: chamber.diffusion_clock, lots_processed: chamber.lots_processed + 1, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: 0 }],\n };\n});", + "x": 1185, + "y": 585 + }, + { + "id": "transition__start_maintenance", + "name": "Start preventive maintenance", + "inputArcs": [ + { + "placeId": "place__chambers_available", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__maintenance_crew", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_in_maintenance", + "weight": 1 + }, + { + "placeId": "place__maintenance_events", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n return chamber.condition >= parameters.maintenance_threshold;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersAvailable[0];\n return {\n ChambersInMaintenance: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: Distribution.Lognormal(Math.log(parameters.maintenance_duration), parameters.maintenance_sigma), diffusion_clock: 0, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 1515, + "y": 1395 + }, + { + "id": "transition__maintenance_complete", + "name": "Maintenance complete (drift recalibrated)", + "inputArcs": [ + { + "placeId": "place__chambers_in_maintenance", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_available", + "weight": 1 + }, + { + "placeId": "place__maintenance_crew", + "weight": 1 + }, + { + "placeId": "place__calibrations", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.ChambersInMaintenance[0].maintenance_remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersInMaintenance[0];\n return {\n ChambersAvailable: [{ condition: 0, particle_count: 0.05, hours_since_maintenance: 0, maintenance_remaining: 0, diffusion_clock: chamber.diffusion_clock, lots_processed: 0, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: Distribution.Gaussian(0, parameters.calibration_residual), batch_count: 0 }],\n };\n});", + "x": 2190, + "y": 1515 + }, + { + "id": "transition__breakdown", + "name": "Chamber breakdown", + "inputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__in_process", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__maintenance_crew", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_in_maintenance", + "weight": 1 + }, + { + "placeId": "place__scrapped", + "weight": 1 + }, + { + "placeId": "place__breakdown_events", + "weight": 1 + }, + { + "placeId": "place__wip_permits", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n const chamber = input.ChambersProcessing[0];\n const lot = input.InProcess[0];\n if (chamber.machine_group === 2 || lot.processing_chamber !== chamber.tool_id * 100 + chamber.chamber_idx) {\n return 0;\n }\n return parameters.breakdown_base_rate\n * Math.exp(parameters.breakdown_condition_factor * chamber.condition);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersProcessing[0];\n const lot = input.InProcess[0];\n return {\n ChambersInMaintenance: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: Distribution.Lognormal(Math.log(parameters.breakdown_repair_time), parameters.breakdown_sigma), diffusion_clock: 0, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n Scrapped: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 1305, + "y": 1515 + }, + { + "id": "transition__breakdown_no_crew", + "name": "Chamber breakdown (no crew available)", + "inputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__in_process", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__maintenance_crew", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_broken", + "weight": 1 + }, + { + "placeId": "place__scrapped", + "weight": 1 + }, + { + "placeId": "place__breakdown_events", + "weight": 1 + }, + { + "placeId": "place__wip_permits", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n const chamber = input.ChambersProcessing[0];\n const lot = input.InProcess[0];\n if (chamber.machine_group === 2 || lot.processing_chamber !== chamber.tool_id * 100 + chamber.chamber_idx) {\n return 0;\n }\n return parameters.breakdown_base_rate\n * Math.exp(parameters.breakdown_condition_factor * chamber.condition);\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersProcessing[0];\n const lot = input.InProcess[0];\n return {\n ChambersBroken: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n Scrapped: [{ product_type: lot.product_type, layer: lot.layer, priority: lot.priority, age: lot.age, defect_count: lot.defect_count, process_remaining: lot.process_remaining, wait_time: lot.wait_time, due_date: lot.due_date, process_bias: lot.process_bias, processing_chamber: lot.processing_chamber, batch_wait: lot.batch_wait }],\n };\n});", + "x": 1530, + "y": 1020 + }, + { + "id": "transition__crew_reaches_broken", + "name": "Crew reaches broken chamber", + "inputArcs": [ + { + "placeId": "place__chambers_broken", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__maintenance_crew", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_in_maintenance", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => true);", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersBroken[0];\n return {\n ChambersInMaintenance: [{ condition: chamber.condition, particle_count: chamber.particle_count, hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining > 0 ? chamber.maintenance_remaining : Distribution.Lognormal(Math.log(parameters.breakdown_repair_time), parameters.breakdown_sigma), diffusion_clock: chamber.diffusion_clock, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: chamber.process_drift, batch_count: chamber.batch_count }],\n };\n});", + "x": 2080, + "y": 1256 + }, + { + "id": "transition__drift_diffusion", + "name": "Process noise injection (diffusion step)", + "inputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__chambers_processing", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.ChambersProcessing[0].diffusion_clock <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const chamber = input.ChambersProcessing[0];\n const step = parameters.diffusion_step;\n return {\n ChambersProcessing: [{ condition: chamber.condition, particle_count: Distribution.Gaussian(Math.max(chamber.particle_count, 0), parameters.particle_volatility * Math.sqrt(step)), hours_since_maintenance: chamber.hours_since_maintenance, maintenance_remaining: chamber.maintenance_remaining, diffusion_clock: parameters.diffusion_step, lots_processed: chamber.lots_processed, machine_group: chamber.machine_group, tool_id: chamber.tool_id, chamber_idx: chamber.chamber_idx, qualification: chamber.qualification, process_drift: Distribution.Gaussian(chamber.process_drift, parameters.drift_volatility * Math.sqrt(step)), batch_count: chamber.batch_count }],\n };\n});", + "x": 1530, + "y": 810 + } + ], + "types": [ + { + "id": "type__lot", + "name": "Lot", + "iconSlug": "circle", + "displayColor": "#8b5cf6", + "elements": [ + { + "elementId": "type__lot__product_type", + "name": "product_type", + "type": "integer" + }, + { + "elementId": "type__lot__layer", + "name": "layer", + "type": "integer" + }, + { + "elementId": "type__lot__priority", + "name": "priority", + "type": "real" + }, + { + "elementId": "type__lot__age", + "name": "age", + "type": "real" + }, + { + "elementId": "type__lot__defect_count", + "name": "defect_count", + "type": "real" + }, + { + "elementId": "type__lot__process_remaining", + "name": "process_remaining", + "type": "real" + }, + { + "elementId": "type__lot__wait_time", + "name": "wait_time", + "type": "real" + }, + { + "elementId": "type__lot__due_date", + "name": "due_date", + "type": "real" + }, + { + "elementId": "type__lot__process_bias", + "name": "process_bias", + "type": "real" + }, + { + "elementId": "type__lot__batch_wait", + "name": "batch_wait", + "type": "real" + }, + { + "elementId": "elem__processing_chamber", + "name": "processing_chamber", + "type": "integer" + } + ] + }, + { + "id": "type__chamber", + "name": "Chamber", + "iconSlug": "circle", + "displayColor": "#06b6d4", + "elements": [ + { + "elementId": "type__chamber__condition", + "name": "condition", + "type": "real" + }, + { + "elementId": "type__chamber__particle_count", + "name": "particle_count", + "type": "real" + }, + { + "elementId": "type__chamber__hours_since_maintenance", + "name": "hours_since_maintenance", + "type": "real" + }, + { + "elementId": "type__chamber__maintenance_remaining", + "name": "maintenance_remaining", + "type": "real" + }, + { + "elementId": "type__chamber__diffusion_clock", + "name": "diffusion_clock", + "type": "real" + }, + { + "elementId": "type__chamber__lots_processed", + "name": "lots_processed", + "type": "integer" + }, + { + "elementId": "type__chamber__machine_group", + "name": "machine_group", + "type": "integer" + }, + { + "elementId": "type__chamber__tool_id", + "name": "tool_id", + "type": "integer" + }, + { + "elementId": "type__chamber__chamber_idx", + "name": "chamber_idx", + "type": "integer" + }, + { + "elementId": "type__chamber__qualification", + "name": "qualification", + "type": "integer" + }, + { + "elementId": "type__chamber__process_drift", + "name": "process_drift", + "type": "real" + }, + { + "elementId": "type__chamber__batch_count", + "name": "batch_count", + "type": "integer" + } + ] + } + ], + "differentialEquations": [ + { + "id": "de__aging", + "name": "Lot urgency escalation (+ age, wait clocks)", + "colorId": "type__lot", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((lot) => ({\n priority: lot.priority < parameters.max_priority\n ? (lot.priority * lot.priority) / parameters.target_cycle_time\n : 0,\n age: 1, defect_count: 0, process_remaining: 0, wait_time: 1, due_date: 0, process_bias: 0, batch_wait: 0\n }));\n});" + }, + { + "id": "de__processing", + "name": "Clock: process countdown (+ age)", + "colorId": "type__lot", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({\n priority: 0, age: 1, defect_count: 0, process_remaining: -1, wait_time: 0, due_date: 0, process_bias: 0, batch_wait: 1\n }));\n});" + }, + { + "id": "de__chamber_processing", + "name": "Chamber wear and contamination (coupled)", + "colorId": "type__chamber", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((chamber) => {\n const particleTarget = parameters.particle_baseline\n + parameters.particle_drift * chamber.hours_since_maintenance\n + parameters.particle_condition_factor * chamber.condition;\n return {\n condition: parameters.degradation_rate\n * (1 + chamber.particle_count / parameters.particle_threshold),\n particle_count: parameters.particle_reversion\n * (particleTarget - chamber.particle_count),\n hours_since_maintenance: 1,\n maintenance_remaining: 0,\n diffusion_clock: -1,\n process_drift: -parameters.drift_reversion * chamber.process_drift\n };\n });\n});" + }, + { + "id": "de__chamber_maintenance", + "name": "Clock: maintenance countdown", + "colorId": "type__chamber", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({\n condition: 0, particle_count: 0, hours_since_maintenance: 0, maintenance_remaining: -1, diffusion_clock: 0, process_drift: 0\n }));\n});" + }, + { + "id": "de__batch_wait", + "name": "Lot urgency escalation in batch queue (+ age, wait, batch clocks)", + "colorId": "type__lot", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((lot) => ({\n priority: lot.priority < parameters.max_priority\n ? (lot.priority * lot.priority) / parameters.target_cycle_time\n : 0,\n age: 1, defect_count: 0, process_remaining: 0, wait_time: 1, due_date: 0, process_bias: 0, batch_wait: 1\n }));\n});" + } + ], + "parameters": [ + { + "id": "param__litho_time", + "name": "Litho process time (hours)", + "variableName": "litho_time", + "type": "real", + "defaultValue": "2.0" + }, + { + "id": "param__etch_time", + "name": "Etch process time (hours)", + "variableName": "etch_time", + "type": "real", + "defaultValue": "1.5" + }, + { + "id": "param__furnace_time", + "name": "Furnace process time (hours)", + "variableName": "furnace_time", + "type": "real", + "defaultValue": "5.0" + }, + { + "id": "param__inspect_time", + "name": "Inspection time (hours)", + "variableName": "inspect_time", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__process_sigma", + "name": "Process time lognormal sigma", + "variableName": "process_sigma", + "type": "real", + "defaultValue": "0.25" + }, + { + "id": "param__degradation_rate", + "name": "Chamber degradation rate (per hour)", + "variableName": "degradation_rate", + "type": "real", + "defaultValue": "0.004" + }, + { + "id": "param__maintenance_threshold", + "name": "Condition triggering maintenance", + "variableName": "maintenance_threshold", + "type": "real", + "defaultValue": "0.85" + }, + { + "id": "param__maintenance_duration", + "name": "Maintenance duration median (hours)", + "variableName": "maintenance_duration", + "type": "real", + "defaultValue": "18" + }, + { + "id": "param__maintenance_sigma", + "name": "Maintenance duration sigma", + "variableName": "maintenance_sigma", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param__breakdown_base_rate", + "name": "Breakdown base rate (per hour)", + "variableName": "breakdown_base_rate", + "type": "real", + "defaultValue": "0.0003" + }, + { + "id": "param__breakdown_condition_factor", + "name": "Breakdown exponential factor", + "variableName": "breakdown_condition_factor", + "type": "real", + "defaultValue": "4.0" + }, + { + "id": "param__breakdown_repair_time", + "name": "Breakdown repair median (hours)", + "variableName": "breakdown_repair_time", + "type": "real", + "defaultValue": "36" + }, + { + "id": "param__breakdown_sigma", + "name": "Breakdown repair sigma", + "variableName": "breakdown_sigma", + "type": "real", + "defaultValue": "0.4" + }, + { + "id": "param__base_defect_rate", + "name": "Base defect rate per step", + "variableName": "base_defect_rate", + "type": "real", + "defaultValue": "0.01" + }, + { + "id": "param__condition_sensitivity", + "name": "Defect sensitivity to condition", + "variableName": "condition_sensitivity", + "type": "real", + "defaultValue": "8.0" + }, + { + "id": "param__particle_sensitivity", + "name": "Defect sensitivity to particles", + "variableName": "particle_sensitivity", + "type": "real", + "defaultValue": "5.0" + }, + { + "id": "param__drift_defect_factor", + "name": "Defect sensitivity to process drift", + "variableName": "drift_defect_factor", + "type": "real", + "defaultValue": "3.0" + }, + { + "id": "param__scrap_threshold", + "name": "Cumulative defects causing scrap", + "variableName": "scrap_threshold", + "type": "real", + "defaultValue": "1.5" + }, + { + "id": "param__particle_baseline", + "name": "Particle count baseline", + "variableName": "particle_baseline", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__particle_drift", + "name": "Particle drift per hour since maintenance", + "variableName": "particle_drift", + "type": "real", + "defaultValue": "0.003" + }, + { + "id": "param__particle_reversion", + "name": "Particle OU reversion rate", + "variableName": "particle_reversion", + "type": "real", + "defaultValue": "0.4" + }, + { + "id": "param__particle_volatility", + "name": "Particle OU volatility", + "variableName": "particle_volatility", + "type": "real", + "defaultValue": "0.08" + }, + { + "id": "param__drift_reversion", + "name": "Process drift OU reversion rate", + "variableName": "drift_reversion", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__drift_volatility", + "name": "Process drift OU volatility", + "variableName": "drift_volatility", + "type": "real", + "defaultValue": "0.02" + }, + { + "id": "param__calibration_residual", + "name": "Residual drift sigma after calibration", + "variableName": "calibration_residual", + "type": "real", + "defaultValue": "0.005" + }, + { + "id": "param__diffusion_step", + "name": "Diffusion step interval (hours)", + "variableName": "diffusion_step", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__demand_rate", + "name": "Demand arrival rate (lots/hour)", + "variableName": "demand_rate", + "type": "real", + "defaultValue": "0.12" + }, + { + "id": "param__wip_cap", + "name": "WIP lot cap", + "variableName": "wip_cap", + "type": "integer", + "defaultValue": "50" + }, + { + "id": "param__target_cycle_time", + "name": "Target cycle time (hours)", + "variableName": "target_cycle_time", + "type": "real", + "defaultValue": "180" + }, + { + "id": "param__priority_update_interval", + "name": "Priority recalculation interval (hours)", + "variableName": "priority_update_interval", + "type": "real", + "defaultValue": "2.0" + }, + { + "id": "param__deadline_grace_period", + "name": "Hours past due before renegotiation", + "variableName": "deadline_grace_period", + "type": "real", + "defaultValue": "30" + }, + { + "id": "param__batch_size", + "name": "Furnace batch size", + "variableName": "batch_size", + "type": "integer", + "defaultValue": "4" + }, + { + "id": "param__batch_timeout", + "name": "Batch timeout (hours)", + "variableName": "batch_timeout", + "type": "real", + "defaultValue": "3.0" + }, + { + "id": "param__particle_threshold", + "name": "Particle count that doubles the wear rate", + "variableName": "particle_threshold", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__particle_condition_factor", + "name": "Extra particle equilibrium per unit of chamber wear", + "variableName": "particle_condition_factor", + "type": "real", + "defaultValue": "0.15" + }, + { + "id": "param__max_priority", + "name": "Priority ceiling for continuous escalation", + "variableName": "max_priority", + "type": "real", + "defaultValue": "10" + } + ], + "scenarios": [ + { + "id": "scenario__normal", + "name": "Normal operation", + "description": "16 chambers (4 litho, 6 etch, 4 furnace, 2 inspection) across 7 physical tools. Each chamber drifts independently.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "wip_cap", + "default": 50 + }, + { + "type": "real", + "identifier": "demand_rate", + "default": 0.12 + }, + { + "type": "real", + "identifier": "maintenance_threshold", + "default": 0.85 + } + ], + "parameterOverrides": { + "param__wip_cap": "scenario.wip_cap", + "param__demand_rate": "scenario.demand_rate", + "param__maintenance_threshold": "scenario.maintenance_threshold" + }, + "initialState": { + "type": "code", + "content": "return {\n FabEntrance: [],\n WIPQueue: [\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 }\n ],\n BatchQueue: [],\n InProcess: [],\n PostProcess: [],\n InInspection: [],\n Finished: [],\n Scrapped: [],\n WIPPermits: Math.max(0, parameters.wip_cap - 35),\n ChambersAvailable: [\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.015, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 1, qualification: 0, process_drift: 0.0, batch_count: 0 },\n { condition: 0.32, particle_count: 0.34, hours_since_maintenance: 80.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 0, qualification: 1, process_drift: 0.005, batch_count: 0 },\n { condition: 0.4, particle_count: 0.4, hours_since_maintenance: 100.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 1, qualification: 2, process_drift: 0.01, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: 0.0, batch_count: 0 }\n ],\n ChambersProcessing: [],\n ChambersInMaintenance: [],\n ChambersBroken: [],\n MaintenanceCrew: 3,\n MaintenanceEvents: 0,\n BreakdownEvents: 0,\n LotsReleased: 0,\n LotsCompleted: 0,\n Calibrations: 0,\n};" + } + }, + { + "id": "scenario__high_drift", + "name": "High process drift", + "description": "Drift volatility doubled (0.04 vs 0.02). Chambers diverge faster from nominal, increasing defect rate and yield loss.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "wip_cap", + "default": 50 + }, + { + "type": "real", + "identifier": "demand_rate", + "default": 0.12 + }, + { + "type": "real", + "identifier": "maintenance_threshold", + "default": 0.85 + } + ], + "parameterOverrides": { + "param__wip_cap": "scenario.wip_cap", + "param__demand_rate": "scenario.demand_rate", + "param__maintenance_threshold": "scenario.maintenance_threshold", + "param__drift_volatility": "0.04" + }, + "initialState": { + "type": "code", + "content": "return {\n FabEntrance: [],\n WIPQueue: [\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 }\n ],\n BatchQueue: [],\n InProcess: [],\n PostProcess: [],\n InInspection: [],\n Finished: [],\n Scrapped: [],\n WIPPermits: Math.max(0, parameters.wip_cap - 35),\n ChambersAvailable: [\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.015, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 1, qualification: 0, process_drift: 0.0, batch_count: 0 },\n { condition: 0.32, particle_count: 0.34, hours_since_maintenance: 80.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 0, qualification: 1, process_drift: 0.005, batch_count: 0 },\n { condition: 0.4, particle_count: 0.4, hours_since_maintenance: 100.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 1, qualification: 2, process_drift: 0.01, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: 0.0, batch_count: 0 }\n ],\n ChambersProcessing: [],\n ChambersInMaintenance: [],\n ChambersBroken: [],\n MaintenanceCrew: 3,\n MaintenanceEvents: 0,\n BreakdownEvents: 0,\n LotsReleased: 0,\n LotsCompleted: 0,\n Calibrations: 0,\n};" + } + }, + { + "id": "scenario__frequent_calibration", + "name": "Frequent calibration", + "description": "Maintenance threshold lowered to 0.6. Chambers are serviced more often, keeping drift low but reducing available capacity.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "wip_cap", + "default": 50 + }, + { + "type": "real", + "identifier": "demand_rate", + "default": 0.12 + }, + { + "type": "real", + "identifier": "maintenance_threshold", + "default": 0.6 + } + ], + "parameterOverrides": { + "param__wip_cap": "scenario.wip_cap", + "param__demand_rate": "scenario.demand_rate", + "param__maintenance_threshold": "scenario.maintenance_threshold" + }, + "initialState": { + "type": "code", + "content": "return {\n FabEntrance: [],\n WIPQueue: [\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 }\n ],\n BatchQueue: [],\n InProcess: [],\n PostProcess: [],\n InInspection: [],\n Finished: [],\n Scrapped: [],\n WIPPermits: Math.max(0, parameters.wip_cap - 35),\n ChambersAvailable: [\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.015, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 1, qualification: 0, process_drift: 0.0, batch_count: 0 },\n { condition: 0.32, particle_count: 0.34, hours_since_maintenance: 80.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 0, qualification: 1, process_drift: 0.005, batch_count: 0 },\n { condition: 0.4, particle_count: 0.4, hours_since_maintenance: 100.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 1, qualification: 2, process_drift: 0.01, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: 0.0, batch_count: 0 }\n ],\n ChambersProcessing: [],\n ChambersInMaintenance: [],\n ChambersBroken: [],\n MaintenanceCrew: 3,\n MaintenanceEvents: 0,\n BreakdownEvents: 0,\n LotsReleased: 0,\n LotsCompleted: 0,\n Calibrations: 0,\n};" + } + }, + { + "id": "scenario__half_crew", + "name": "Reduced maintenance crew", + "description": "Only 2 technicians instead of 3. When one chamber of a multi-chamber tool is down, the other keeps running but drift accumulates.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "wip_cap", + "default": 50 + }, + { + "type": "real", + "identifier": "demand_rate", + "default": 0.12 + }, + { + "type": "real", + "identifier": "maintenance_threshold", + "default": 0.85 + } + ], + "parameterOverrides": { + "param__wip_cap": "scenario.wip_cap", + "param__demand_rate": "scenario.demand_rate", + "param__maintenance_threshold": "scenario.maintenance_threshold" + }, + "initialState": { + "type": "code", + "content": "return {\n FabEntrance: [],\n WIPQueue: [\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 21, priority: 1.0, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 7, priority: 1.11, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 14, priority: 1.0, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 21, priority: 1.22, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 14, priority: 1.09, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 21, priority: 1.41, age: 52.5, defect_count: 0.378, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.063, processing_chamber: -1, batch_wait: 0 },\n { product_type: 2, layer: 0, priority: 1.0, age: 0.0, defect_count: 0.0, process_remaining: 0, wait_time: 0, due_date: 250, process_bias: 0.0, processing_chamber: -1, batch_wait: 0 },\n { product_type: 0, layer: 7, priority: 1.0, age: 17.5, defect_count: 0.126, process_remaining: 0, wait_time: 0, due_date: 200, process_bias: 0.021, processing_chamber: -1, batch_wait: 0 },\n { product_type: 1, layer: 14, priority: 1.24, age: 35.0, defect_count: 0.252, process_remaining: 0, wait_time: 0, due_date: 180, process_bias: 0.042, processing_chamber: -1, batch_wait: 0 }\n ],\n BatchQueue: [],\n InProcess: [],\n PostProcess: [],\n InInspection: [],\n Finished: [],\n Scrapped: [],\n WIPPermits: Math.max(0, parameters.wip_cap - 35),\n ChambersAvailable: [\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 0, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.015, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 1, chamber_idx: 1, qualification: 0, process_drift: 0.0, batch_count: 0 },\n { condition: 0.32, particle_count: 0.34, hours_since_maintenance: 80.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 0, qualification: 1, process_drift: 0.005, batch_count: 0 },\n { condition: 0.4, particle_count: 0.4, hours_since_maintenance: 100.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 1, tool_id: 2, chamber_idx: 1, qualification: 2, process_drift: 0.01, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.01, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 0, chamber_idx: 1, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.16, particle_count: 0.22, hours_since_maintenance: 40.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 0, qualification: 1, process_drift: 0.0, batch_count: 0 },\n { condition: 0.24, particle_count: 0.28, hours_since_maintenance: 60.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 2, tool_id: 1, chamber_idx: 1, qualification: 2, process_drift: 0.005, batch_count: 0 },\n { condition: 0.0, particle_count: 0.1, hours_since_maintenance: 0.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 0, chamber_idx: 0, qualification: 0, process_drift: -0.005, batch_count: 0 },\n { condition: 0.08, particle_count: 0.16, hours_since_maintenance: 20.0, maintenance_remaining: 0, diffusion_clock: 0, lots_processed: 0, machine_group: 3, tool_id: 1, chamber_idx: 0, qualification: 0, process_drift: 0.0, batch_count: 0 }\n ],\n ChambersProcessing: [],\n ChambersInMaintenance: [],\n ChambersBroken: [],\n MaintenanceCrew: 2,\n MaintenanceEvents: 0,\n BreakdownEvents: 0,\n LotsReleased: 0,\n LotsCompleted: 0,\n Calibrations: 0,\n};" + } + } + ], + "metrics": [ + { + "id": "metric__throughput", + "name": "Throughput", + "description": "Lots completing all layers.", + "code": "return state.places.Finished.count;" + }, + { + "id": "metric__yield", + "name": "Yield", + "description": "Fraction of exiting lots that pass.", + "code": "const finished = state.places.Finished.count;\nconst scrapped = state.places.Scrapped.count;\nconst total = finished + scrapped;\nreturn total === 0 ? 1 : finished / total;" + }, + { + "id": "metric__avg_cycle_time", + "name": "Average cycle time (hours)", + "description": "Mean age of finished lots.", + "code": "const lots = state.places.Finished.tokens;\nif (lots.length === 0) return 0;\nreturn lots.reduce((sum, lot) => sum + lot.age, 0) / lots.length;" + }, + { + "id": "metric__on_time_delivery", + "name": "On-time delivery rate", + "description": "Fraction of finished lots within original due date.", + "code": "const lots = state.places.Finished.tokens;\nif (lots.length === 0) return 1;\nconst onTime = lots.reduce((n, lot) => lot.age <= lot.due_date ? n + 1 : n, 0);\nreturn onTime / lots.length;" + }, + { + "id": "metric__avg_process_bias", + "name": "Average process bias", + "description": "Mean absolute accumulated drift across finished lots.", + "code": "const lots = state.places.Finished.tokens;\nif (lots.length === 0) return 0;\nreturn lots.reduce((sum, lot) => sum + Math.abs(lot.process_bias), 0) / lots.length;" + }, + { + "id": "metric__max_drift", + "name": "Maximum chamber drift", + "description": "Worst-case absolute process drift across all active chambers.", + "code": "const all = state.places.ChambersAvailable.tokens.concat(state.places.ChambersProcessing.tokens);\nif (all.length === 0) return 0;\nreturn all.reduce((mx, c) => Math.max(mx, Math.abs(c.process_drift)), 0);" + }, + { + "id": "metric__chamber_utilisation", + "name": "Chamber utilisation", + "description": "Fraction of chambers currently processing.", + "code": "const processing = state.places.ChambersProcessing.count;\nconst available = state.places.ChambersAvailable.count;\nconst inMaint = state.places.ChambersInMaintenance.count;\nconst broken = state.places.ChambersBroken.count;\nconst total = processing + available + inMaint + broken;\nreturn total === 0 ? 0 : processing / total;" + }, + { + "id": "metric__maintenance_events", + "name": "Maintenance events", + "description": "Cumulative maintenance starts.", + "code": "return state.places.MaintenanceEvents.count;" + }, + { + "id": "metric__breakdowns", + "name": "Unplanned breakdowns", + "description": "Cumulative breakdowns.", + "code": "return state.places.BreakdownEvents.count;" + }, + { + "id": "metric__calibrations", + "name": "Calibrations", + "description": "Drift recalibrations (completed maintenance).", + "code": "return state.places.Calibrations.count;" + }, + { + "id": "metric__wip_level", + "name": "WIP level", + "description": "Lots currently in the fab.", + "code": "return state.places.WIPQueue.count + state.places.InProcess.count + state.places.InInspection.count + state.places.PostProcess.count + state.places.BatchQueue.count;" + }, + { + "id": "metric__batch_queue_size", + "name": "Batch queue size", + "description": "Lots waiting for furnace batch.", + "code": "return state.places.BatchQueue.count;" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Semiconductor fab \u2014 process drift & multi-chamber tools (v3)" +} diff --git a/apps/petrinaut-website/src/examples/models/truck-fleet-predictive-maintenance.json b/apps/petrinaut-website/src/examples/models/truck-fleet-predictive-maintenance.json new file mode 100644 index 00000000000..f34bdebd96d --- /dev/null +++ b/apps/petrinaut-website/src/examples/models/truck-fleet-predictive-maintenance.json @@ -0,0 +1,2265 @@ +{ + "places": [ + { + "id": "place__load_board", + "name": "LoadBoard", + "colorId": "type__load", + "dynamicsEnabled": true, + "differentialEquationId": "de__waiting_load", + "showAsInitialState": false, + "x": 540, + "y": 1530 + }, + { + "id": "place__available", + "name": "Available", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": true, + "x": 3660, + "y": 675 + }, + { + "id": "place__drivers", + "name": "Drivers", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2685, + "y": 1575 + }, + { + "id": "place__on_route", + "name": "OnRoute", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__driving", + "showAsInitialState": false, + "x": 1035, + "y": 1290 + }, + { + "id": "place__stranded", + "name": "Stranded", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": false, + "x": 1605, + "y": 1395 + }, + { + "id": "place__recovery", + "name": "RecoveryUnits", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2145, + "y": 1620 + }, + { + "id": "place__under_recovery", + "name": "UnderRecovery", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": false, + "x": 2160, + "y": 1395 + }, + { + "id": "place__returning", + "name": "Returning", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__returning", + "showAsInitialState": false, + "x": 1605, + "y": 870 + }, + { + "id": "place__depot_queue", + "name": "DepotQueue", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": false, + "x": 2115, + "y": 645 + }, + { + "id": "place__bays", + "name": "Bays", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2115, + "y": 480 + }, + { + "id": "place__technicians", + "name": "Technicians", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2115, + "y": 285 + }, + { + "id": "place__spares", + "name": "Spares", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": 2115, + "y": 135 + }, + { + "id": "place__in_bay", + "name": "InBay", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__in_bay", + "showAsInitialState": false, + "x": 3135, + "y": 150 + }, + { + "id": "place__needs_repair", + "name": "NeedsRepair", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": false, + "x": 2115, + "y": -15 + }, + { + "id": "place__awaiting_parts", + "name": "AwaitingParts", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__stopped", + "showAsInitialState": false, + "x": 2670, + "y": 150 + }, + { + "id": "place__parts_on_order", + "name": "PartsOnOrder", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2670, + "y": 375 + }, + { + "id": "place__delivered", + "name": "DeliveredLoads", + "colorId": "type__load", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1605, + "y": 1170 + }, + { + "id": "place__late_loads", + "name": "LateLoads", + "colorId": "type__load", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1605, + "y": 1020 + }, + { + "id": "place__dropped_loads", + "name": "DroppedLoads", + "colorId": "type__load", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1605, + "y": 1785 + }, + { + "id": "place__roadside_events", + "name": "RoadsideEvents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 1605, + "y": 1620 + }, + { + "id": "place__services_done", + "name": "ServicesDone", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 3645, + "y": 0 + }, + { + "id": "place__repairs_done", + "name": "RepairsDone", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 3660, + "y": 285 + }, + { + "id": "place__deferred", + "name": "DeferredServices", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2670, + "y": 570 + }, + { + "id": "place__conditions", + "name": "Conditions", + "colorId": "type__conditions", + "dynamicsEnabled": true, + "differentialEquationId": "de__conditions", + "showAsInitialState": true, + "x": 750, + "y": 1080 + }, + { + "id": "place__rest", + "name": "Rest", + "colorId": "type__truck", + "dynamicsEnabled": true, + "differentialEquationId": "de__resting", + "showAsInitialState": false, + "x": 2670, + "y": 915 + }, + { + "id": "place__rest_events", + "name": "RestEvents", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": false, + "x": 2670, + "y": 1065 + } + ], + "transitions": [ + { + "id": "transition__load_motorway", + "name": "A motorway load is offered", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__load_board", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.motorway_rate;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n LoadBoard: [\n {\n route_class: 0,\n distance: 420,\n due: 420 / parameters.average_speed * parameters.due_allowance,\n revenue: 420 * parameters.revenue_per_km,\n age: 0,\n },\n ],\n };\n});", + "x": 300, + "y": 1425 + }, + { + "id": "transition__load_urban", + "name": "A urban load is offered", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__load_board", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.urban_rate;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n LoadBoard: [\n {\n route_class: 1,\n distance: 180,\n due: 180 / parameters.average_speed * parameters.due_allowance,\n revenue: 180 * parameters.revenue_per_km,\n age: 0,\n },\n ],\n };\n});", + "x": 300, + "y": 1530 + }, + { + "id": "transition__load_mountain", + "name": "A mountain load is offered", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__load_board", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return parameters.mountain_rate;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n return {\n LoadBoard: [\n {\n route_class: 2,\n distance: 260,\n due: 260 / parameters.average_speed * parameters.due_allowance,\n revenue: 260 * parameters.revenue_per_km,\n age: 0,\n },\n ],\n };\n});", + "x": 300, + "y": 1635 + }, + { + "id": "transition__dispatch", + "name": "Dispatch a truck", + "inputArcs": [ + { + "placeId": "place__load_board", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__available", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__drivers", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__conditions", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__on_route", + "weight": 1 + }, + { + "placeId": "place__conditions", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.Available[0];\n const load = input.LoadBoard[0];\n const limit = load.route_class === 2\n ? parameters.severe_route_wear_limit\n : parameters.wear_limit;\n const maxWear = Math.max(truck.brake_wear, truck.engine_wear, truck.tyre_wear);\n const estimatedHours = load.distance / (parameters.average_speed * 0.9);\n return maxWear < limit\n && truck.hours_driven + estimatedHours < parameters.max_driving_hours * 1.5;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.Available[0];\n const load = input.LoadBoard[0];\n const cond = input.Conditions[0];\n return {\n OnRoute: [{\n brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: load.distance, route_distance: load.distance, service_remaining: truck.service_remaining, route_class: load.route_class, load_due: truck.age + load.due - load.age, load_revenue: load.revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: cond.severity_mean, speed_factor: cond.speed_mean, conditions_clock: parameters.conditions_step, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: parameters.fuel_per_km\n }],\n Conditions: [{ severity_mean: cond.severity_mean, speed_mean: cond.speed_mean, clock: cond.clock }],\n };\n});", + "x": 765, + "y": 1290 + }, + { + "id": "transition__deliver_on_time", + "name": "Load delivered on time", + "inputArcs": [ + { + "placeId": "place__on_route", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__returning", + "weight": 1 + }, + { + "placeId": "place__delivered", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const truck = input.OnRoute[0];\n return truck.km_remaining <= 0 && truck.age <= truck.load_due;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.OnRoute[0];\n return {\n Returning: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.route_distance, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done + 1, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: parameters.conditions_step, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }],\n DeliveredLoads: [{ route_class: truck.route_class, distance: truck.route_distance, due: truck.load_due, revenue: truck.load_revenue, age: truck.age }],\n };\n});", + "x": 1305, + "y": 1170 + }, + { + "id": "transition__deliver_late", + "name": "Load delivered late", + "inputArcs": [ + { + "placeId": "place__on_route", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__returning", + "weight": 1 + }, + { + "placeId": "place__late_loads", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const truck = input.OnRoute[0];\n return truck.km_remaining <= 0 && truck.age > truck.load_due;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.OnRoute[0];\n return {\n Returning: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.route_distance, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done + 1, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: parameters.conditions_step, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }],\n LateLoads: [{ route_class: truck.route_class, distance: truck.route_distance, due: truck.load_due, revenue: truck.load_revenue, age: truck.age }],\n };\n});", + "x": 1305, + "y": 960 + }, + { + "id": "transition__breakdown", + "name": "Truck fails at the roadside", + "inputArcs": [ + { + "placeId": "place__on_route", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__stranded", + "weight": 1 + }, + { + "placeId": "place__roadside_events", + "weight": 1 + }, + { + "placeId": "place__dropped_loads", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.OnRoute[0];\n const routeSeverity = truck.route_class === 0 ? 1.0\n : truck.route_class === 1 ? 1.8 : 3.2;\n const brakeHazard = 1 + parameters.brake_sensitivity * truck.brake_wear;\n const engineHazard = 1 + parameters.engine_sensitivity * truck.engine_wear;\n const tyreHazard = 1 + parameters.tyre_sensitivity * truck.tyre_wear;\n return parameters.failure_rate * routeSeverity\n * Math.max(brakeHazard, engineHazard, tyreHazard);\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.OnRoute[0];\n return {\n Stranded: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }],\n DroppedLoads: [{ route_class: truck.route_class, distance: truck.route_distance, due: truck.load_due, revenue: truck.load_revenue, age: truck.age }],\n };\n});", + "x": 1305, + "y": 1395 + }, + { + "id": "transition__recover", + "name": "Recovery unit reaches the truck", + "inputArcs": [ + { + "placeId": "place__stranded", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__recovery", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__under_recovery", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / parameters.recovery_response;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.Stranded[0];\n return { UnderRecovery: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 1890, + "y": 1395 + }, + { + "id": "transition__tow_home", + "name": "Truck towed back to the depot", + "inputArcs": [ + { + "placeId": "place__under_recovery", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__needs_repair", + "weight": 1 + }, + { + "placeId": "place__recovery", + "weight": 1 + }, + { + "placeId": "place__drivers", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / parameters.tow_time;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.UnderRecovery[0];\n return { NeedsRepair: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: 0, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2430, + "y": 1395 + }, + { + "id": "transition__start_repair", + "name": "Repair after a breakdown starts", + "inputArcs": [ + { + "placeId": "place__needs_repair", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__bays", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__technicians", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__spares", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_bay", + "weight": 1 + }, + { + "placeId": "place__parts_on_order", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => true);", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.NeedsRepair[0];\n return { InBay: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: Distribution.Lognormal(Math.log(parameters.repair_time), 0.4), route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 1, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2415, + "y": -165 + }, + { + "id": "transition__repair_waits_for_parts", + "name": "Repair waits for a part", + "inputArcs": [ + { + "placeId": "place__needs_repair", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__bays", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__technicians", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__spares", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__awaiting_parts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => true);", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.NeedsRepair[0];\n return { AwaitingParts: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 1, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2415, + "y": 45 + }, + { + "id": "transition__arrive_depot", + "name": "Truck arrives back at the depot", + "inputArcs": [ + { + "placeId": "place__returning", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1 + }, + { + "placeId": "place__drivers", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.Returning[0].km_remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.Returning[0];\n return { DepotQueue: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: 0, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 1875, + "y": 870 + }, + { + "id": "transition__park", + "name": "Truck parks up, no service due", + "inputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__available", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.DepotQueue[0];\n const maxWear = Math.max(truck.brake_wear, truck.engine_wear, truck.tyre_wear);\n return maxWear < parameters.service_wear_limit\n && truck.hours_driven < parameters.max_driving_hours;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.DepotQueue[0];\n return { Available: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2415, + "y": 795 + }, + { + "id": "transition__park_workshop_full", + "name": "Service deferred, workshop full", + "inputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__bays", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__available", + "weight": 1 + }, + { + "placeId": "place__deferred", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.DepotQueue[0];\n const maxWear = Math.max(truck.brake_wear, truck.engine_wear, truck.tyre_wear);\n return maxWear >= parameters.service_wear_limit;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.DepotQueue[0];\n return { Available: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2415, + "y": 645 + }, + { + "id": "transition__into_bay", + "name": "Truck goes into a bay", + "inputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__bays", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__technicians", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__spares", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_bay", + "weight": 1 + }, + { + "placeId": "place__parts_on_order", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.DepotQueue[0];\n const maxWear = Math.max(truck.brake_wear, truck.engine_wear, truck.tyre_wear);\n return maxWear >= parameters.service_wear_limit;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.DepotQueue[0];\n return { InBay: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: Distribution.Lognormal(Math.log(parameters.service_time), 0.3), route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 0, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2400, + "y": 435 + }, + { + "id": "transition__wait_for_parts", + "name": "Truck waits for a part", + "inputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__bays", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__technicians", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__spares", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__awaiting_parts", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n const truck = input.DepotQueue[0];\n const maxWear = Math.max(truck.brake_wear, truck.engine_wear, truck.tyre_wear);\n return maxWear >= parameters.service_wear_limit;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.DepotQueue[0];\n return { AwaitingParts: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 0, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2415, + "y": 270 + }, + { + "id": "transition__parts_arrive", + "name": "Ordered part arrives", + "inputArcs": [ + { + "placeId": "place__parts_on_order", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__spares", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "export default Lambda((input, parameters) => {\n return 1 / parameters.parts_lead_time;\n});", + "transitionKernelCode": "", + "x": 2895, + "y": 375 + }, + { + "id": "transition__fit_part", + "name": "Part fitted", + "inputArcs": [ + { + "placeId": "place__awaiting_parts", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__spares", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__in_bay", + "weight": 1 + }, + { + "placeId": "place__parts_on_order", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda(() => true);", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.AwaitingParts[0];\n const duration = truck.unplanned === 1\n ? Distribution.Lognormal(Math.log(parameters.repair_time), 0.4)\n : Distribution.Lognormal(Math.log(parameters.service_time), 0.3);\n return { InBay: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: duration, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2895, + "y": -90 + }, + { + "id": "transition__service_complete", + "name": "Planned service finished", + "inputArcs": [ + { + "placeId": "place__in_bay", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__available", + "weight": 1 + }, + { + "placeId": "place__bays", + "weight": 1 + }, + { + "placeId": "place__technicians", + "weight": 1 + }, + { + "placeId": "place__services_done", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const truck = input.InBay[0];\n return truck.service_remaining <= 0 && truck.unplanned === 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.InBay[0];\n return { Available: [{ brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 0, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 3375, + "y": 0 + }, + { + "id": "transition__repair_complete", + "name": "Breakdown repair finished", + "inputArcs": [ + { + "placeId": "place__in_bay", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__available", + "weight": 1 + }, + { + "placeId": "place__bays", + "weight": 1 + }, + { + "placeId": "place__technicians", + "weight": 1 + }, + { + "placeId": "place__repairs_done", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n const truck = input.InBay[0];\n return truck.service_remaining <= 0 && truck.unplanned === 1;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.InBay[0];\n return { Available: [{ brake_wear: truck.brake_wear * 0.5, engine_wear: truck.engine_wear * 0.5, tyre_wear: truck.tyre_wear * 0.5, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: 0, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 3375, + "y": 285 + }, + { + "id": "transition__load_expires", + "name": "Load goes to another haulier", + "inputArcs": [ + { + "placeId": "place__load_board", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__dropped_loads", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.LoadBoard[0].age > parameters.board_patience;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const load = input.LoadBoard[0];\n return { DroppedLoads: [{ route_class: load.route_class, distance: load.distance, due: load.due, revenue: load.revenue, age: load.age }] };\n});", + "x": 915, + "y": 1785 + }, + { + "id": "transition__conditions_on_route", + "name": "Road noise injection (OnRoute)", + "inputArcs": [ + { + "placeId": "place__on_route", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__on_route", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.OnRoute[0].conditions_clock <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.OnRoute[0];\n const step = parameters.conditions_step;\n return {\n OnRoute: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: Distribution.Gaussian(truck.road_severity, parameters.severity_volatility * Math.sqrt(step)), speed_factor: Distribution.Gaussian(truck.speed_factor, parameters.speed_volatility * Math.sqrt(step)), conditions_clock: parameters.conditions_step, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }],\n };\n});", + "x": 1305, + "y": 1620 + }, + { + "id": "transition__conditions_returning", + "name": "Road noise injection (Returning)", + "inputArcs": [ + { + "placeId": "place__returning", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__returning", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.Returning[0].conditions_clock <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.Returning[0];\n const step = parameters.conditions_step;\n return {\n Returning: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: Distribution.Gaussian(truck.road_severity, parameters.severity_volatility * Math.sqrt(step)), speed_factor: Distribution.Gaussian(truck.speed_factor, parameters.speed_volatility * Math.sqrt(step)), conditions_clock: parameters.conditions_step, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }],\n };\n});", + "x": 1620, + "y": 705 + }, + { + "id": "transition__env_shift", + "name": "Weather noise injection", + "inputArcs": [ + { + "placeId": "place__conditions", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__conditions", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.Conditions[0].clock <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const cond = input.Conditions[0];\n const step = parameters.env_step;\n return {\n Conditions: [{ severity_mean: Distribution.Gaussian(cond.severity_mean, parameters.env_volatility * Math.sqrt(step)), speed_mean: Distribution.Gaussian(cond.speed_mean, parameters.env_volatility * Math.sqrt(step)), clock: parameters.env_step }],\n };\n});", + "x": 765, + "y": 885 + }, + { + "id": "transition__mandatory_rest", + "name": "Mandatory rest", + "inputArcs": [ + { + "placeId": "place__depot_queue", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__rest", + "weight": 1 + }, + { + "placeId": "place__rest_events", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.DepotQueue[0].hours_driven >= parameters.max_driving_hours;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.DepotQueue[0];\n return {\n Rest: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: Distribution.Lognormal(Math.log(11), 0.15), fuel_rate: truck.fuel_rate }],\n };\n});", + "x": 2415, + "y": 915 + }, + { + "id": "transition__mandatory_rest_available", + "name": "Mandatory rest (parked truck)", + "inputArcs": [ + { + "placeId": "place__available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__rest", + "weight": 1 + }, + { + "placeId": "place__rest_events", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input, parameters) => {\n return input.Available[0].hours_driven >= parameters.max_driving_hours;\n});", + "transitionKernelCode": "export default TransitionKernel((input, parameters) => {\n const truck = input.Available[0];\n return {\n Rest: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: truck.hours_driven, rest_remaining: Distribution.Lognormal(Math.log(11), 0.15), fuel_rate: truck.fuel_rate }],\n };\n});", + "x": 2640, + "y": 915 + }, + { + "id": "transition__rest_complete", + "name": "Rest complete", + "inputArcs": [ + { + "placeId": "place__rest", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__available", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "export default Lambda((input) => {\n return input.Rest[0].rest_remaining <= 0;\n});", + "transitionKernelCode": "export default TransitionKernel((input) => {\n const truck = input.Rest[0];\n return { Available: [{ brake_wear: truck.brake_wear, engine_wear: truck.engine_wear, tyre_wear: truck.tyre_wear, km_remaining: truck.km_remaining, route_distance: truck.route_distance, service_remaining: truck.service_remaining, route_class: truck.route_class, load_due: truck.load_due, load_revenue: truck.load_revenue, age: truck.age, loads_done: truck.loads_done, unplanned: truck.unplanned, road_severity: truck.road_severity, speed_factor: truck.speed_factor, conditions_clock: truck.conditions_clock, fuel_burned: truck.fuel_burned, hours_driven: 0, rest_remaining: truck.rest_remaining, fuel_rate: truck.fuel_rate }] };\n});", + "x": 2895, + "y": 915 + } + ], + "types": [ + { + "id": "type__truck", + "name": "Truck", + "iconSlug": "circle", + "displayColor": "#3b82f6", + "elements": [ + { + "elementId": "type__truck__brake_wear", + "name": "brake_wear", + "type": "real" + }, + { + "elementId": "type__truck__engine_wear", + "name": "engine_wear", + "type": "real" + }, + { + "elementId": "type__truck__tyre_wear", + "name": "tyre_wear", + "type": "real" + }, + { + "elementId": "type__truck__km_remaining", + "name": "km_remaining", + "type": "real" + }, + { + "elementId": "type__truck__route_distance", + "name": "route_distance", + "type": "real" + }, + { + "elementId": "type__truck__service_remaining", + "name": "service_remaining", + "type": "real" + }, + { + "elementId": "type__truck__route_class", + "name": "route_class", + "type": "integer" + }, + { + "elementId": "type__truck__load_due", + "name": "load_due", + "type": "real" + }, + { + "elementId": "type__truck__load_revenue", + "name": "load_revenue", + "type": "real" + }, + { + "elementId": "type__truck__age", + "name": "age", + "type": "real" + }, + { + "elementId": "type__truck__loads_done", + "name": "loads_done", + "type": "integer" + }, + { + "elementId": "type__truck__unplanned", + "name": "unplanned", + "type": "integer" + }, + { + "elementId": "type__truck__road_severity", + "name": "road_severity", + "type": "real" + }, + { + "elementId": "type__truck__speed_factor", + "name": "speed_factor", + "type": "real" + }, + { + "elementId": "type__truck__conditions_clock", + "name": "conditions_clock", + "type": "real" + }, + { + "elementId": "type__truck__fuel_burned", + "name": "fuel_burned", + "type": "real" + }, + { + "elementId": "type__truck__hours_driven", + "name": "hours_driven", + "type": "real" + }, + { + "elementId": "type__truck__rest_remaining", + "name": "rest_remaining", + "type": "real" + }, + { + "elementId": "type__truck__fuel_rate", + "name": "fuel_rate", + "type": "real" + } + ] + }, + { + "id": "type__load", + "name": "Load", + "iconSlug": "circle", + "displayColor": "#f97316", + "elements": [ + { + "elementId": "type__load__route_class", + "name": "route_class", + "type": "integer" + }, + { + "elementId": "type__load__distance", + "name": "distance", + "type": "real" + }, + { + "elementId": "type__load__due", + "name": "due", + "type": "real" + }, + { + "elementId": "type__load__revenue", + "name": "revenue", + "type": "real" + }, + { + "elementId": "type__load__age", + "name": "age", + "type": "real" + } + ] + }, + { + "id": "type__conditions", + "name": "Conditions", + "iconSlug": "circle", + "displayColor": "#10b981", + "elements": [ + { + "elementId": "type__conditions__severity_mean", + "name": "severity_mean", + "type": "real" + }, + { + "elementId": "type__conditions__speed_mean", + "name": "speed_mean", + "type": "real" + }, + { + "elementId": "type__conditions__clock", + "name": "clock", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "de__driving", + "name": "Wear, fuel and road conditions (loaded)", + "colorId": "type__truck", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((truck) => {\n const speed = parameters.average_speed * truck.speed_factor;\n const severity = truck.road_severity;\n const brakeRoute = truck.route_class === 2 ? 2.5 : truck.route_class === 1 ? 1.4 : 1.0;\n const tyreRoute = truck.route_class === 2 ? 1.6 : 1.0;\n return {\n brake_wear: parameters.brake_wear_per_km * speed * severity * brakeRoute\n * (1 + parameters.wear_feedback * truck.brake_wear),\n engine_wear: parameters.engine_wear_per_km * speed * severity * 1.2\n * (1 + parameters.wear_feedback * truck.engine_wear),\n tyre_wear: parameters.tyre_wear_per_km * speed * severity * tyreRoute\n * (1 + parameters.wear_feedback * truck.tyre_wear),\n km_remaining: -speed,\n route_distance: 0, service_remaining: 0, load_due: 0, load_revenue: 0, age: 1,\n road_severity: parameters.severity_reversion\n * (parameters.base_severity_mean - truck.road_severity),\n speed_factor: parameters.speed_reversion\n * (parameters.base_speed_mean - truck.speed_factor),\n conditions_clock: -1,\n fuel_burned: parameters.fuel_per_km * speed * severity\n * (truck.route_class === 2 ? 1.4 : 1.0),\n hours_driven: 1, rest_remaining: 0, fuel_rate: 0\n };\n });\n});" + }, + { + "id": "de__returning", + "name": "Wear, fuel and road conditions (running back empty)", + "colorId": "type__truck", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((truck) => {\n const speed = parameters.average_speed * truck.speed_factor;\n const severity = truck.road_severity;\n const brakeRoute = truck.route_class === 2 ? 2.5 : truck.route_class === 1 ? 1.4 : 1.0;\n const tyreRoute = truck.route_class === 2 ? 1.6 : 1.0;\n return {\n brake_wear: parameters.brake_wear_per_km * speed * severity * brakeRoute * 0.7\n * (1 + parameters.wear_feedback * truck.brake_wear),\n engine_wear: parameters.engine_wear_per_km * speed * severity * 0.7\n * (1 + parameters.wear_feedback * truck.engine_wear),\n tyre_wear: parameters.tyre_wear_per_km * speed * severity * tyreRoute * 0.7\n * (1 + parameters.wear_feedback * truck.tyre_wear),\n km_remaining: -speed,\n route_distance: 0, service_remaining: 0, load_due: 0, load_revenue: 0, age: 1,\n road_severity: parameters.severity_reversion\n * (parameters.base_severity_mean - truck.road_severity),\n speed_factor: parameters.speed_reversion\n * (parameters.base_speed_mean - truck.speed_factor),\n conditions_clock: -1,\n fuel_burned: parameters.fuel_per_km * speed * severity\n * (truck.route_class === 2 ? 1.4 : 1.0) * 0.8,\n hours_driven: 1, rest_remaining: 0, fuel_rate: 0\n };\n });\n});" + }, + { + "id": "de__stopped", + "name": "Clock: standing at the depot (age only)", + "colorId": "type__truck", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, load_due: 0, load_revenue: 0, age: 1, road_severity: 0, speed_factor: 0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0 }));\n});" + }, + { + "id": "de__in_bay", + "name": "Clock: service countdown", + "colorId": "type__truck", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: -1, load_due: 0, load_revenue: 0, age: 1, road_severity: 0, speed_factor: 0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0 }));\n});" + }, + { + "id": "de__resting", + "name": "Clock: driver rest countdown", + "colorId": "type__truck", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, load_due: 0, load_revenue: 0, age: 1, road_severity: 0, speed_factor: 0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: -1, fuel_rate: 0 }));\n});" + }, + { + "id": "de__waiting_load", + "name": "Clock: load ageing on the board", + "colorId": "type__load", + "code": "export default Dynamics((tokens) => {\n return tokens.map(() => ({ distance: 0, due: 0, revenue: 0, age: 1 }));\n});" + }, + { + "id": "de__conditions", + "name": "Regional weather drift (+ resample clock)", + "colorId": "type__conditions", + "code": "export default Dynamics((tokens, parameters) => {\n return tokens.map((cond) => ({\n severity_mean: parameters.env_reversion\n * (parameters.base_severity_mean - cond.severity_mean),\n speed_mean: parameters.env_reversion\n * (parameters.base_speed_mean - cond.speed_mean),\n clock: -1\n }));\n});" + } + ], + "parameters": [ + { + "id": "param__average_speed", + "name": "Average speed (km per hour)", + "variableName": "average_speed", + "type": "real", + "defaultValue": "62" + }, + { + "id": "param__motorway_rate", + "name": "Motorway loads offered (per hour)", + "variableName": "motorway_rate", + "type": "real", + "defaultValue": "0.07" + }, + { + "id": "param__urban_rate", + "name": "Urban loads offered (per hour)", + "variableName": "urban_rate", + "type": "real", + "defaultValue": "0.095" + }, + { + "id": "param__mountain_rate", + "name": "Mountain loads offered (per hour)", + "variableName": "mountain_rate", + "type": "real", + "defaultValue": "0.048" + }, + { + "id": "param__due_allowance", + "name": "Delivery window as a multiple of driving time", + "variableName": "due_allowance", + "type": "real", + "defaultValue": "2.2" + }, + { + "id": "param__revenue_per_km", + "name": "Revenue per km", + "variableName": "revenue_per_km", + "type": "real", + "defaultValue": "1.4" + }, + { + "id": "param__board_patience", + "name": "Hours a load stays on the board", + "variableName": "board_patience", + "type": "real", + "defaultValue": "10" + }, + { + "id": "param__brake_wear_per_km", + "name": "Brake wear per km", + "variableName": "brake_wear_per_km", + "type": "real", + "defaultValue": "0.00012" + }, + { + "id": "param__engine_wear_per_km", + "name": "Engine wear per km", + "variableName": "engine_wear_per_km", + "type": "real", + "defaultValue": "0.0001" + }, + { + "id": "param__tyre_wear_per_km", + "name": "Tyre wear per km", + "variableName": "tyre_wear_per_km", + "type": "real", + "defaultValue": "0.00014" + }, + { + "id": "param__failure_rate", + "name": "Roadside failure rate for a new truck (per hour)", + "variableName": "failure_rate", + "type": "real", + "defaultValue": "0.00035" + }, + { + "id": "param__brake_sensitivity", + "name": "How much brake wear multiplies the failure rate", + "variableName": "brake_sensitivity", + "type": "real", + "defaultValue": "50" + }, + { + "id": "param__engine_sensitivity", + "name": "How much engine wear multiplies the failure rate", + "variableName": "engine_sensitivity", + "type": "real", + "defaultValue": "70" + }, + { + "id": "param__tyre_sensitivity", + "name": "How much tyre wear multiplies the failure rate", + "variableName": "tyre_sensitivity", + "type": "real", + "defaultValue": "40" + }, + { + "id": "param__service_wear_limit", + "name": "Wear level that sends a truck into a bay", + "variableName": "service_wear_limit", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__wear_limit", + "name": "Wear a truck may carry onto an ordinary route", + "variableName": "wear_limit", + "type": "real", + "defaultValue": "9" + }, + { + "id": "param__severe_route_wear_limit", + "name": "Wear a truck may carry onto a mountain route", + "variableName": "severe_route_wear_limit", + "type": "real", + "defaultValue": "9" + }, + { + "id": "param__service_time", + "name": "Service duration (hours)", + "variableName": "service_time", + "type": "real", + "defaultValue": "5" + }, + { + "id": "param__parts_lead_time", + "name": "Parts lead time (hours)", + "variableName": "parts_lead_time", + "type": "real", + "defaultValue": "20" + }, + { + "id": "param__recovery_response", + "name": "Recovery response time (hours)", + "variableName": "recovery_response", + "type": "real", + "defaultValue": "2.5" + }, + { + "id": "param__tow_time", + "name": "Tow time (hours)", + "variableName": "tow_time", + "type": "real", + "defaultValue": "3" + }, + { + "id": "param__repair_time", + "name": "Repair after a breakdown (hours)", + "variableName": "repair_time", + "type": "real", + "defaultValue": "12" + }, + { + "id": "param__fuel_per_km", + "name": "Base fuel consumption (litres per km)", + "variableName": "fuel_per_km", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param__max_driving_hours", + "name": "Maximum driving hours before rest", + "variableName": "max_driving_hours", + "type": "real", + "defaultValue": "9" + }, + { + "id": "param__conditions_step", + "name": "Per-truck diffusion step (hours)", + "variableName": "conditions_step", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__severity_reversion", + "name": "Road severity OU reversion rate", + "variableName": "severity_reversion", + "type": "real", + "defaultValue": "0.8" + }, + { + "id": "param__severity_volatility", + "name": "Road severity OU volatility", + "variableName": "severity_volatility", + "type": "real", + "defaultValue": "0.15" + }, + { + "id": "param__speed_reversion", + "name": "Speed factor OU reversion rate", + "variableName": "speed_reversion", + "type": "real", + "defaultValue": "0.6" + }, + { + "id": "param__speed_volatility", + "name": "Speed factor OU volatility", + "variableName": "speed_volatility", + "type": "real", + "defaultValue": "0.08" + }, + { + "id": "param__env_step", + "name": "Global environment shift interval (hours)", + "variableName": "env_step", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param__env_reversion", + "name": "Global environment OU reversion rate", + "variableName": "env_reversion", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__env_volatility", + "name": "Global environment OU volatility", + "variableName": "env_volatility", + "type": "real", + "defaultValue": "0.05" + }, + { + "id": "param__base_severity_mean", + "name": "Long-run severity mean", + "variableName": "base_severity_mean", + "type": "real", + "defaultValue": "1.0" + }, + { + "id": "param__base_speed_mean", + "name": "Long-run speed mean", + "variableName": "base_speed_mean", + "type": "real", + "defaultValue": "1.0" + }, + { + "id": "param__fuel_cost_per_unit", + "name": "Fuel cost per litre", + "variableName": "fuel_cost_per_unit", + "type": "real", + "defaultValue": "1.5" + }, + { + "id": "param__repair_cost", + "name": "Cost per breakdown repair", + "variableName": "repair_cost", + "type": "real", + "defaultValue": "3500" + }, + { + "id": "param__service_cost", + "name": "Cost per planned service", + "variableName": "service_cost", + "type": "real", + "defaultValue": "800" + }, + { + "id": "param__rest_penalty", + "name": "Penalty per mandatory rest event", + "variableName": "rest_penalty", + "type": "real", + "defaultValue": "150" + }, + { + "id": "param__late_penalty_fraction", + "name": "Revenue lost on late delivery", + "variableName": "late_penalty_fraction", + "type": "real", + "defaultValue": "0.3" + }, + { + "id": "param__wear_feedback", + "name": "How much wear already carried accelerates further wear", + "variableName": "wear_feedback", + "type": "real", + "defaultValue": "0.6" + } + ], + "scenarios": [ + { + "id": "scenario__run_to_failure", + "name": "Run to failure", + "description": "Nothing is serviced on condition: trucks are only ever repaired after they fail.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 9 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__condition_based", + "name": "Condition-based servicing", + "description": "Trucks come in when any component's wear reaches the threshold. Everything else is identical to the baseline.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__early_servicing", + "name": "Servicing too early", + "description": "The same rule at a quarter of full wear. Fewer breakdowns, but the fleet spends its life in the workshop.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.25 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__route_restriction", + "name": "Condition-based, worn trucks off mountain work", + "description": "Condition-based servicing plus a dispatch rule: a truck past a third of full wear is not sent on mountain routes.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "severe_route_wear_limit", + "default": 0.35 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__severe_route_wear_limit": "scenario.severe_route_wear_limit" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__single_bay", + "name": "Condition-based, one bay", + "description": "Condition-based servicing with the second bay closed. Planned services and breakdown repairs compete for one bay.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 1 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__thin_spares", + "name": "Condition-based, one part on the shelf", + "description": "Condition-based servicing with a single spare and a long parts lead time.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 1 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "parts_lead_time", + "default": 72 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__parts_lead_time": "scenario.parts_lead_time" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__winter", + "name": "Winter conditions", + "description": "Icy roads, slower speeds, more wear. Severity mean 1.4, speed mean 0.8.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "base_severity_mean", + "default": 1.4 + }, + { + "type": "real", + "identifier": "base_speed_mean", + "default": 0.8 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__base_severity_mean": "scenario.base_severity_mean", + "param__base_speed_mean": "scenario.base_speed_mean" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: scenario.base_severity_mean,\n speed_factor: scenario.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: scenario.base_severity_mean, speed_mean: scenario.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__summer", + "name": "Summer baseline", + "description": "Dry roads, faster speeds. Severity mean 0.9, speed mean 1.1.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "base_severity_mean", + "default": 0.9 + }, + { + "type": "real", + "identifier": "base_speed_mean", + "default": 1.1 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__base_severity_mean": "scenario.base_severity_mean", + "param__base_speed_mean": "scenario.base_speed_mean" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: scenario.base_severity_mean,\n speed_factor: scenario.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: scenario.base_severity_mean, speed_mean: scenario.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__demand_surge", + "name": "Demand surge", + "description": "Load arrival rates multiplied by 1.5.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "motorway_rate", + "default": 0.105 + }, + { + "type": "real", + "identifier": "urban_rate", + "default": 0.1425 + }, + { + "type": "real", + "identifier": "mountain_rate", + "default": 0.072 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__motorway_rate": "scenario.motorway_rate", + "param__urban_rate": "scenario.urban_rate", + "param__mountain_rate": "scenario.mountain_rate" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: parameters.base_severity_mean,\n speed_factor: parameters.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: parameters.base_severity_mean, speed_mean: parameters.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__winter_surge", + "name": "Winter + demand surge", + "description": "The compound scenario: bad weather and high demand together.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "base_severity_mean", + "default": 1.4 + }, + { + "type": "real", + "identifier": "base_speed_mean", + "default": 0.8 + }, + { + "type": "real", + "identifier": "motorway_rate", + "default": 0.105 + }, + { + "type": "real", + "identifier": "urban_rate", + "default": 0.1425 + }, + { + "type": "real", + "identifier": "mountain_rate", + "default": 0.072 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__base_severity_mean": "scenario.base_severity_mean", + "param__base_speed_mean": "scenario.base_speed_mean", + "param__motorway_rate": "scenario.motorway_rate", + "param__urban_rate": "scenario.urban_rate", + "param__mountain_rate": "scenario.mountain_rate" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: scenario.base_severity_mean,\n speed_factor: scenario.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: scenario.base_severity_mean, speed_mean: scenario.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + }, + { + "id": "scenario__route_aware_winter", + "name": "Route-aware dispatch + winter", + "description": "Tests whether the route restriction rule becomes more valuable in bad weather. Mountain wear limit 0.35, winter conditions.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "trucks", + "default": 8 + }, + { + "type": "integer", + "identifier": "drivers", + "default": 8 + }, + { + "type": "integer", + "identifier": "bays", + "default": 2 + }, + { + "type": "integer", + "identifier": "technicians", + "default": 2 + }, + { + "type": "integer", + "identifier": "spares", + "default": 10 + }, + { + "type": "integer", + "identifier": "recovery_units", + "default": 2 + }, + { + "type": "real", + "identifier": "service_wear_limit", + "default": 0.5 + }, + { + "type": "real", + "identifier": "severe_route_wear_limit", + "default": 0.35 + }, + { + "type": "real", + "identifier": "base_severity_mean", + "default": 1.4 + }, + { + "type": "real", + "identifier": "base_speed_mean", + "default": 0.8 + } + ], + "parameterOverrides": { + "param__service_wear_limit": "scenario.service_wear_limit", + "param__severe_route_wear_limit": "scenario.severe_route_wear_limit", + "param__base_severity_mean": "scenario.base_severity_mean", + "param__base_speed_mean": "scenario.base_speed_mean" + }, + "initialState": { + "type": "code", + "content": "const newTruck = {\n brake_wear: 0, engine_wear: 0, tyre_wear: 0, km_remaining: 0, route_distance: 0, service_remaining: 0, route_class: 0, load_due: 0, load_revenue: 0, age: 0, loads_done: 0, unplanned: 0, road_severity: 1.0, speed_factor: 1.0, conditions_clock: 0, fuel_burned: 0, hours_driven: 0, rest_remaining: 0, fuel_rate: 0\n };\n const fleet = [];\n for (let index = 0; index < scenario.trucks; index += 1) {\n fleet.push({ ...newTruck,\n brake_wear: (index / scenario.trucks) * 0.1,\n engine_wear: (index / scenario.trucks) * 0.08,\n tyre_wear: (index / scenario.trucks) * 0.12,\n road_severity: scenario.base_severity_mean,\n speed_factor: scenario.base_speed_mean,\n });\n }\n return {\n Available: fleet,\n LoadBoard: [],\n Drivers: scenario.drivers,\n Bays: scenario.bays,\n Technicians: scenario.technicians,\n Spares: scenario.spares,\n RecoveryUnits: scenario.recovery_units,\n Conditions: [{ severity_mean: scenario.base_severity_mean, speed_mean: scenario.base_speed_mean, clock: 0 }],\n Rest: [],\n RestEvents: 0,\n };" + } + } + ], + "metrics": [ + { + "id": "metric__loads_delivered", + "name": "Loads delivered on time", + "description": "Loads that reached the customer inside the window.", + "code": "return state.places.DeliveredLoads.count;" + }, + { + "id": "metric__loads_late", + "name": "Loads delivered late", + "description": "Loads that arrived outside the window.", + "code": "return state.places.LateLoads.count;" + }, + { + "id": "metric__loads_dropped", + "name": "Loads dropped", + "description": "Loads nobody collected plus loads lost to a breakdown.", + "code": "return state.places.DroppedLoads.count;" + }, + { + "id": "metric__service_level", + "name": "Service level", + "description": "Share of offered loads delivered on time.", + "code": "const delivered = state.places.DeliveredLoads.count;\nconst late = state.places.LateLoads.count;\nconst dropped = state.places.DroppedLoads.count;\nconst offered = delivered + late + dropped;\nreturn offered === 0 ? 1 : delivered / offered;" + }, + { + "id": "metric__revenue", + "name": "Revenue", + "description": "Revenue from delivered loads. Late loads are paid at a discount.", + "code": "const onTime = state.places.DeliveredLoads.tokens.reduce(\n (total, load) => total + load.revenue, 0);\nconst late = state.places.LateLoads.tokens.reduce(\n (total, load) => total + load.revenue * (1 - parameters.late_penalty_fraction), 0);\nreturn onTime + late;" + }, + { + "id": "metric__total_fuel", + "name": "Total fuel burned", + "description": "Litres consumed across the fleet.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nreturn fleet.reduce((total, truck) => total + truck.fuel_burned, 0);" + }, + { + "id": "metric__operating_cost", + "name": "Operating cost", + "description": "Fuel cost plus repairs and services.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nconst fuel = fleet.reduce((total, truck) => total + truck.fuel_burned, 0) * parameters.fuel_cost_per_unit;\nconst repairs = state.places.RepairsDone.count * parameters.repair_cost;\nconst services = state.places.ServicesDone.count * parameters.service_cost;\nconst rest = state.places.RestEvents.count * parameters.rest_penalty;\nreturn fuel + repairs + services + rest;" + }, + { + "id": "metric__profit", + "name": "Profit", + "description": "Revenue minus operating cost minus late delivery penalties.", + "code": "const onTime = state.places.DeliveredLoads.tokens.reduce(\n (total, load) => total + load.revenue, 0);\nconst late = state.places.LateLoads.tokens.reduce(\n (total, load) => total + load.revenue * (1 - parameters.late_penalty_fraction), 0);\nconst revenue = onTime + late;\nconst fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nconst fuel = fleet.reduce((total, truck) => total + truck.fuel_burned, 0) * parameters.fuel_cost_per_unit;\nconst repairs = state.places.RepairsDone.count * parameters.repair_cost;\nconst services = state.places.ServicesDone.count * parameters.service_cost;\nconst rest = state.places.RestEvents.count * parameters.rest_penalty;\nreturn revenue - fuel - repairs - services - rest;" + }, + { + "id": "metric__roadside_failures", + "name": "Roadside failures", + "description": "Breakdowns away from the depot.", + "code": "return state.places.RoadsideEvents.count;" + }, + { + "id": "metric__services", + "name": "Planned services", + "description": "Trucks brought in on the wear rule and returned to as-new condition.", + "code": "return state.places.ServicesDone.count;" + }, + { + "id": "metric__repairs", + "name": "Unplanned repairs", + "description": "Trucks repaired after a breakdown.", + "code": "return state.places.RepairsDone.count;" + }, + { + "id": "metric__deferred_services", + "name": "Services deferred", + "description": "Times a truck was due for a service, found the workshop full.", + "code": "return state.places.DeferredServices.count;" + }, + { + "id": "metric__rest_events", + "name": "Driver rest events", + "description": "Times a truck was sent to mandatory rest.", + "code": "return state.places.RestEvents.count;" + }, + { + "id": "metric__trucks_earning", + "name": "Trucks earning", + "description": "Trucks on a route right now.", + "code": "return state.places.OnRoute.count;" + }, + { + "id": "metric__trucks_off_road", + "name": "Trucks off the road", + "description": "Trucks in a bay, waiting for a part, under recovery, or resting.", + "code": "return state.places.InBay.count + state.places.AwaitingParts.count + state.places.Stranded.count + state.places.UnderRecovery.count + state.places.Rest.count + state.places.NeedsRepair.count;" + }, + { + "id": "metric__fleet_utilisation", + "name": "Fleet utilisation", + "description": "Fraction of trucks on route or returning vs the total fleet.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nif (fleet.length === 0) return 0;\nconst earning = state.places.OnRoute.count + state.places.Returning.count;\nreturn earning / fleet.length;" + }, + { + "id": "metric__avg_brake_wear", + "name": "Average brake wear", + "description": "Mean brake wear across the fleet.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nif (fleet.length === 0) return 0;\nreturn fleet.reduce((t, tr) => t + tr.brake_wear, 0) / fleet.length;" + }, + { + "id": "metric__avg_engine_wear", + "name": "Average engine wear", + "description": "Mean engine wear across the fleet.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nif (fleet.length === 0) return 0;\nreturn fleet.reduce((t, tr) => t + tr.engine_wear, 0) / fleet.length;" + }, + { + "id": "metric__avg_tyre_wear", + "name": "Average tyre wear", + "description": "Mean tyre wear across the fleet.", + "code": "const fleet = state.places.Available.tokens.concat(state.places.OnRoute.tokens.concat(state.places.Returning.tokens.concat(state.places.DepotQueue.tokens.concat(state.places.InBay.tokens.concat(state.places.AwaitingParts.tokens.concat(state.places.Stranded.tokens.concat(state.places.UnderRecovery.tokens.concat(state.places.Rest.tokens.concat(state.places.NeedsRepair.tokens)))))))));\nif (fleet.length === 0) return 0;\nreturn fleet.reduce((t, tr) => t + tr.tyre_wear, 0) / fleet.length;" + } + ], + "subnets": [], + "componentInstances": [], + "version": 1, + "meta": { + "generator": "Petrinaut" + }, + "title": "Truck fleet with condition-based maintenance (v4)" +} diff --git a/apps/petrinaut-website/src/examples/normalize-example.test.ts b/apps/petrinaut-website/src/examples/normalize-example.test.ts new file mode 100644 index 00000000000..592bc4c2751 --- /dev/null +++ b/apps/petrinaut-website/src/examples/normalize-example.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { parseSDCPNFile } from "@hashintel/petrinaut-core"; + +import { normalizeExampleDefinition } from "./normalize-example"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; + +const loadModel = async (slug: string): Promise => { + const module = (await import(`./models/${slug}.json`)) as { + default: unknown; + }; + const parsed = parseSDCPNFile(module.default); + if (!parsed.ok) { + throw new Error(parsed.error); + } + const { title: _title, ...definition } = parsed.sdcpn; + return definition; +}; + +describe("normalizeExampleDefinition", () => { + it("rewrites the truck-fleet scenario initial states", async () => { + const slug = "truck-fleet-predictive-maintenance"; + const definition = await loadModel(slug); + const normalized = normalizeExampleDefinition(slug, definition); + + const codeScenarios = (normalized.scenarios ?? []).filter( + (scenario) => scenario.initialState.type === "code", + ); + expect(codeScenarios.length).toBeGreaterThan(0); + + // The content sniffing must actually have fired for the real model: the + // imperative loop is gone and the range() rewrite is in its place. If the + // source model changes shape, this fails here instead of as an opaque + // scenario-compilation error in the artifact-generation script. + for (const scenario of codeScenarios) { + const content = + scenario.initialState.type === "code" + ? scenario.initialState.content + : ""; + expect(content).not.toContain("for (let index = 0;"); + expect(content).toContain("range(scenario.trucks)"); + expect(content).toMatch(/(scenario|parameters)\.base_severity_mean/); + } + }); + + it("initialises every attribute of the types it rewrites", async () => { + // The rewrite hardcodes the attributes of `Truck` and `Conditions`. A field + // added to either type would otherwise fall back to the type default and + // simulate silently wrong, because the sniff still fires and the generator + // stays green. + const slug = "truck-fleet-predictive-maintenance"; + const definition = await loadModel(slug); + const normalized = normalizeExampleDefinition(slug, definition); + + const rewritten = (normalized.scenarios ?? []) + .map((scenario) => + scenario.initialState.type === "code" + ? scenario.initialState.content + : "", + ) + .join("\n"); + + for (const typeName of ["Truck", "Conditions"]) { + const colourType = definition.types.find( + (candidate) => candidate.name === typeName, + ); + expect(colourType, `${typeName} is missing from the model`).toBeDefined(); + for (const element of colourType?.elements ?? []) { + // Anchored on a word boundary so `clock:` is not satisfied by + // `conditions_clock:`. + expect( + rewritten, + `${typeName}.${element.name} is not initialised by the rewrite`, + ).toMatch(new RegExp(`\\b${element.name}:`, "u")); + } + } + }); + + it("passes other examples through unchanged", async () => { + const slug = "gases-1-pn"; + const definition = await loadModel(slug); + expect(normalizeExampleDefinition(slug, definition)).toBe(definition); + }); +}); diff --git a/apps/petrinaut-website/src/examples/normalize-example.ts b/apps/petrinaut-website/src/examples/normalize-example.ts new file mode 100644 index 00000000000..ee0d375bdfb --- /dev/null +++ b/apps/petrinaut-website/src/examples/normalize-example.ts @@ -0,0 +1,83 @@ +import type { SDCPN } from "@hashintel/petrinaut-core"; + +const TRUCK_FLEET_SLUG = "truck-fleet-predictive-maintenance"; + +const buildTruckFleetInitialState = ( + meanSource: "parameters" | "scenario", +): string => `const fleet = range(scenario.trucks).map((index) => ({ + brake_wear: (index / scenario.trucks) * 0.1, + engine_wear: (index / scenario.trucks) * 0.08, + tyre_wear: (index / scenario.trucks) * 0.12, + km_remaining: 0, + route_distance: 0, + service_remaining: 0, + route_class: 0, + load_due: 0, + load_revenue: 0, + age: 0, + loads_done: 0, + unplanned: 0, + road_severity: ${meanSource}.base_severity_mean, + speed_factor: ${meanSource}.base_speed_mean, + conditions_clock: 0, + fuel_burned: 0, + hours_driven: 0, + rest_remaining: 0, + fuel_rate: 0, + })); + return { + Available: fleet, + LoadBoard: [], + Drivers: scenario.drivers, + Bays: scenario.bays, + Technicians: scenario.technicians, + Spares: scenario.spares, + RecoveryUnits: scenario.recovery_units, + Conditions: [{ severity_mean: ${meanSource}.base_severity_mean, speed_mean: ${meanSource}.base_speed_mean, clock: 0 }], + Rest: [], + RestEvents: 0, + };`; + +/** + * Applies the small, reviewed compatibility transformations needed by the + * published examples. Source files stay byte-for-byte identical to the files + * supplied for FE-1500; both the canonical route and generated embed artifacts + * consume this normalized definition. + */ +export const normalizeExampleDefinition = ( + slug: string, + definition: SDCPN, +): SDCPN => { + if (slug !== TRUCK_FLEET_SLUG) { + return definition; + } + + return { + ...definition, + scenarios: definition.scenarios?.map((scenario) => { + if (scenario.initialState.type !== "code") { + return scenario; + } + + const content = scenario.initialState.content; + if ( + !content.includes("for (let index = 0;") || + !content.includes("...newTruck") + ) { + return scenario; + } + + const meanSource = content.includes("scenario.base_severity_mean") + ? "scenario" + : "parameters"; + + return { + ...scenario, + initialState: { + type: "code", + content: buildTruckFleetInitialState(meanSource), + }, + }; + }), + }; +}; diff --git a/apps/petrinaut-website/turbo.json b/apps/petrinaut-website/turbo.json index b7287618b3a..86914e4360a 100644 --- a/apps/petrinaut-website/turbo.json +++ b/apps/petrinaut-website/turbo.json @@ -2,7 +2,7 @@ "extends": ["//"], "tasks": { "build": { - "dependsOn": ["codegen", "^build"], + "dependsOn": ["codegen", "examples:generate", "^build"], "outputs": ["dist/**"], // `vite.config.ts` inlines these into the bundle through `define`, so a // cached `dist` from one environment must never be restored for another. @@ -13,6 +13,26 @@ }, "codegen": { "outputs": ["src/routeTree.gen.ts"] + }, + "examples:generate": { + "dependsOn": ["^build"], + "inputs": [ + "scripts/generate-example-artifacts.ts", + "src/examples/catalog-metadata.ts", + "src/examples/models/**", + "src/examples/normalize-example.ts" + ], + "outputs": ["src/examples/generated/**"] + }, + "lint:tsc": { + "dependsOn": ["codegen", "examples:generate", "^build"] + }, + "test:unit": { + "dependsOn": ["codegen", "examples:generate", "^build"], + // Restated because a package task definition replaces the root one, and + // the root declares this so a coverage run cannot reuse a plain cache + // entry. + "env": ["TEST_COVERAGE"] } } } diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 81f50d11a7f..318a498ccc7 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -67,6 +67,7 @@ export default defineConfig({ "**/*.toml", // Autogenerated files "**/*.snap.*", + "apps/petrinaut-website/src/examples/generated/**/*.json", "apps/petrinaut-website/src/routeTree.gen.ts", "**/openapi.json", "**/*.aux.mir", From 143853fc763b4f63db8a9bc3a3ddce04938c3ce7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:31:03 +0200 Subject: [PATCH 2/3] FE-1500: add read-only example pages --- .../src/examples/example-search.test.ts | 75 +++++++++++++ .../src/examples/example-search.ts | 91 ++++++++++++++++ .../src/examples/full-example-page.tsx | 72 +++++++++++++ .../src/examples/navigation-search.test.ts | 45 ++++++++ .../src/examples/navigation-search.ts | 46 ++++++++ .../examples/readonly-example-handle.test.ts | 31 ++++++ .../src/examples/readonly-example-handle.ts | 35 ++++++ .../use-shared-search-navigation.test.tsx | 102 ++++++++++++++++++ .../examples/use-shared-search-navigation.ts | 95 ++++++++++++++++ .../src/routes/examples.$slug.tsx | 47 ++++++++ .../src/routes/examples.index.tsx | 8 ++ 11 files changed, 647 insertions(+) create mode 100644 apps/petrinaut-website/src/examples/example-search.test.ts create mode 100644 apps/petrinaut-website/src/examples/example-search.ts create mode 100644 apps/petrinaut-website/src/examples/full-example-page.tsx create mode 100644 apps/petrinaut-website/src/examples/navigation-search.test.ts create mode 100644 apps/petrinaut-website/src/examples/navigation-search.ts create mode 100644 apps/petrinaut-website/src/examples/readonly-example-handle.test.ts create mode 100644 apps/petrinaut-website/src/examples/readonly-example-handle.ts create mode 100644 apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx create mode 100644 apps/petrinaut-website/src/examples/use-shared-search-navigation.ts create mode 100644 apps/petrinaut-website/src/routes/examples.$slug.tsx create mode 100644 apps/petrinaut-website/src/routes/examples.index.tsx diff --git a/apps/petrinaut-website/src/examples/example-search.test.ts b/apps/petrinaut-website/src/examples/example-search.test.ts new file mode 100644 index 00000000000..b2fef615d09 --- /dev/null +++ b/apps/petrinaut-website/src/examples/example-search.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + canonicalSearchString, + selectionToSearch, + sharedSearchesMatch, + validateSharedExampleSearch, +} from "./example-search"; + +describe("example search contract", () => { + it("strips unsupported query values", () => { + expect( + validateSharedExampleSearch({ + scenario: "scenario-1", + subnet: 42, + itemType: "unknown", + itemId: "place-1", + unrelated: "value", + }), + ).toEqual({ + scenario: "scenario-1", + subnet: undefined, + }); + }); + + it("keeps focused items only as complete pairs", () => { + expect(validateSharedExampleSearch({ itemType: "place" })).toEqual({ + scenario: undefined, + subnet: undefined, + }); + expect( + validateSharedExampleSearch({ itemType: "place", itemId: "place-1" }), + ).toEqual({ + scenario: undefined, + subnet: undefined, + itemType: "place", + itemId: "place-1", + }); + }); + + it("carries no item for a multi-selection", () => { + expect( + selectionToSearch([ + { type: "place", id: "place-1" }, + { type: "transition", id: "transition-1" }, + ]), + ).toEqual({}); + }); + + it("compares locations by canonical string, not key order", () => { + expect( + sharedSearchesMatch( + { subnet: "subnet-1", scenario: "scenario-1" }, + { scenario: "scenario-1", subnet: "subnet-1" }, + ), + ).toBe(true); + expect( + sharedSearchesMatch( + { scenario: "scenario-1" }, + { scenario: "scenario-2" }, + ), + ).toBe(false); + }); + + it("encodes the canonical string with contract keys only, sorted", () => { + expect( + canonicalSearchString({ + subnet: "subnet-1", + scenario: "scenario-1", + itemType: "place", + itemId: "place-1", + }), + ).toBe("itemId=place-1&itemType=place&scenario=scenario-1&subnet=subnet-1"); + }); +}); diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts new file mode 100644 index 00000000000..fedbd8071a6 --- /dev/null +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -0,0 +1,91 @@ +/** + * The example URL contract: scenario, subnet, and a single focused item. + * + * Deliberately free of React and of the Petrinaut editor: the canonical page, + * the embed page, and the oEmbed server function all speak this one contract, + * and the server function must not bundle the editor to do so. + */ +import { z } from "zod"; + +import { + selectionItemTypes, + type SelectionItem, + type SelectionItemType, +} from "@hashintel/petrinaut-core/selection"; + +/** + * Search params understood by every example surface. A URL carries at most one + * focused item: multi-selection is in-app state, not a shareable location. + */ +export type SharedExampleSearch = { + scenario?: string; + subnet?: string; + itemType?: SelectionItemType; + itemId?: string; +}; + +/** The keys this contract owns. Anything else in a URL is foreign. */ +const sharedSearchKeys = [ + "scenario", + "subnet", + "itemType", + "itemId", +] as const satisfies readonly (keyof SharedExampleSearch)[]; + +// `.catch(undefined)` is the contract's whole validation story: anything a URL +// can carry that is not a usable value simply drops out. +const optionalNonEmptyString = z.string().min(1).optional().catch(undefined); + +const optionalSelectionItemType = z + .enum(selectionItemTypes) + .optional() + .catch(undefined); + +/** The focused item, when the URL names a complete one. */ +export const selectionFromInput = ( + input: Record, +): readonly SelectionItem[] => { + const itemType = optionalSelectionItemType.parse(input.itemType); + const itemId = optionalNonEmptyString.parse(input.itemId); + return itemType && itemId ? [{ type: itemType, id: itemId }] : []; +}; + +/** Encodes a selection of exactly one item; anything else carries no item. */ +export const selectionToSearch = ( + selection: readonly SelectionItem[], +): Pick => { + const item = selection.length === 1 ? selection[0] : undefined; + return item ? { itemType: item.type, itemId: item.id } : {}; +}; + +/** + * Decodes arbitrary input into the contract, dropping anything it cannot + * represent. Also the normalizer: its output is the canonical spelling of a + * location. + */ +export const validateSharedExampleSearch = ( + input: Record, +): SharedExampleSearch => ({ + scenario: optionalNonEmptyString.parse(input.scenario), + subnet: optionalNonEmptyString.parse(input.subnet), + ...selectionToSearch(selectionFromInput(input)), +}); + +/** Canonical query string for a validated search: sorted, contract keys only. */ +export const canonicalSearchString = (search: SharedExampleSearch): string => { + const params = new URLSearchParams(); + for (const key of sharedSearchKeys) { + const value = search[key]; + if (value !== undefined) { + params.set(key, value); + } + } + params.sort(); + return params.toString(); +}; + +/** Two searches are the same location when their canonical strings agree. */ +export const sharedSearchesMatch = ( + left: SharedExampleSearch, + right: SharedExampleSearch, +): boolean => canonicalSearchString(left) === canonicalSearchString(right); diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx new file mode 100644 index 00000000000..d9005e4fb6b --- /dev/null +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -0,0 +1,72 @@ +import { useEffect } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; +import { Petrinaut } from "@hashintel/petrinaut/ui"; + +import { getReadonlyExampleHandle } from "./readonly-example-handle"; +import { useSharedSearchNavigation } from "./use-shared-search-navigation"; + +import type { LoadedExample } from "./catalog"; +import type { SharedExampleSearch } from "./example-search"; + +const pageStyle = css({ + width: "[100vw]", + height: "[100vh]", + minWidth: "0", + minHeight: "0", + overflow: "hidden", +}); + +const titleStyle = css({ + minWidth: "0", + overflow: "hidden", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export type FullExamplePageProps = { + example: LoadedExample; + /** Writes the shared search subset back to the page URL. */ + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}; + +export const FullExamplePage = ({ + example, + onSearchChange, + search, +}: FullExamplePageProps) => { + const handle = getReadonlyExampleHandle(example); + const navigation = useSharedSearchNavigation(search, onSearchChange); + + useEffect(() => { + const previousTitle = document.title; + document.title = `${example.catalog.title} · Petrinaut`; + return () => { + document.title = previousTitle; + }; + }, [example.catalog.title]); + + return ( +
+ {example.catalog.title} + ), + }} + title={example.catalog.title} + /> +
+ ); +}; diff --git a/apps/petrinaut-website/src/examples/navigation-search.test.ts b/apps/petrinaut-website/src/examples/navigation-search.test.ts new file mode 100644 index 00000000000..f2b421946cf --- /dev/null +++ b/apps/petrinaut-website/src/examples/navigation-search.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + navigationStateToSharedSearch, + sharedSearchToNavigationState, +} from "./navigation-search"; + +describe("navigation state projection", () => { + it("round-trips scenario, subnet, and selection", () => { + const state = sharedSearchToNavigationState({ + scenario: "none", + subnet: "subnet-1", + itemType: "place", + itemId: "place-1", + }); + + expect(state.scenarioId).toBeNull(); + expect(state.subnetId).toBe("subnet-1"); + expect(state.selection).toEqual([{ type: "place", id: "place-1" }]); + expect(navigationStateToSharedSearch(state)).toEqual({ + scenario: "none", + subnet: "subnet-1", + itemType: "place", + itemId: "place-1", + }); + }); + + it("distinguishes an explicit no-scenario choice from an absent one", () => { + expect(sharedSearchToNavigationState({}).scenarioId).toBeUndefined(); + expect( + sharedSearchToNavigationState({ scenario: "none" }).scenarioId, + ).toBeNull(); + expect( + navigationStateToSharedSearch(sharedSearchToNavigationState({})).scenario, + ).toBeUndefined(); + }); + + it("takes editor defaults for fields the URL does not carry", () => { + const state = sharedSearchToNavigationState({ subnet: "subnet-1" }); + + expect(state.mode).toBe("edit"); + expect(state.overlay).toBeNull(); + expect(state.simulateResource).toBeNull(); + }); +}); diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts new file mode 100644 index 00000000000..94698850184 --- /dev/null +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -0,0 +1,46 @@ +/** + * Projects the example URL contract onto Petrinaut's navigation state. The + * editor navigates more than the URL carries (mode, Simulate section, + * overlays), so those fields take editor defaults here and live in page state + * instead — see `useSharedSearchNavigation`. + */ +import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; + +import { + selectionFromInput, + selectionToSearch, + type SharedExampleSearch, +} from "./example-search"; + +import type { PetrinautNavigationState } from "@hashintel/petrinaut/react"; + +/** `none` is an explicit no-scenario choice; absence means "first available". */ +const scenarioFromSearch = ( + search: SharedExampleSearch, +): string | null | undefined => { + if (search.scenario === undefined) { + return undefined; + } + return search.scenario === "none" ? null : search.scenario; +}; + +const scenarioToSearch = ( + scenarioId: string | null | undefined, +): string | undefined => (scenarioId === null ? "none" : scenarioId); + +export const sharedSearchToNavigationState = ( + search: SharedExampleSearch, +): PetrinautNavigationState => ({ + ...defaultPetrinautNavigationState, + scenarioId: scenarioFromSearch(search), + subnetId: search.subnet ?? null, + selection: selectionFromInput(search as Record), +}); + +export const navigationStateToSharedSearch = ( + state: Readonly, +): SharedExampleSearch => ({ + scenario: scenarioToSearch(state.scenarioId), + subnet: state.subnetId ?? undefined, + ...selectionToSearch(state.selection), +}); diff --git a/apps/petrinaut-website/src/examples/readonly-example-handle.test.ts b/apps/petrinaut-website/src/examples/readonly-example-handle.test.ts new file mode 100644 index 00000000000..3268149e923 --- /dev/null +++ b/apps/petrinaut-website/src/examples/readonly-example-handle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { exampleCatalog, type LoadedExample } from "./catalog"; +import { getReadonlyExampleHandle } from "./readonly-example-handle"; + +const example: LoadedExample = { + catalog: exampleCatalog[0]!, + definition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, +}; + +describe("read-only example handles", () => { + it("reuses a history-free, read-only handle per example", () => { + const handle = getReadonlyExampleHandle(example); + + expect(getReadonlyExampleHandle(example)).toBe(handle); + expect(handle.capabilities?.readonly).toBe(true); + expect(handle.history).toBeUndefined(); + + handle.change((draft) => { + draft.subnets = []; + }); + + expect(handle.doc()?.subnets).toBeUndefined(); + }); +}); diff --git a/apps/petrinaut-website/src/examples/readonly-example-handle.ts b/apps/petrinaut-website/src/examples/readonly-example-handle.ts new file mode 100644 index 00000000000..01be92389e3 --- /dev/null +++ b/apps/petrinaut-website/src/examples/readonly-example-handle.ts @@ -0,0 +1,35 @@ +import { + createJsonDocHandle, + type PetrinautDocHandle, +} from "@hashintel/petrinaut-core"; + +import type { LoadedExample } from "./catalog"; + +const handlesByExample = new Map(); + +/** + * Return the one in-memory document handle for an example. + * + * Retaining one handle per example keeps the full editor stable across + * search-only navigations. Read-only capability enforcement happens at the + * document boundary and history is omitted. + */ +export const getReadonlyExampleHandle = ({ + catalog, + definition, +}: LoadedExample): PetrinautDocHandle => { + const key = catalog.slug; + const existing = handlesByExample.get(key); + if (existing) { + return existing; + } + + const handle = createJsonDocHandle({ + id: `example:${key}`, + initial: definition, + capabilities: { readonly: true }, + historyLimit: 0, + }); + handlesByExample.set(key, handle); + return handle; +}; diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx new file mode 100644 index 00000000000..a571b5d7caf --- /dev/null +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { act, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useSharedSearchNavigation } from "./use-shared-search-navigation"; + +import type { SharedExampleSearch } from "./example-search"; +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; + +const Probe = ({ + onController, + onSearchChange, + search, +}: { + onController: (controller: PetrinautNavigationController) => void; + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}) => { + onController(useSharedSearchNavigation(search, onSearchChange)); + return null; +}; + +describe("useSharedSearchNavigation", () => { + it("keeps URL-unrepresentable state in memory and mirrors the shared subset", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ scenario: "scenario-1" }} + />, + ); + + // A mode change is not part of the URL contract: it applies in memory + // and produces no URL write. + act(() => { + controller.onNavigate((current) => ({ ...current, mode: "simulate" }), { + history: "push", + intent: { cause: "user", action: "mode" }, + }); + }); + expect(controller.state.mode).toBe("simulate"); + expect(onSearchChange).not.toHaveBeenCalled(); + + // A subnet change is shared: it applies in memory AND writes the URL. + act(() => { + controller.onNavigate( + (current) => ({ ...current, subnetId: "subnet-1" }), + { history: "push", intent: { cause: "user", action: "subnet" } }, + ); + }); + expect(controller.state.subnetId).toBe("subnet-1"); + expect(controller.state.mode).toBe("simulate"); + expect(onSearchChange).toHaveBeenCalledOnce(); + expect(onSearchChange).toHaveBeenCalledWith( + { scenario: "scenario-1", subnet: "subnet-1" }, + "push", + ); + }); + + it("merges an external URL change without resetting in-memory fields", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ scenario: "scenario-1" }} + />, + ); + + act(() => { + controller.onNavigate((current) => ({ ...current, mode: "simulate" }), { + history: "push", + intent: { cause: "user", action: "mode" }, + }); + }); + + // Back/Forward delivers a different shared search: URL-owned fields + // update, the in-memory mode survives. + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ scenario: "scenario-2" }} + />, + ); + expect(controller.state.scenarioId).toBe("scenario-2"); + expect(controller.state.mode).toBe("simulate"); + }); +}); diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts new file mode 100644 index 00000000000..18d7dce3959 --- /dev/null +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -0,0 +1,95 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { + sharedSearchesMatch, + type SharedExampleSearch, +} from "./example-search"; +import { + navigationStateToSharedSearch, + sharedSearchToNavigationState, +} from "./navigation-search"; + +import type { + PetrinautNavigationController, + PetrinautNavigationHistoryPolicy, + PetrinautNavigationState, +} from "@hashintel/petrinaut/react"; + +/** + * Overwrites the URL-owned fields of the in-memory location with the current + * shared search, keeping the fields the URL cannot represent. + */ +const mergeSharedSearch = ( + current: PetrinautNavigationState, + search: SharedExampleSearch, +): PetrinautNavigationState => { + const shared = sharedSearchToNavigationState(search); + return { + ...current, + scenarioId: shared.scenarioId, + subnetId: shared.subnetId, + selection: shared.selection, + }; +}; + +/** + * Navigation controller for pages whose URL carries the shared + * scenario/subnet/selection subset. The editor navigates more than that + * (global mode, overlays), so the full location lives in page state and only + * its shared projection is mirrored to the URL — otherwise every control + * driving a non-shared field would silently snap back. + */ +export const useSharedSearchNavigation = ( + search: SharedExampleSearch, + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void, + options?: { historyPolicy?: PetrinautNavigationHistoryPolicy }, +): PetrinautNavigationController => { + const [navigationState, setNavigationState] = + useState(() => + sharedSearchToNavigationState(search), + ); + + // Merge external URL changes (Back/Forward, a normalization redirect) + // into the in-memory location during render. + const [previousSearch, setPreviousSearch] = useState(search); + if (!sharedSearchesMatch(search, previousSearch)) { + setPreviousSearch(search); + setNavigationState((current) => mergeSharedSearch(current, search)); + } + + // Freshest committed location for callbacks that can fire several times + // within one browser event, before React rerenders. Re-synced on every + // commit: this state always holds the applied value. + const navigationStateRef = useRef(navigationState); + useLayoutEffect(() => { + navigationStateRef.current = navigationState; + }); + + // The search this page last received from the router OR last sent to it. + // Synced only when the router delivers a new search — an unconditional + // resync would overwrite an in-flight write with the not-yet-committed + // prop and make the next identical navigation skip its URL write. + const latestSearchRef = useRef(search); + useLayoutEffect(() => { + latestSearchRef.current = search; + }, [search]); + + return { + state: navigationState, + historyPolicy: options?.historyPolicy, + onNavigate: (update, { history }) => { + const next = update(navigationStateRef.current); + navigationStateRef.current = next; + setNavigationState(next); + + const nextSearch = navigationStateToSharedSearch(next); + if (!sharedSearchesMatch(nextSearch, latestSearchRef.current)) { + latestSearchRef.current = nextSearch; + onSearchChange(nextSearch, history); + } + }, + }; +}; diff --git a/apps/petrinaut-website/src/routes/examples.$slug.tsx b/apps/petrinaut-website/src/routes/examples.$slug.tsx new file mode 100644 index 00000000000..90d2aeb9a8b --- /dev/null +++ b/apps/petrinaut-website/src/routes/examples.$slug.tsx @@ -0,0 +1,47 @@ +import { + createFileRoute, + notFound, + useLoaderData, + useNavigate, + useSearch, +} from "@tanstack/react-router"; + +import { isExampleSlug, loadExample } from "../examples/catalog"; +import { validateSharedExampleSearch } from "../examples/example-search"; +import { FullExamplePage } from "../examples/full-example-page"; + +function ExampleRoute() { + const navigate = useNavigate({ from: "/examples/$slug" }); + const example = useLoaderData({ from: "/examples/$slug" }); + const search = useSearch({ from: "/examples/$slug" }); + + return ( + { + void navigate({ replace: history === "replace", search: nextSearch }); + }} + search={search} + /> + ); +} + +export const Route = createFileRoute("/examples/$slug")({ + beforeLoad: ({ params }) => { + if (!isExampleSlug(params.slug)) { + throw notFound(); + } + }, + component: ExampleRoute, + loader: ({ params }) => { + if (!isExampleSlug(params.slug)) { + throw notFound(); + } + return loadExample(params.slug); + }, + validateSearch: validateSharedExampleSearch, +}); diff --git a/apps/petrinaut-website/src/routes/examples.index.tsx b/apps/petrinaut-website/src/routes/examples.index.tsx new file mode 100644 index 00000000000..c899035e953 --- /dev/null +++ b/apps/petrinaut-website/src/routes/examples.index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +// There is no example index page: examples are embed and deep-link content. +export const Route = createFileRoute("/examples/")({ + beforeLoad: () => { + throw redirect({ to: "/", replace: true }); + }, +}); From fd3bc6ad5a2eec1049bce25e0341fcc68b228860 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 16:24:54 +0200 Subject: [PATCH 3/3] FE-1500: property-test the search codec laws --- apps/petrinaut-website/package.json | 2 + .../examples/example-search.property.test.ts | 72 +++++++++++++++++++ yarn.lock | 15 +++- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/petrinaut-website/src/examples/example-search.property.test.ts diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 6d9302cce24..06d57bb1b91 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -37,6 +37,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@fast-check/vitest": "0.4.1", "@tanstack/router-generator": "1.167.32", "@tanstack/router-plugin": "1.168.34", "@types/react": "19.2.14", @@ -44,6 +45,7 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "@vitejs/plugin-react": "6.1.0", "@whatwg-node/server": "0.10.18", + "fast-check": "4.9.0", "oxc-transform-react": "0.145.0", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", diff --git a/apps/petrinaut-website/src/examples/example-search.property.test.ts b/apps/petrinaut-website/src/examples/example-search.property.test.ts new file mode 100644 index 00000000000..a9f59af0172 --- /dev/null +++ b/apps/petrinaut-website/src/examples/example-search.property.test.ts @@ -0,0 +1,72 @@ +import { fc, test } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { + canonicalSearchString, + sharedSearchesMatch, + validateSharedExampleSearch, +} from "./example-search"; + +const knownKeys = ["scenario", "subnet", "itemType", "itemId"] as const; + +const plausibleValues = fc.constantFrom( + "none", + "scenario-1", + "subnet-1", + "place", + "transition", + "not-a-type", + "", +); + +const paramValue = fc.oneof( + plausibleValues, + fc.string({ maxLength: 12 }), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.array( + fc.oneof(plausibleValues, fc.string({ maxLength: 12 }), fc.integer()), + { maxLength: 4 }, + ), +); + +/** + * Arbitrary decoded-search objects as TanStack Router hands them to + * `validateSearch`: known and unknown keys, values of any JSON-ish shape. + */ +const searchInput = fc.dictionary( + fc.oneof( + fc.constantFrom(...knownKeys), + fc.string({ minLength: 1, maxLength: 8 }), + ), + paramValue, + { maxKeys: 8 }, +); + +describe("example search contract laws", () => { + test.prop([searchInput])("decoding never throws", (input) => { + expect(() => validateSharedExampleSearch(input)).not.toThrow(); + }); + + test.prop([searchInput])( + "validation is idempotent, so the embed entry redirect terminates", + (input) => { + const once = validateSharedExampleSearch(input); + const twice = validateSharedExampleSearch(once); + expect(twice).toEqual(once); + expect(sharedSearchesMatch(once, twice)).toBe(true); + }, + ); + + test.prop([searchInput])( + "the canonical string is the same location, and re-decodes to itself", + (input) => { + const search = validateSharedExampleSearch(input); + const decoded = validateSharedExampleSearch( + Object.fromEntries(new URLSearchParams(canonicalSearchString(search))), + ); + expect(sharedSearchesMatch(decoded, search)).toBe(true); + }, + ); +}); diff --git a/yarn.lock b/yarn.lock index 895707ea30c..83f4ba0596d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -918,6 +918,7 @@ __metadata: resolution: "@apps/petrinaut-website@workspace:apps/petrinaut-website" dependencies: "@ai-sdk/openai": "npm:3.0.63" + "@fast-check/vitest": "npm:0.4.1" "@hashintel/brunch-agent-transport-aisdk": "workspace:*" "@hashintel/ds-components": "workspace:*" "@hashintel/ds-helpers": "workspace:*" @@ -936,6 +937,7 @@ __metadata: "@vitejs/plugin-react": "npm:6.1.0" "@whatwg-node/server": "npm:0.10.18" ai: "npm:6.0.182" + fast-check: "npm:4.9.0" immer: "npm:10.1.3" oxc-transform-react: "npm:0.145.0" oxlint: "npm:1.63.0" @@ -6281,6 +6283,17 @@ __metadata: languageName: node linkType: hard +"@fast-check/vitest@npm:0.4.1": + version: 0.4.1 + resolution: "@fast-check/vitest@npm:0.4.1" + dependencies: + fast-check: "npm:^3.0.0 || ^4.0.0" + peerDependencies: + vitest: ^4.1.0 + checksum: 10c0/c558f443cbf79cfc18f81372d21129021a13e5d821b57876449d3cac254638ddc5c9736b687fc82dc78f53cf1d948a68787aff7f0d1700704168e1805af088d2 + languageName: node + linkType: hard + "@fastify/busboy@npm:^3.1.1": version: 3.2.0 resolution: "@fastify/busboy@npm:3.2.0" @@ -28371,7 +28384,7 @@ __metadata: languageName: node linkType: hard -"fast-check@npm:4.9.0": +"fast-check@npm:4.9.0, fast-check@npm:^3.0.0 || ^4.0.0": version: 4.9.0 resolution: "fast-check@npm:4.9.0" dependencies: