@tellegen/svelte 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +20 -0
  2. package/dist/TellegenMap.svelte +63 -22
  3. package/dist/TellegenProvider.svelte +7 -4
  4. package/dist/TellegenShell.svelte +10 -1
  5. package/dist/TellegenViewer.svelte +2 -0
  6. package/dist/basemap.d.ts +14 -0
  7. package/dist/basemap.js +27 -0
  8. package/dist/colors.d.ts +6 -1
  9. package/dist/colors.js +11 -4
  10. package/dist/components/DemandSlider.svelte +0 -2
  11. package/dist/components/FormulationSelector.svelte +1 -1
  12. package/dist/components/LocalCaseDetails.svelte +18 -3
  13. package/dist/components/McResults.svelte +18 -3
  14. package/dist/components/McWorkbench.svelte +688 -0
  15. package/dist/components/McWorkbench.svelte.d.ts +38 -0
  16. package/dist/components/MultiWire.svelte +136 -0
  17. package/dist/components/MultiWire.svelte.d.ts +7 -0
  18. package/dist/components/MulticonductorDetails.svelte +7 -0
  19. package/dist/components/OmniSearch.svelte +163 -0
  20. package/dist/components/OmniSearch.svelte.d.ts +14 -0
  21. package/dist/components/ResultsTable.svelte +259 -0
  22. package/dist/components/ResultsTable.svelte.d.ts +13 -0
  23. package/dist/components/SchematicCanvas.svelte +1896 -0
  24. package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
  25. package/dist/components/SolveCard.svelte +6 -0
  26. package/dist/components/schematic/PickCard.svelte +126 -0
  27. package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
  28. package/dist/components/schematic/export.d.ts +21 -0
  29. package/dist/components/schematic/export.js +110 -0
  30. package/dist/components/schematic/layers.d.ts +161 -0
  31. package/dist/components/schematic/layers.js +756 -0
  32. package/dist/components/schematic/paint.d.ts +28 -0
  33. package/dist/components/schematic/paint.js +69 -0
  34. package/dist/components/schematic/palette.d.ts +34 -0
  35. package/dist/components/schematic/palette.js +54 -0
  36. package/dist/components/schematic/text.d.ts +83 -0
  37. package/dist/components/schematic/text.js +190 -0
  38. package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
  39. package/dist/components/schematic/view-prefs.svelte.js +77 -0
  40. package/dist/components.d.ts +1 -0
  41. package/dist/components.js +1 -0
  42. package/dist/context.svelte.d.ts +2 -0
  43. package/dist/context.svelte.js +2 -0
  44. package/dist/controller.svelte.d.ts +33 -5
  45. package/dist/controller.svelte.js +173 -20
  46. package/dist/diagram/basemap.d.ts +28 -0
  47. package/dist/diagram/basemap.js +39 -0
  48. package/dist/diagram/bus-stacks.d.ts +19 -0
  49. package/dist/diagram/bus-stacks.js +99 -0
  50. package/dist/diagram/colour-modes.d.ts +18 -0
  51. package/dist/diagram/colour-modes.js +33 -0
  52. package/dist/diagram/connections.d.ts +30 -0
  53. package/dist/diagram/connections.js +129 -0
  54. package/dist/diagram/display-tree.d.ts +21 -0
  55. package/dist/diagram/display-tree.js +65 -0
  56. package/dist/diagram/energisation.d.ts +10 -0
  57. package/dist/diagram/energisation.js +31 -0
  58. package/dist/diagram/feeders.d.ts +30 -0
  59. package/dist/diagram/feeders.js +57 -0
  60. package/dist/diagram/galvanic-zones.d.ts +60 -0
  61. package/dist/diagram/galvanic-zones.js +210 -0
  62. package/dist/diagram/graph-inputs.d.ts +18 -0
  63. package/dist/diagram/graph-inputs.js +103 -0
  64. package/dist/diagram/index.d.ts +28 -0
  65. package/dist/diagram/index.js +28 -0
  66. package/dist/diagram/landmarks.d.ts +50 -0
  67. package/dist/diagram/landmarks.js +153 -0
  68. package/dist/diagram/layout-morph.d.ts +15 -0
  69. package/dist/diagram/layout-morph.js +32 -0
  70. package/dist/diagram/multi-wire.d.ts +44 -0
  71. package/dist/diagram/multi-wire.js +65 -0
  72. package/dist/diagram/phase-bars.d.ts +35 -0
  73. package/dist/diagram/phase-bars.js +44 -0
  74. package/dist/diagram/results-table.d.ts +47 -0
  75. package/dist/diagram/results-table.js +173 -0
  76. package/dist/diagram/schematic-equipment.d.ts +133 -0
  77. package/dist/diagram/schematic-equipment.js +645 -0
  78. package/dist/diagram/schematic-layout.d.ts +51 -0
  79. package/dist/diagram/schematic-layout.js +240 -0
  80. package/dist/diagram/search.d.ts +36 -0
  81. package/dist/diagram/search.js +48 -0
  82. package/dist/diagram/shared-routes.d.ts +21 -0
  83. package/dist/diagram/shared-routes.js +40 -0
  84. package/dist/diagram/short-names.d.ts +11 -0
  85. package/dist/diagram/short-names.js +54 -0
  86. package/dist/diagram/snapshot.d.ts +115 -0
  87. package/dist/diagram/snapshot.js +242 -0
  88. package/dist/diagram/summary.d.ts +65 -0
  89. package/dist/diagram/summary.js +144 -0
  90. package/dist/diagram/svg-export.d.ts +80 -0
  91. package/dist/diagram/svg-export.js +108 -0
  92. package/dist/diagram/tree-walk.d.ts +13 -0
  93. package/dist/diagram/tree-walk.js +83 -0
  94. package/dist/diagram/value-labels.d.ts +24 -0
  95. package/dist/diagram/value-labels.js +69 -0
  96. package/dist/diagram/view-state.d.ts +12 -0
  97. package/dist/diagram/view-state.js +22 -0
  98. package/dist/diagram/voltage-levels.d.ts +14 -0
  99. package/dist/diagram/voltage-levels.js +20 -0
  100. package/dist/index.d.ts +1 -0
  101. package/dist/index.js +1 -0
  102. package/dist/state.svelte.d.ts +37 -1
  103. package/dist/state.svelte.js +35 -0
  104. package/dist/transformer-icon.d.ts +22 -13
  105. package/dist/transformer-icon.js +23 -13
  106. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import { type TellegenApiClient } from './api.js';
2
2
  import type { BranchRatingDeltas, Network, NetworkBus, SensitivityColumn, Solution } from './api.js';
3
3
  import { CaseState, LocalCase, MulticonductorCase, type AppState, type DemandRangeMode, type SolvableCase } from './state.svelte.js';
4
- import { type EngineTransport, type McPfDetail, type McPfDetailQuery, type McPfOptions, type McPfSummary, type McStudySnapshot, type BrowserStudy, type Formulation, type IngestedCase, type PowerIoDiagnostic, type SensTarget } from '@tellegen/engine';
4
+ import { type EngineTransport, type McPfDetail, type McPfDetailQuery, type McPfOptions, type McPfResult, type McPfSummary, type McStudySnapshot, type BrowserStudy, type Formulation, type IngestedCase, type PowerIoDiagnostic, type SensTarget } from '@tellegen/engine';
5
5
  /** A parsed geographic sidecar awaiting application: the canonical `.geo.json`
6
6
  * layer document from the engine's tolerant reader, its source file name, and
7
7
  * the reader's notes on records it could not use. */
@@ -34,6 +34,7 @@ export interface ControllerOptions {
34
34
  /** Whether `load()` fetches the bundled cases. Default `true`. With
35
35
  * `false`, `load()` only marks the shell loaded and frames the map. */
36
36
  loadDefaultCases?: boolean;
37
+ acOpfWasmUrl?: string;
37
38
  mcTransport?: Pick<EngineTransport, 'solveMcModule' | 'applyMcGeo'> & Partial<Pick<EngineTransport, 'solveMcStudy' | 'applyMcStudyGeo' | 'createMcPfSession'>>;
38
39
  }
39
40
  /** Input to `Controller.openBalancedModule` and `replaceLocalCase`: a
@@ -66,6 +67,12 @@ export declare class Controller {
66
67
  abort: AbortController | null;
67
68
  sensitivity429Until: number;
68
69
  serverComputeOff: boolean;
70
+ /** The optional EPL-bearing WASI asset is never inferred or bundled. */
71
+ private readonly acOpfWasmUrl;
72
+ private acOpfProbeStarted;
73
+ private readonly acOpfProbeAbort;
74
+ private readonly acOpfSolves;
75
+ acOpfAvailable: boolean;
69
76
  caseStudies: WeakMap<SolvableCase, {
70
77
  study: BrowserStudy;
71
78
  studyInputJson: string;
@@ -111,6 +118,9 @@ export declare class Controller {
111
118
  /** True when two selection targets name the same bus or branch. */
112
119
  sameTarget(a: SensTarget, b: SensTarget): boolean;
113
120
  isPerturbed(c: SolvableCase | null): boolean;
121
+ formulationAvailable(c: SolvableCase, formulation: Formulation): boolean;
122
+ probeAcOpf: () => void;
123
+ caseExportUnavailableReason(c: SolvableCase): string | null;
114
124
  bumpRevision(c: SolvableCase): void;
115
125
  setNearbyRangeAnchor(c: SolvableCase, bus: number, delta?: number): void;
116
126
  beginBusSelection(c: SolvableCase, busId: number): {
@@ -131,10 +141,10 @@ export declare class Controller {
131
141
  getStudy(c: SolvableCase, studyInputJson: string): Promise<BrowserStudy | null>;
132
142
  browserSensitivity(c: SolvableCase, studyInputJson: string, target: SensTarget): Promise<SensitivityColumn | null>;
133
143
  /** Release every engine handle this controller holds: each case's browser
134
- * Study, including one still being built, and each multiconductor
135
- * session, with their pending edits and in-flight requests.
136
- * `TellegenProvider` calls it when it unmounts; the controller builds no
137
- * Study and keeps no new session afterwards. */
144
+ * Study, including one still being built, its AC OPF solve, each
145
+ * multiconductor session, with their pending edits and in-flight requests,
146
+ * and the AC OPF asset probe. `TellegenProvider` calls it when it unmounts;
147
+ * the controller builds no Study and keeps no new session afterwards. */
138
148
  dispose(): void;
139
149
  disposeStudy(c: SolvableCase): void;
140
150
  acceptSensitivity(c: SolvableCase, col: SensitivityColumn | null, target: SensTarget | null, sensitivitySeq?: number): void;
@@ -354,6 +364,24 @@ export declare class Controller {
354
364
  * operating point, from the live session or the stored full result. Null
355
365
  * when the operating point changed before the page arrived. */
356
366
  multiResultDetail: (c: MulticonductorCase, query: McPfDetailQuery) => Promise<McPfDetail | null>;
367
+ /** Above this many buses, reading and indexing the whole operating point takes a noticeable
368
+ * fraction of a second (evidence/diagram), so the overview is computed on request instead of
369
+ * after every solve. */
370
+ static readonly AUTO_OVERVIEW_MAX_BUSES = 25000;
371
+ private overviewSeq;
372
+ /** Whether this case computes its overview only on request. */
373
+ overviewOnRequest: (c: MulticonductorCase) => boolean;
374
+ /** Read the whole operating point into `c.overviewResult` for the overview and the diagram. The
375
+ * previous result stays visible until the read lands; a newer read supersedes it. */
376
+ readOverview: (c: MulticonductorCase) => void;
377
+ private startOverviewRead;
378
+ /** Follow the active case's solves: read its operating point once load edits settle, or clear it
379
+ * for a case that computes the overview on request. Call from an effect that tracks the case's
380
+ * `result` and `mcFullResult`; the returned function cancels a pending read. */
381
+ followOverview: (c: MulticonductorCase | null) => (() => void) | undefined;
382
+ /** The complete operating point for whole-network views: the stored result, or one read from
383
+ * the live session. Null when there is no result or it changed before the read finished. */
384
+ multiFullResult: (c: MulticonductorCase) => Promise<McPfResult | null>;
357
385
  /** Every terminal's identity, voltage, and injected current, as numeric
358
386
  * arrays of interleaved `[re, im]` values. */
359
387
  multiTerminalTable: (c: MulticonductorCase) => Promise<{
@@ -6,7 +6,7 @@ import { placeSyntheticTopology } from './synthetic-layout.js';
6
6
  import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
7
7
  import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
8
8
  import { buildGeographicView, buildDiagramView, placeMultiView } from './multiconductor.js';
9
- import { browserWasmTransport, applyDisplayGeo, applyGeo, applyLayout, createStudy, extractGeo, FORMULATIONS, formatPowerIoDiagnostic, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo, mcPfResultDetail, summarizeMcPfResult } from '@tellegen/engine';
9
+ import { browserWasmTransport, applyDisplayGeo, applyGeo, applyLayout, createStudy, extractGeo, FORMULATIONS, formatPowerIoDiagnostic, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo, probeAcOpfWorker, solveAcOpfModule, solveResponseToSolution, mcPfResultDetail, summarizeMcPfResult } from '@tellegen/engine';
10
10
  import { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
11
  import { DEAD_ZONE_MW } from './slider.js';
12
12
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
@@ -83,6 +83,12 @@ export class Controller {
83
83
  // Latched once any server fallback answers 403: compute is disabled on this
84
84
  // deploy, so later fallbacks show the notice instead of firing doomed requests.
85
85
  serverComputeOff = false;
86
+ /** The optional EPL-bearing WASI asset is never inferred or bundled. */
87
+ acOpfWasmUrl;
88
+ acOpfProbeStarted = false;
89
+ acOpfProbeAbort = new AbortController();
90
+ acOpfSolves = new Map();
91
+ acOpfAvailable = $state(false);
86
92
  // Build-once browser Study per case: the retained PowerIO module is parsed and the model built
87
93
  // when the Study is created, so a drag re-solves (commit) and previews without
88
94
  // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
@@ -131,6 +137,7 @@ export class Controller {
131
137
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
132
138
  this.loadDefaultCases = options.loadDefaultCases ?? true;
133
139
  this.mcTransport = options.mcTransport ?? browserWasmTransport;
140
+ this.acOpfWasmUrl = options.acOpfWasmUrl ?? null;
134
141
  }
135
142
  // ===== helpers =====
136
143
  isBackendCase(c) {
@@ -158,6 +165,31 @@ export class Controller {
158
165
  isPerturbed(c) {
159
166
  return c?.perturbed ?? false;
160
167
  }
168
+ formulationAvailable(c, formulation) {
169
+ if (formulation === 'acopf')
170
+ return this.acOpfAvailable && !c.perturbed;
171
+ return !FORMULATIONS.find((entry) => entry.id === formulation)?.disabled;
172
+ }
173
+ probeAcOpf = () => {
174
+ if (this.disposed || this.acOpfProbeStarted || !this.acOpfWasmUrl)
175
+ return;
176
+ this.acOpfProbeStarted = true;
177
+ void probeAcOpfWorker(this.acOpfWasmUrl, undefined, this.acOpfProbeAbort.signal).then((available) => {
178
+ if (this.disposed)
179
+ return;
180
+ this.acOpfAvailable = available;
181
+ // A declared instance can be dropped while the optional asset is probing.
182
+ const c = this.app.activeLocal;
183
+ if (available && c?.formulation === 'acopf' && !c.solving) {
184
+ this.maybeStartLocalSolve(c.id);
185
+ }
186
+ });
187
+ };
188
+ caseExportUnavailableReason(c) {
189
+ return c.formulation === 'acopf'
190
+ ? 'Saving and exporting AC OPF cases is not supported yet.'
191
+ : null;
192
+ }
161
193
  bumpRevision(c) {
162
194
  c.revisionGeneration += 1;
163
195
  }
@@ -352,18 +384,24 @@ export class Controller {
352
384
  return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
353
385
  }
354
386
  /** Release every engine handle this controller holds: each case's browser
355
- * Study, including one still being built, and each multiconductor
356
- * session, with their pending edits and in-flight requests.
357
- * `TellegenProvider` calls it when it unmounts; the controller builds no
358
- * Study and keeps no new session afterwards. */
387
+ * Study, including one still being built, its AC OPF solve, each
388
+ * multiconductor session, with their pending edits and in-flight requests,
389
+ * and the AC OPF asset probe. `TellegenProvider` calls it when it unmounts;
390
+ * the controller builds no Study and keeps no new session afterwards. */
359
391
  dispose() {
360
392
  if (this.disposed)
361
393
  return;
362
394
  this.disposed = true;
363
395
  this.abort?.abort();
364
396
  this.abort = null;
365
- for (const c of [...this.app.cases, ...this.app.localCases])
397
+ this.acOpfProbeAbort.abort();
398
+ for (const cancellation of this.acOpfSolves.values())
399
+ cancellation.abort();
400
+ this.acOpfSolves.clear();
401
+ for (const c of [...this.app.cases, ...this.app.localCases]) {
402
+ c.solveSeq = (c.solveSeq ?? 0) + 1;
366
403
  this.disposeStudy(c);
404
+ }
367
405
  for (const c of this.app.multiCases) {
368
406
  c.solveAbort?.abort();
369
407
  if (c.mcEditTimer !== null)
@@ -375,6 +413,8 @@ export class Controller {
375
413
  }
376
414
  // Release a case's Study (if any) when the case is removed.
377
415
  disposeStudy(c) {
416
+ this.acOpfSolves.get(c)?.abort();
417
+ this.acOpfSolves.delete(c);
378
418
  const cached = this.caseStudies.get(c);
379
419
  if (cached) {
380
420
  cached.study.free();
@@ -557,7 +597,9 @@ export class Controller {
557
597
  sliderBounds = $derived.by(() => this.demandBounds(this.app.demandRangeMode, this.selectedBusData, this.nearbyRangeCenter));
558
598
  sliderMin = $derived(this.sliderBounds.min);
559
599
  sliderMax = $derived(this.sliderBounds.max);
560
- sliderDisabled = $derived(!this.selectedBusData || isDisplayOnlyElement(this.selectedBusData));
600
+ sliderDisabled = $derived(this.activeFormulation === 'acopf' ||
601
+ !this.selectedBusData ||
602
+ isDisplayOnlyElement(this.selectedBusData));
561
603
  committedRating = $derived.by(() => this.activeSolvable && this.app.selectedBranch !== null
562
604
  ? (this.caseRatings(this.activeSolvable)[this.app.selectedBranch] ?? 0)
563
605
  : 0);
@@ -569,7 +611,7 @@ export class Controller {
569
611
  // to perturb.
570
612
  ratingBounds = $derived.by(() => {
571
613
  const b = this.selectedBranchData;
572
- if (!b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
614
+ if (this.activeFormulation === 'acopf' || !b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
573
615
  return { min: 0, max: 0, disabled: true };
574
616
  const span = Math.min(50, Math.max(5, 0.2 * b.rate_mw));
575
617
  return {
@@ -681,6 +723,7 @@ export class Controller {
681
723
  if (this.loading)
682
724
  return this.loading;
683
725
  this.loading = (async () => {
726
+ this.probeAcOpf();
684
727
  // Learn the deploy's compute gate up front so fallback paths pick honest
685
728
  // copy and skip doomed requests (the SSE stream cannot see a 403). On
686
729
  // failure assume enabled; the 403 latch in fetchServerColumn still
@@ -842,7 +885,7 @@ export class Controller {
842
885
  this.app.activeCaseId = null;
843
886
  this.leaveMulti();
844
887
  this.app.activeLocalId = c.id;
845
- if (c.formulation === 'acpf' && this.app.displayMode === 'price')
888
+ if ((c.formulation === 'acpf' || c.formulation === 'acopf') && this.app.displayMode === 'price')
846
889
  this.app.displayMode = 'voltage';
847
890
  this.app.placingLocalId = c.coordsKind === 'synthetic_pending' && !c.diagram ? c.id : null;
848
891
  if (c.view || c.diagram || c.substations)
@@ -868,7 +911,7 @@ export class Controller {
868
911
  this.app.activeCaseId = null;
869
912
  this.leaveMulti();
870
913
  this.app.addLocal(c);
871
- if (c.formulation === 'acpf' && this.app.displayMode === 'price')
914
+ if ((c.formulation === 'acpf' || c.formulation === 'acopf') && this.app.displayMode === 'price')
872
915
  this.app.displayMode = 'voltage';
873
916
  if (c.diagram)
874
917
  this.app.placingLocalId = null;
@@ -1049,6 +1092,7 @@ export class Controller {
1049
1092
  copy.iterations = source.iterations;
1050
1093
  copy.solveMs = source.solveMs;
1051
1094
  copy.solveBackend = source.solveBackend;
1095
+ copy.solveDetail = source.solveDetail;
1052
1096
  copy.network = source.network;
1053
1097
  if (source.network) {
1054
1098
  copy.view = {
@@ -1121,7 +1165,8 @@ export class Controller {
1121
1165
  'baseSolution',
1122
1166
  'iterations',
1123
1167
  'solveMs',
1124
- 'solveBackend'
1168
+ 'solveBackend',
1169
+ 'solveDetail'
1125
1170
  ])
1126
1171
  Object.assign(copy, { [key]: source[key] });
1127
1172
  const captured = this.detachedCaseCaptures.get(source);
@@ -1276,7 +1321,7 @@ export class Controller {
1276
1321
  if (ac.signal.aborted)
1277
1322
  return;
1278
1323
  }
1279
- if (c.formulation === 'acpf')
1324
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1280
1325
  return;
1281
1326
  try {
1282
1327
  // The column from the browser Study (under the case's formulation). DC OPF
@@ -1412,7 +1457,7 @@ export class Controller {
1412
1457
  this.app.placingLocalId = null;
1413
1458
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1414
1459
  try {
1415
- if (c.formulation === 'acpf')
1460
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1416
1461
  return;
1417
1462
  const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1418
1463
  bus: busId
@@ -1471,7 +1516,7 @@ export class Controller {
1471
1516
  if (ac.signal.aborted)
1472
1517
  return;
1473
1518
  }
1474
- if (c.formulation === 'acpf')
1519
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1475
1520
  return;
1476
1521
  const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1477
1522
  branch: branchId
@@ -1535,8 +1580,12 @@ export class Controller {
1535
1580
  // fall back: the server solves at base ratings, so a Study failure there is
1536
1581
  // terminal.
1537
1582
  runSolve = (c, target) => {
1538
- if (c.formulation === 'acpf')
1583
+ if (this.disposed)
1584
+ return;
1585
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1539
1586
  target = null;
1587
+ this.acOpfSolves.get(c)?.abort();
1588
+ this.acOpfSolves.delete(c);
1540
1589
  // Cancel this case's own previous server stream, if any (backend only).
1541
1590
  if (this.isBackendCase(c)) {
1542
1591
  c.closeStream?.();
@@ -1547,11 +1596,12 @@ export class Controller {
1547
1596
  this.app.error = null;
1548
1597
  c.solving = true;
1549
1598
  c.solveBackend = null;
1599
+ c.solveDetail = null;
1550
1600
  c.solveFallbackReason = null;
1551
1601
  c.iterations = [];
1552
1602
  c.solveMs = null;
1553
1603
  this.ensureStudyInputJson(c).then(async (studyInputJson) => {
1554
- if (seq !== (c.solveSeq ?? 0))
1604
+ if (this.disposed || seq !== (c.solveSeq ?? 0))
1555
1605
  return;
1556
1606
  if (!studyInputJson) {
1557
1607
  c.solveFallbackReason ??= 'PowerIO module unavailable';
@@ -1574,6 +1624,45 @@ export class Controller {
1574
1624
  return;
1575
1625
  }
1576
1626
  const t0 = performance.now();
1627
+ if (c.formulation === 'acopf') {
1628
+ if (!this.acOpfAvailable || !this.acOpfWasmUrl) {
1629
+ c.solving = false;
1630
+ this.app.error = `${this.caseName(c)}: the experimental AC OPF worker is unavailable`;
1631
+ return;
1632
+ }
1633
+ if (c.perturbed) {
1634
+ c.solving = false;
1635
+ this.app.error = `${this.caseName(c)}: AC OPF currently solves the canonical base case only; reset demand and rating edits first`;
1636
+ return;
1637
+ }
1638
+ const cancellation = new AbortController();
1639
+ this.acOpfSolves.set(c, cancellation);
1640
+ c.solveBackend = 'pounce-wasi';
1641
+ try {
1642
+ const response = await solveAcOpfModule(this.acOpfWasmUrl, studyInputJson, cancellation.signal);
1643
+ if (seq !== (c.solveSeq ?? 0) || cancellation.signal.aborted)
1644
+ return;
1645
+ c.solution = solveResponseToSolution(response);
1646
+ const diagnostics = response.iterations;
1647
+ c.solveDetail =
1648
+ diagnostics && !Array.isArray(diagnostics) && 'nlp_iterations' in diagnostics
1649
+ ? `${diagnostics.total_nlp_iterations ?? diagnostics.nlp_iterations} NLP iterations${diagnostics.recovered_by ? ' (recovered)' : ''}`
1650
+ : 'POUNCE WASI';
1651
+ c.solveMs = Math.round(performance.now() - t0);
1652
+ this.finishSolve(c, seq, null);
1653
+ }
1654
+ catch (error) {
1655
+ if (seq !== (c.solveSeq ?? 0) || cancellation.signal.aborted)
1656
+ return;
1657
+ c.solving = false;
1658
+ this.app.error = `${this.caseName(c)}: AC OPF failed in the isolated browser worker: ${errorText(error)}`;
1659
+ }
1660
+ finally {
1661
+ if (this.acOpfSolves.get(c) === cancellation)
1662
+ this.acOpfSolves.delete(c);
1663
+ }
1664
+ return;
1665
+ }
1577
1666
  c.solveBackend = 'clarabel-wasm';
1578
1667
  // Build-once Study path: commit the new operating point (no re-parse). The
1579
1668
  // selected nodal value sensitivity column is computed in the same
@@ -1763,12 +1852,12 @@ export class Controller {
1763
1852
  this.fail('This PowerIO instance requires its declared calculation.');
1764
1853
  return;
1765
1854
  }
1766
- if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1855
+ if (!this.formulationAvailable(c, next))
1767
1856
  return;
1768
1857
  c.formulation = next;
1769
1858
  // Demand does not depend on the calculation, so a demand colouring stays.
1770
1859
  if (this.app.displayMode !== 'demand')
1771
- this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1860
+ this.app.displayMode = next === 'acpf' || next === 'acopf' ? 'voltage' : 'price';
1772
1861
  this.bumpRevision(c);
1773
1862
  // The committed point carries over (same demand), but the model and its solution do
1774
1863
  // not; drop the Study and the cached solutions so they rebuild under `next`.
@@ -1777,6 +1866,7 @@ export class Controller {
1777
1866
  c.solution = null;
1778
1867
  c.iterations = [];
1779
1868
  c.solveMs = null;
1869
+ c.solveDetail = null;
1780
1870
  c.predictedObjective = null;
1781
1871
  this.app.previewPrices = null;
1782
1872
  this.previewObjective = null;
@@ -2299,6 +2389,64 @@ export class Controller {
2299
2389
  const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2300
2390
  return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
2301
2391
  };
2392
+ /** Above this many buses, reading and indexing the whole operating point takes a noticeable
2393
+ * fraction of a second (evidence/diagram), so the overview is computed on request instead of
2394
+ * after every solve. */
2395
+ static AUTO_OVERVIEW_MAX_BUSES = 25_000;
2396
+ overviewSeq = 0;
2397
+ /** Whether this case computes its overview only on request. */
2398
+ overviewOnRequest = (c) => (c.graph?.buses.length ?? 0) > Controller.AUTO_OVERVIEW_MAX_BUSES;
2399
+ /** Read the whole operating point into `c.overviewResult` for the overview and the diagram. The
2400
+ * previous result stays visible until the read lands; a newer read supersedes it. */
2401
+ readOverview = (c) => this.startOverviewRead(c, ++this.overviewSeq);
2402
+ startOverviewRead(c, seq) {
2403
+ c.overviewLoading = true;
2404
+ this.multiFullResult(c).then((full) => {
2405
+ if (seq !== this.overviewSeq)
2406
+ return;
2407
+ if (full)
2408
+ c.overviewResult = full;
2409
+ c.overviewLoading = false;
2410
+ }, () => {
2411
+ if (seq === this.overviewSeq)
2412
+ c.overviewLoading = false;
2413
+ });
2414
+ }
2415
+ /** Follow the active case's solves: read its operating point once load edits settle, or clear it
2416
+ * for a case that computes the overview on request. Call from an effect that tracks the case's
2417
+ * `result` and `mcFullResult`; the returned function cancels a pending read. */
2418
+ followOverview = (c) => {
2419
+ const seq = ++this.overviewSeq;
2420
+ if (!c)
2421
+ return;
2422
+ if (c.result?.solve_count === undefined || this.overviewOnRequest(c)) {
2423
+ c.overviewResult = null;
2424
+ c.overviewLoading = false;
2425
+ return;
2426
+ }
2427
+ c.overviewLoading = true;
2428
+ const timer = setTimeout(() => this.startOverviewRead(c, seq), 300);
2429
+ return () => {
2430
+ clearTimeout(timer);
2431
+ // A read still in flight is superseded by the next one, so this case is no longer loading.
2432
+ c.overviewLoading = false;
2433
+ };
2434
+ };
2435
+ /** The complete operating point for whole-network views: the stored result, or one read from
2436
+ * the live session. Null when there is no result or it changed before the read finished. */
2437
+ multiFullResult = async (c) => {
2438
+ if (!c.result)
2439
+ return null;
2440
+ const stored = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2441
+ const session = c.mcSession;
2442
+ if (stored || !session)
2443
+ return stored;
2444
+ if (c.mcEditRunning)
2445
+ return null;
2446
+ const solve = c.result.solve_count;
2447
+ const full = await session.result();
2448
+ return session === c.mcSession && c.result?.solve_count === solve ? full : null;
2449
+ };
2302
2450
  /** Every terminal's identity, voltage, and injected current, as numeric
2303
2451
  * arrays of interleaved `[re, im]` values. */
2304
2452
  multiTerminalTable = async (c) => {
@@ -2736,6 +2884,11 @@ export class Controller {
2736
2884
  * save or export captures exactly what is on screen. Null (with an error set) when no
2737
2885
  * PowerIO module or Study is available. */
2738
2886
  syncedStudy = async (c) => {
2887
+ const unavailable = this.caseExportUnavailableReason(c);
2888
+ if (unavailable) {
2889
+ this.app.error = `${this.caseName(c)}: ${unavailable}`;
2890
+ return null;
2891
+ }
2739
2892
  const studyInputJson = await this.ensureStudyInputJson(c);
2740
2893
  if (!studyInputJson) {
2741
2894
  this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
@@ -2818,7 +2971,7 @@ export class Controller {
2818
2971
  // can't preview (browser fallback path, server-only cases): the map then
2819
2972
  // falls back to the JS sensitivity-times-step preview.
2820
2973
  runPreview = (c, bus, value) => {
2821
- if (c.formulation === 'acpf')
2974
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
2822
2975
  return;
2823
2976
  // Fast path: the committed price/demand column (already solved at the committed point),
2824
2977
  // scaled by the demand step, is the same first-order linearization the engine preview
@@ -2933,7 +3086,7 @@ export class Controller {
2933
3086
  // point (preview is replacement-absolute, so the absolute ratings map is built),
2934
3087
  // then scaled by the live step; null when no Study slope is available.
2935
3088
  runRatingPreview = (c, branch, value) => {
2936
- if (c.formulation === 'acpf')
3089
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
2937
3090
  return;
2938
3091
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
2939
3092
  const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBranch;
@@ -0,0 +1,28 @@
1
+ /** The basemap under geographic positions: the main map's own vector style, drawn by a MapLibre map
2
+ * kept in step with the schematic camera. Geographic positions are Web Mercator metres (y down)
3
+ * relative to an origin, so the camera converts exactly. */
4
+ /** Shared with the main map, so both backgrounds look the same. */
5
+ export declare const BASEMAP_STYLE = "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json";
6
+ export declare const BASEMAP_ATTRIBUTION = "\u00A9 CARTO \u00A9 OpenStreetMap contributors";
7
+ /** Longitude and latitude in degrees of a Web Mercator point in metres, y down. */
8
+ export declare function mercatorToLngLat(x: number, y: number): [number, number];
9
+ /** The MapLibre camera showing what a schematic camera shows: same centre, and a zoom at which
10
+ * one metre spans the same pixels (MapLibre's world is 512 px wide at zoom 0). */
11
+ export declare function basemapCamera(camera: {
12
+ target: [number, number, number] | [number, number];
13
+ zoom: number;
14
+ }, origin: [number, number]): {
15
+ center: [number, number];
16
+ zoom: number;
17
+ };
18
+ /** Web Mercator metres (y down) of a longitude and latitude in degrees. */
19
+ export declare function lngLatToMercator(lng: number, lat: number): [number, number];
20
+ /** The schematic camera showing what a MapLibre camera shows (the inverse of
21
+ * {@link basemapCamera}), for opening the diagram where the map was. */
22
+ export declare function cameraFromMap(map: {
23
+ center: [number, number];
24
+ zoom: number;
25
+ }, origin: [number, number]): {
26
+ target: [number, number, number];
27
+ zoom: number;
28
+ };
@@ -0,0 +1,39 @@
1
+ /** The basemap under geographic positions: the main map's own vector style, drawn by a MapLibre map
2
+ * kept in step with the schematic camera. Geographic positions are Web Mercator metres (y down)
3
+ * relative to an origin, so the camera converts exactly. */
4
+ const R = 6_378_137;
5
+ const WORLD = 2 * Math.PI * R;
6
+ /** Shared with the main map, so both backgrounds look the same. */
7
+ export const BASEMAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
8
+ export const BASEMAP_ATTRIBUTION = '© CARTO © OpenStreetMap contributors';
9
+ /** Longitude and latitude in degrees of a Web Mercator point in metres, y down. */
10
+ export function mercatorToLngLat(x, y) {
11
+ const lng = ((x / R) * 180) / Math.PI;
12
+ const lat = ((2 * Math.atan(Math.exp(-y / R)) - Math.PI / 2) * 180) / Math.PI;
13
+ return [lng, lat];
14
+ }
15
+ /** The MapLibre camera showing what a schematic camera shows: same centre, and a zoom at which
16
+ * one metre spans the same pixels (MapLibre's world is 512 px wide at zoom 0). */
17
+ export function basemapCamera(camera, origin) {
18
+ return {
19
+ center: mercatorToLngLat(camera.target[0] + origin[0], camera.target[1] + origin[1]),
20
+ zoom: Math.log2((WORLD * 2 ** camera.zoom) / 512)
21
+ };
22
+ }
23
+ /** Web Mercator metres (y down) of a longitude and latitude in degrees. */
24
+ export function lngLatToMercator(lng, lat) {
25
+ const clamped = Math.max(-85, Math.min(85, lat));
26
+ return [
27
+ (R * lng * Math.PI) / 180,
28
+ -R * Math.log(Math.tan(Math.PI / 4 + (clamped * Math.PI) / 360))
29
+ ];
30
+ }
31
+ /** The schematic camera showing what a MapLibre camera shows (the inverse of
32
+ * {@link basemapCamera}), for opening the diagram where the map was. */
33
+ export function cameraFromMap(map, origin) {
34
+ const [x, y] = lngLatToMercator(map.center[0], map.center[1]);
35
+ return {
36
+ target: [x - origin[0], y - origin[1], 0],
37
+ zoom: Math.log2((512 * 2 ** map.zoom) / WORLD)
38
+ };
39
+ }
@@ -0,0 +1,19 @@
1
+ import type { DistGraph } from '@tellegen/engine';
2
+ import type { SchematicGeometry } from './schematic-layout.js';
3
+ /** Buses drawn at exactly the same point (an MV and an LV bus at one pole, say). */
4
+ export interface BusStacks {
5
+ /** Groups of two or more bus indices, in graph order. */
6
+ groups: number[][];
7
+ /** Per bus: its group index, or −1 when it stands alone. */
8
+ stackOf: Int32Array;
9
+ }
10
+ export declare function busStacks(geometry: SchematicGeometry): BusStacks;
11
+ /** Offsets in drawing units that spread a stack's members on a circle of `radiusPx` screen pixels
12
+ * (`scale` pixels per unit): two side by side, more around the circle from the top. */
13
+ export declare function spreadOffsets(count: number, scale: number, radiusPx?: number): [number, number][];
14
+ /** `geometry` with the given buses moved by their offsets, and every branch end at a moved bus
15
+ * moved with it (interior route points stay put, so routes bend from the spread bus back onto
16
+ * their path). A branch whose two ends share a point (a pole transformer between stacked buses)
17
+ * takes its first end as `from` and its last as `to`. Returns `geometry` itself when nothing
18
+ * moves. */
19
+ export declare function displaceBuses(graph: DistGraph, geometry: SchematicGeometry, offsets: Map<number, [number, number]>): SchematicGeometry;
@@ -0,0 +1,99 @@
1
+ export function busStacks(geometry) {
2
+ const n = geometry.positions.length / 3;
3
+ const byPoint = new Map();
4
+ for (let i = 0; i < n; i++) {
5
+ const key = `${geometry.positions[i * 3]},${geometry.positions[i * 3 + 1]}`;
6
+ const list = byPoint.get(key);
7
+ if (list)
8
+ list.push(i);
9
+ else
10
+ byPoint.set(key, [i]);
11
+ }
12
+ const groups = [];
13
+ const stackOf = new Int32Array(n).fill(-1);
14
+ for (const list of byPoint.values())
15
+ if (list.length > 1) {
16
+ for (const i of list)
17
+ stackOf[i] = groups.length;
18
+ groups.push(list);
19
+ }
20
+ return { groups, stackOf };
21
+ }
22
+ /** Offsets in drawing units that spread a stack's members on a circle of `radiusPx` screen pixels
23
+ * (`scale` pixels per unit): two side by side, more around the circle from the top. */
24
+ export function spreadOffsets(count, scale, radiusPx = 12) {
25
+ const r = radiusPx / scale;
26
+ if (count === 2)
27
+ return [
28
+ [-r, 0],
29
+ [r, 0]
30
+ ];
31
+ return Array.from({ length: count }, (_, i) => {
32
+ const a = -Math.PI / 2 + (2 * Math.PI * i) / count;
33
+ return [r * Math.cos(a), r * Math.sin(a)];
34
+ });
35
+ }
36
+ /** `geometry` with the given buses moved by their offsets, and every branch end at a moved bus
37
+ * moved with it (interior route points stay put, so routes bend from the spread bus back onto
38
+ * their path). A branch whose two ends share a point (a pole transformer between stacked buses)
39
+ * takes its first end as `from` and its last as `to`. Returns `geometry` itself when nothing
40
+ * moves. */
41
+ export function displaceBuses(graph, geometry, offsets) {
42
+ if (!offsets.size)
43
+ return geometry;
44
+ const positions = Float32Array.from(geometry.positions);
45
+ for (const [i, [dx, dy]] of offsets) {
46
+ positions[i * 3] += dx;
47
+ positions[i * 3 + 1] += dy;
48
+ }
49
+ const sources = Float32Array.from(geometry.sources);
50
+ const targets = Float32Array.from(geometry.targets);
51
+ const index = new Map(graph.buses.map((b, i) => [b.id.toLowerCase(), i]));
52
+ // First and last segment of each edge.
53
+ const first = new Map();
54
+ const last = new Map();
55
+ for (let s = 0; s < geometry.segmentEdge.length; s++) {
56
+ const k = geometry.segmentEdge[s];
57
+ if (!first.has(k))
58
+ first.set(k, s);
59
+ last.set(k, s);
60
+ }
61
+ const at = (i) => [
62
+ geometry.positions[i * 3],
63
+ geometry.positions[i * 3 + 1]
64
+ ];
65
+ const near = (x, y, i) => {
66
+ const [px, py] = at(i);
67
+ return x === px && y === py;
68
+ };
69
+ graph.edges.forEach((e, k) => {
70
+ const s0 = first.get(k);
71
+ const s1 = last.get(k);
72
+ if (s0 === undefined || s1 === undefined)
73
+ return;
74
+ const a = index.get(e.from.toLowerCase()) ?? -1;
75
+ const b = index.get(e.to.toLowerCase()) ?? -1;
76
+ const sx = geometry.sources[s0 * 3];
77
+ const sy = geometry.sources[s0 * 3 + 1];
78
+ const tx = geometry.targets[s1 * 3];
79
+ const ty = geometry.targets[s1 * 3 + 1];
80
+ // Which bus each end belongs to: the one drawn there, `from` first when both are.
81
+ const startBus = a >= 0 && near(sx, sy, a) ? a : b >= 0 && near(sx, sy, b) ? b : -1;
82
+ const endBus = b >= 0 && near(tx, ty, b) && b !== startBus
83
+ ? b
84
+ : a >= 0 && near(tx, ty, a) && a !== startBus
85
+ ? a
86
+ : -1;
87
+ const ds = startBus >= 0 ? offsets.get(startBus) : undefined;
88
+ const de = endBus >= 0 ? offsets.get(endBus) : undefined;
89
+ if (ds) {
90
+ sources[s0 * 3] += ds[0];
91
+ sources[s0 * 3 + 1] += ds[1];
92
+ }
93
+ if (de) {
94
+ targets[s1 * 3] += de[0];
95
+ targets[s1 * 3 + 1] += de[1];
96
+ }
97
+ });
98
+ return { ...geometry, positions, sources, targets };
99
+ }
@@ -0,0 +1,18 @@
1
+ import { type RGBA } from '../colors.js';
2
+ import type { PairVoltage } from './snapshot.js';
3
+ export type ColourMode = 'none' | 'voltage' | 'voltage-band' | 'loading' | 'loading-heat';
4
+ export declare const COLOUR_MODES: Record<ColourMode, {
5
+ label: string;
6
+ continuous: boolean;
7
+ }>;
8
+ /** Threshold and continuous variants of one quantity share issue focus, warnings and presets. */
9
+ export declare function colourFamily(mode: ColourMode): 'voltage' | 'loading' | 'none';
10
+ /** Position in the declared band: −1 at min, 0 mid-band, +1 at max; beyond ±1 is a violation. */
11
+ export declare function bandPosition(p: PairVoltage): number | null;
12
+ /** The pair nearest a limit decides a bus colour, so a violation can never be averaged away. */
13
+ export declare function worstBand(pairs: PairVoltage[]): number | null;
14
+ /** The application's sequential ramp; light loads start part-way up so they stay visible on paper. */
15
+ export declare const loadingColour: (loading: number) => RGBA;
16
+ /** The application's diverging ramp over the declared band, clamped at the limits. */
17
+ export declare const bandColour: (t: number) => RGBA;
18
+ export declare const MISSING_COLOUR: RGBA;