@tellegen/svelte 0.4.3 → 0.4.5

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 (115) hide show
  1. package/README.md +45 -0
  2. package/dist/TellegenMap.svelte +63 -22
  3. package/dist/TellegenProvider.svelte +7 -4
  4. package/dist/TellegenShell.svelte +12 -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/DiagnosticLogView.svelte +176 -0
  11. package/dist/components/DiagnosticLogView.svelte.d.ts +11 -0
  12. package/dist/components/FormulationSelector.svelte +1 -1
  13. package/dist/components/LocalCaseDetails.svelte +18 -3
  14. package/dist/components/LogEntryView.svelte +188 -0
  15. package/dist/components/LogEntryView.svelte.d.ts +11 -0
  16. package/dist/components/LogPanel.svelte +25 -0
  17. package/dist/components/LogPanel.svelte.d.ts +3 -0
  18. package/dist/components/McResults.svelte +18 -3
  19. package/dist/components/McWorkbench.svelte +688 -0
  20. package/dist/components/McWorkbench.svelte.d.ts +38 -0
  21. package/dist/components/MultiWire.svelte +136 -0
  22. package/dist/components/MultiWire.svelte.d.ts +7 -0
  23. package/dist/components/MulticonductorDetails.svelte +7 -0
  24. package/dist/components/NotificationCenter.svelte +37 -6
  25. package/dist/components/OmniSearch.svelte +163 -0
  26. package/dist/components/OmniSearch.svelte.d.ts +14 -0
  27. package/dist/components/ResultsTable.svelte +259 -0
  28. package/dist/components/ResultsTable.svelte.d.ts +13 -0
  29. package/dist/components/SchematicCanvas.svelte +1896 -0
  30. package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
  31. package/dist/components/SolveCard.svelte +6 -0
  32. package/dist/components/schematic/PickCard.svelte +126 -0
  33. package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
  34. package/dist/components/schematic/export.d.ts +21 -0
  35. package/dist/components/schematic/export.js +110 -0
  36. package/dist/components/schematic/layers.d.ts +161 -0
  37. package/dist/components/schematic/layers.js +756 -0
  38. package/dist/components/schematic/paint.d.ts +28 -0
  39. package/dist/components/schematic/paint.js +69 -0
  40. package/dist/components/schematic/palette.d.ts +34 -0
  41. package/dist/components/schematic/palette.js +54 -0
  42. package/dist/components/schematic/text.d.ts +83 -0
  43. package/dist/components/schematic/text.js +190 -0
  44. package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
  45. package/dist/components/schematic/view-prefs.svelte.js +77 -0
  46. package/dist/components.d.ts +4 -0
  47. package/dist/components.js +4 -0
  48. package/dist/context.svelte.d.ts +2 -0
  49. package/dist/context.svelte.js +2 -0
  50. package/dist/controller.svelte.d.ts +40 -5
  51. package/dist/controller.svelte.js +230 -25
  52. package/dist/diagram/basemap.d.ts +28 -0
  53. package/dist/diagram/basemap.js +39 -0
  54. package/dist/diagram/bus-stacks.d.ts +19 -0
  55. package/dist/diagram/bus-stacks.js +99 -0
  56. package/dist/diagram/colour-modes.d.ts +18 -0
  57. package/dist/diagram/colour-modes.js +33 -0
  58. package/dist/diagram/connections.d.ts +30 -0
  59. package/dist/diagram/connections.js +129 -0
  60. package/dist/diagram/display-tree.d.ts +21 -0
  61. package/dist/diagram/display-tree.js +65 -0
  62. package/dist/diagram/energisation.d.ts +10 -0
  63. package/dist/diagram/energisation.js +31 -0
  64. package/dist/diagram/feeders.d.ts +30 -0
  65. package/dist/diagram/feeders.js +57 -0
  66. package/dist/diagram/galvanic-zones.d.ts +60 -0
  67. package/dist/diagram/galvanic-zones.js +210 -0
  68. package/dist/diagram/graph-inputs.d.ts +18 -0
  69. package/dist/diagram/graph-inputs.js +103 -0
  70. package/dist/diagram/index.d.ts +28 -0
  71. package/dist/diagram/index.js +28 -0
  72. package/dist/diagram/landmarks.d.ts +50 -0
  73. package/dist/diagram/landmarks.js +153 -0
  74. package/dist/diagram/layout-morph.d.ts +15 -0
  75. package/dist/diagram/layout-morph.js +32 -0
  76. package/dist/diagram/multi-wire.d.ts +44 -0
  77. package/dist/diagram/multi-wire.js +65 -0
  78. package/dist/diagram/phase-bars.d.ts +35 -0
  79. package/dist/diagram/phase-bars.js +44 -0
  80. package/dist/diagram/results-table.d.ts +47 -0
  81. package/dist/diagram/results-table.js +173 -0
  82. package/dist/diagram/schematic-equipment.d.ts +133 -0
  83. package/dist/diagram/schematic-equipment.js +645 -0
  84. package/dist/diagram/schematic-layout.d.ts +51 -0
  85. package/dist/diagram/schematic-layout.js +240 -0
  86. package/dist/diagram/search.d.ts +36 -0
  87. package/dist/diagram/search.js +48 -0
  88. package/dist/diagram/shared-routes.d.ts +21 -0
  89. package/dist/diagram/shared-routes.js +40 -0
  90. package/dist/diagram/short-names.d.ts +11 -0
  91. package/dist/diagram/short-names.js +54 -0
  92. package/dist/diagram/snapshot.d.ts +115 -0
  93. package/dist/diagram/snapshot.js +242 -0
  94. package/dist/diagram/summary.d.ts +65 -0
  95. package/dist/diagram/summary.js +144 -0
  96. package/dist/diagram/svg-export.d.ts +80 -0
  97. package/dist/diagram/svg-export.js +108 -0
  98. package/dist/diagram/tree-walk.d.ts +13 -0
  99. package/dist/diagram/tree-walk.js +83 -0
  100. package/dist/diagram/value-labels.d.ts +24 -0
  101. package/dist/diagram/value-labels.js +69 -0
  102. package/dist/diagram/view-state.d.ts +12 -0
  103. package/dist/diagram/view-state.js +22 -0
  104. package/dist/diagram/voltage-levels.d.ts +14 -0
  105. package/dist/diagram/voltage-levels.js +20 -0
  106. package/dist/index.d.ts +3 -0
  107. package/dist/index.js +2 -0
  108. package/dist/log.svelte.d.ts +77 -0
  109. package/dist/log.svelte.js +149 -0
  110. package/dist/notices.svelte.d.ts +3 -0
  111. package/dist/state.svelte.d.ts +42 -5
  112. package/dist/state.svelte.js +56 -4
  113. package/dist/transformer-icon.d.ts +22 -13
  114. package/dist/transformer-icon.js +23 -13
  115. 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. */
@@ -20,6 +20,9 @@ type SavedCaseCapture = {
20
20
  export declare function isDisplayOnlyElement(element: {
21
21
  editable?: boolean;
22
22
  } | null | undefined): boolean;
23
+ /** Trigger a browser download of in-memory text. Case files never leave the machine:
24
+ * a save is a local Blob download. */
25
+ export declare function downloadText(text: string, filename: string, mime: string): void;
23
26
  export type { SolvableCase };
24
27
  type DemandRangeAnchor = {
25
28
  caseId: string;
@@ -34,6 +37,7 @@ export interface ControllerOptions {
34
37
  /** Whether `load()` fetches the bundled cases. Default `true`. With
35
38
  * `false`, `load()` only marks the shell loaded and frames the map. */
36
39
  loadDefaultCases?: boolean;
40
+ acOpfWasmUrl?: string;
37
41
  mcTransport?: Pick<EngineTransport, 'solveMcModule' | 'applyMcGeo'> & Partial<Pick<EngineTransport, 'solveMcStudy' | 'applyMcStudyGeo' | 'createMcPfSession'>>;
38
42
  }
39
43
  /** Input to `Controller.openBalancedModule` and `replaceLocalCase`: a
@@ -66,6 +70,12 @@ export declare class Controller {
66
70
  abort: AbortController | null;
67
71
  sensitivity429Until: number;
68
72
  serverComputeOff: boolean;
73
+ /** The optional EPL-bearing WASI asset is never inferred or bundled. */
74
+ private readonly acOpfWasmUrl;
75
+ private acOpfProbeStarted;
76
+ private readonly acOpfProbeAbort;
77
+ private readonly acOpfSolves;
78
+ acOpfAvailable: boolean;
69
79
  caseStudies: WeakMap<SolvableCase, {
70
80
  study: BrowserStudy;
71
81
  studyInputJson: string;
@@ -111,6 +121,9 @@ export declare class Controller {
111
121
  /** True when two selection targets name the same bus or branch. */
112
122
  sameTarget(a: SensTarget, b: SensTarget): boolean;
113
123
  isPerturbed(c: SolvableCase | null): boolean;
124
+ formulationAvailable(c: SolvableCase, formulation: Formulation): boolean;
125
+ probeAcOpf: () => void;
126
+ caseExportUnavailableReason(c: SolvableCase): string | null;
114
127
  bumpRevision(c: SolvableCase): void;
115
128
  setNearbyRangeAnchor(c: SolvableCase, bus: number, delta?: number): void;
116
129
  beginBusSelection(c: SolvableCase, busId: number): {
@@ -131,10 +144,10 @@ export declare class Controller {
131
144
  getStudy(c: SolvableCase, studyInputJson: string): Promise<BrowserStudy | null>;
132
145
  browserSensitivity(c: SolvableCase, studyInputJson: string, target: SensTarget): Promise<SensitivityColumn | null>;
133
146
  /** 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. */
147
+ * Study, including one still being built, its AC OPF solve, each
148
+ * multiconductor session, with their pending edits and in-flight requests,
149
+ * and the AC OPF asset probe. `TellegenProvider` calls it when it unmounts;
150
+ * the controller builds no Study and keeps no new session afterwards. */
138
151
  dispose(): void;
139
152
  disposeStudy(c: SolvableCase): void;
140
153
  acceptSensitivity(c: SolvableCase, col: SensitivityColumn | null, target: SensTarget | null, sensitivitySeq?: number): void;
@@ -354,6 +367,24 @@ export declare class Controller {
354
367
  * operating point, from the live session or the stored full result. Null
355
368
  * when the operating point changed before the page arrived. */
356
369
  multiResultDetail: (c: MulticonductorCase, query: McPfDetailQuery) => Promise<McPfDetail | null>;
370
+ /** Above this many buses, reading and indexing the whole operating point takes a noticeable
371
+ * fraction of a second (evidence/diagram), so the overview is computed on request instead of
372
+ * after every solve. */
373
+ static readonly AUTO_OVERVIEW_MAX_BUSES = 25000;
374
+ private overviewSeq;
375
+ /** Whether this case computes its overview only on request. */
376
+ overviewOnRequest: (c: MulticonductorCase) => boolean;
377
+ /** Read the whole operating point into `c.overviewResult` for the overview and the diagram. The
378
+ * previous result stays visible until the read lands; a newer read supersedes it. */
379
+ readOverview: (c: MulticonductorCase) => void;
380
+ private startOverviewRead;
381
+ /** Follow the active case's solves: read its operating point once load edits settle, or clear it
382
+ * for a case that computes the overview on request. Call from an effect that tracks the case's
383
+ * `result` and `mcFullResult`; the returned function cancels a pending read. */
384
+ followOverview: (c: MulticonductorCase | null) => (() => void) | undefined;
385
+ /** The complete operating point for whole-network views: the stored result, or one read from
386
+ * the live session. Null when there is no result or it changed before the read finished. */
387
+ multiFullResult: (c: MulticonductorCase) => Promise<McPfResult | null>;
357
388
  /** Every terminal's identity, voltage, and injected current, as numeric
358
389
  * arrays of interleaved `[re, im]` values. */
359
390
  multiTerminalTable: (c: MulticonductorCase) => Promise<{
@@ -388,6 +419,10 @@ export declare class Controller {
388
419
  caseGeographyLayers(c: LocalCase): Promise<string[]>;
389
420
  /** Capture a matching cached result without constructing or solving a model. */
390
421
  captureSavedCase(c: SolvableCase): Promise<SavedCaseCapture>;
422
+ /** Record what PowerIO and the engine said while reading a case file. */
423
+ private logImport;
424
+ /** Record a geographic layer's reader findings and placement notes. */
425
+ private logCoordinates;
391
426
  /** Build a local case from a balanced PowerIO ingest payload without activating
392
427
  * it yet; co-dropped placement data is applied before the first solve. */
393
428
  private localFromBalancedPayload;
@@ -3,10 +3,11 @@ import { previewScaleFor, scalarDomain, sensFlatColor, sensitivityDomain } from
3
3
  import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displayMetaFor, displaySeriesFor } from './display.js';
4
4
  import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
5
5
  import { placeSyntheticTopology } from './synthetic-layout.js';
6
+ import { powerIoEntry, solveEntry } from './log.svelte.js';
6
7
  import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
7
8
  import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
8
9
  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';
10
+ 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
11
  import { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
12
  import { DEAD_ZONE_MW } from './slider.js';
12
13
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
@@ -52,7 +53,7 @@ function delayUntilFocusSettles(ms, signal) {
52
53
  }
53
54
  /** Trigger a browser download of in-memory text. Case files never leave the machine:
54
55
  * a save is a local Blob download. */
55
- function downloadText(text, filename, mime) {
56
+ export function downloadText(text, filename, mime) {
56
57
  const url = URL.createObjectURL(new Blob([text], { type: mime }));
57
58
  const anchor = document.createElement('a');
58
59
  anchor.href = url;
@@ -83,6 +84,12 @@ export class Controller {
83
84
  // Latched once any server fallback answers 403: compute is disabled on this
84
85
  // deploy, so later fallbacks show the notice instead of firing doomed requests.
85
86
  serverComputeOff = false;
87
+ /** The optional EPL-bearing WASI asset is never inferred or bundled. */
88
+ acOpfWasmUrl;
89
+ acOpfProbeStarted = false;
90
+ acOpfProbeAbort = new AbortController();
91
+ acOpfSolves = new Map();
92
+ acOpfAvailable = $state(false);
86
93
  // Build-once browser Study per case: the retained PowerIO module is parsed and the model built
87
94
  // when the Study is created, so a drag re-solves (commit) and previews without
88
95
  // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
@@ -131,6 +138,7 @@ export class Controller {
131
138
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
132
139
  this.loadDefaultCases = options.loadDefaultCases ?? true;
133
140
  this.mcTransport = options.mcTransport ?? browserWasmTransport;
141
+ this.acOpfWasmUrl = options.acOpfWasmUrl ?? null;
134
142
  }
135
143
  // ===== helpers =====
136
144
  isBackendCase(c) {
@@ -158,6 +166,31 @@ export class Controller {
158
166
  isPerturbed(c) {
159
167
  return c?.perturbed ?? false;
160
168
  }
169
+ formulationAvailable(c, formulation) {
170
+ if (formulation === 'acopf')
171
+ return this.acOpfAvailable && !c.perturbed;
172
+ return !FORMULATIONS.find((entry) => entry.id === formulation)?.disabled;
173
+ }
174
+ probeAcOpf = () => {
175
+ if (this.disposed || this.acOpfProbeStarted || !this.acOpfWasmUrl)
176
+ return;
177
+ this.acOpfProbeStarted = true;
178
+ void probeAcOpfWorker(this.acOpfWasmUrl, undefined, this.acOpfProbeAbort.signal).then((available) => {
179
+ if (this.disposed)
180
+ return;
181
+ this.acOpfAvailable = available;
182
+ // A declared instance can be dropped while the optional asset is probing.
183
+ const c = this.app.activeLocal;
184
+ if (available && c?.formulation === 'acopf' && !c.solving) {
185
+ this.maybeStartLocalSolve(c.id);
186
+ }
187
+ });
188
+ };
189
+ caseExportUnavailableReason(c) {
190
+ return c.formulation === 'acopf'
191
+ ? 'Saving and exporting AC OPF cases is not supported yet.'
192
+ : null;
193
+ }
161
194
  bumpRevision(c) {
162
195
  c.revisionGeneration += 1;
163
196
  }
@@ -352,18 +385,24 @@ export class Controller {
352
385
  return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
353
386
  }
354
387
  /** 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. */
388
+ * Study, including one still being built, its AC OPF solve, each
389
+ * multiconductor session, with their pending edits and in-flight requests,
390
+ * and the AC OPF asset probe. `TellegenProvider` calls it when it unmounts;
391
+ * the controller builds no Study and keeps no new session afterwards. */
359
392
  dispose() {
360
393
  if (this.disposed)
361
394
  return;
362
395
  this.disposed = true;
363
396
  this.abort?.abort();
364
397
  this.abort = null;
365
- for (const c of [...this.app.cases, ...this.app.localCases])
398
+ this.acOpfProbeAbort.abort();
399
+ for (const cancellation of this.acOpfSolves.values())
400
+ cancellation.abort();
401
+ this.acOpfSolves.clear();
402
+ for (const c of [...this.app.cases, ...this.app.localCases]) {
403
+ c.solveSeq = (c.solveSeq ?? 0) + 1;
366
404
  this.disposeStudy(c);
405
+ }
367
406
  for (const c of this.app.multiCases) {
368
407
  c.solveAbort?.abort();
369
408
  if (c.mcEditTimer !== null)
@@ -375,6 +414,8 @@ export class Controller {
375
414
  }
376
415
  // Release a case's Study (if any) when the case is removed.
377
416
  disposeStudy(c) {
417
+ this.acOpfSolves.get(c)?.abort();
418
+ this.acOpfSolves.delete(c);
378
419
  const cached = this.caseStudies.get(c);
379
420
  if (cached) {
380
421
  cached.study.free();
@@ -403,6 +444,10 @@ export class Controller {
403
444
  if (seq !== (c.solveSeq ?? 0))
404
445
  return;
405
446
  c.solving = false;
447
+ // Every solve of a case restates the same findings; they count once.
448
+ const findings = c.solution?.diagnostics ?? [];
449
+ if (findings.length)
450
+ this.app.log.record(findings.map((d) => solveEntry(d, { subject: this.caseName(c) })), { repeat: false });
406
451
  const selectionLive = target !== null &&
407
452
  this.isActiveSolveCase(c) &&
408
453
  ('bus' in target
@@ -557,7 +602,9 @@ export class Controller {
557
602
  sliderBounds = $derived.by(() => this.demandBounds(this.app.demandRangeMode, this.selectedBusData, this.nearbyRangeCenter));
558
603
  sliderMin = $derived(this.sliderBounds.min);
559
604
  sliderMax = $derived(this.sliderBounds.max);
560
- sliderDisabled = $derived(!this.selectedBusData || isDisplayOnlyElement(this.selectedBusData));
605
+ sliderDisabled = $derived(this.activeFormulation === 'acopf' ||
606
+ !this.selectedBusData ||
607
+ isDisplayOnlyElement(this.selectedBusData));
561
608
  committedRating = $derived.by(() => this.activeSolvable && this.app.selectedBranch !== null
562
609
  ? (this.caseRatings(this.activeSolvable)[this.app.selectedBranch] ?? 0)
563
610
  : 0);
@@ -569,7 +616,7 @@ export class Controller {
569
616
  // to perturb.
570
617
  ratingBounds = $derived.by(() => {
571
618
  const b = this.selectedBranchData;
572
- if (!b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
619
+ if (this.activeFormulation === 'acopf' || !b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
573
620
  return { min: 0, max: 0, disabled: true };
574
621
  const span = Math.min(50, Math.max(5, 0.2 * b.rate_mw));
575
622
  return {
@@ -681,6 +728,7 @@ export class Controller {
681
728
  if (this.loading)
682
729
  return this.loading;
683
730
  this.loading = (async () => {
731
+ this.probeAcOpf();
684
732
  // Learn the deploy's compute gate up front so fallback paths pick honest
685
733
  // copy and skip doomed requests (the SSE stream cannot see a 403). On
686
734
  // failure assume enabled; the 403 latch in fetchServerColumn still
@@ -842,7 +890,7 @@ export class Controller {
842
890
  this.app.activeCaseId = null;
843
891
  this.leaveMulti();
844
892
  this.app.activeLocalId = c.id;
845
- if (c.formulation === 'acpf' && this.app.displayMode === 'price')
893
+ if ((c.formulation === 'acpf' || c.formulation === 'acopf') && this.app.displayMode === 'price')
846
894
  this.app.displayMode = 'voltage';
847
895
  this.app.placingLocalId = c.coordsKind === 'synthetic_pending' && !c.diagram ? c.id : null;
848
896
  if (c.view || c.diagram || c.substations)
@@ -868,7 +916,7 @@ export class Controller {
868
916
  this.app.activeCaseId = null;
869
917
  this.leaveMulti();
870
918
  this.app.addLocal(c);
871
- if (c.formulation === 'acpf' && this.app.displayMode === 'price')
919
+ if ((c.formulation === 'acpf' || c.formulation === 'acopf') && this.app.displayMode === 'price')
872
920
  this.app.displayMode = 'voltage';
873
921
  if (c.diagram)
874
922
  this.app.placingLocalId = null;
@@ -976,6 +1024,7 @@ export class Controller {
976
1024
  routes += payload.report.matched_branches;
977
1025
  unmatched += payload.report.unmatched_features;
978
1026
  warnings.push(...l.diagnostics.map((d) => `${l.name}: ${formatPowerIoDiagnostic(d)}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
1027
+ this.logCoordinates(l.name, l.diagnostics, payload.report.notes);
979
1028
  }
980
1029
  if (!payload)
981
1030
  return;
@@ -1049,6 +1098,7 @@ export class Controller {
1049
1098
  copy.iterations = source.iterations;
1050
1099
  copy.solveMs = source.solveMs;
1051
1100
  copy.solveBackend = source.solveBackend;
1101
+ copy.solveDetail = source.solveDetail;
1052
1102
  copy.network = source.network;
1053
1103
  if (source.network) {
1054
1104
  copy.view = {
@@ -1121,7 +1171,8 @@ export class Controller {
1121
1171
  'baseSolution',
1122
1172
  'iterations',
1123
1173
  'solveMs',
1124
- 'solveBackend'
1174
+ 'solveBackend',
1175
+ 'solveDetail'
1125
1176
  ])
1126
1177
  Object.assign(copy, { [key]: source[key] });
1127
1178
  const captured = this.detachedCaseCaptures.get(source);
@@ -1276,7 +1327,7 @@ export class Controller {
1276
1327
  if (ac.signal.aborted)
1277
1328
  return;
1278
1329
  }
1279
- if (c.formulation === 'acpf')
1330
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1280
1331
  return;
1281
1332
  try {
1282
1333
  // The column from the browser Study (under the case's formulation). DC OPF
@@ -1412,7 +1463,7 @@ export class Controller {
1412
1463
  this.app.placingLocalId = null;
1413
1464
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1414
1465
  try {
1415
- if (c.formulation === 'acpf')
1466
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1416
1467
  return;
1417
1468
  const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1418
1469
  bus: busId
@@ -1471,7 +1522,7 @@ export class Controller {
1471
1522
  if (ac.signal.aborted)
1472
1523
  return;
1473
1524
  }
1474
- if (c.formulation === 'acpf')
1525
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1475
1526
  return;
1476
1527
  const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1477
1528
  branch: branchId
@@ -1535,8 +1586,12 @@ export class Controller {
1535
1586
  // fall back: the server solves at base ratings, so a Study failure there is
1536
1587
  // terminal.
1537
1588
  runSolve = (c, target) => {
1538
- if (c.formulation === 'acpf')
1589
+ if (this.disposed)
1590
+ return;
1591
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
1539
1592
  target = null;
1593
+ this.acOpfSolves.get(c)?.abort();
1594
+ this.acOpfSolves.delete(c);
1540
1595
  // Cancel this case's own previous server stream, if any (backend only).
1541
1596
  if (this.isBackendCase(c)) {
1542
1597
  c.closeStream?.();
@@ -1547,11 +1602,12 @@ export class Controller {
1547
1602
  this.app.error = null;
1548
1603
  c.solving = true;
1549
1604
  c.solveBackend = null;
1605
+ c.solveDetail = null;
1550
1606
  c.solveFallbackReason = null;
1551
1607
  c.iterations = [];
1552
1608
  c.solveMs = null;
1553
1609
  this.ensureStudyInputJson(c).then(async (studyInputJson) => {
1554
- if (seq !== (c.solveSeq ?? 0))
1610
+ if (this.disposed || seq !== (c.solveSeq ?? 0))
1555
1611
  return;
1556
1612
  if (!studyInputJson) {
1557
1613
  c.solveFallbackReason ??= 'PowerIO module unavailable';
@@ -1574,6 +1630,45 @@ export class Controller {
1574
1630
  return;
1575
1631
  }
1576
1632
  const t0 = performance.now();
1633
+ if (c.formulation === 'acopf') {
1634
+ if (!this.acOpfAvailable || !this.acOpfWasmUrl) {
1635
+ c.solving = false;
1636
+ this.app.error = `${this.caseName(c)}: the experimental AC OPF worker is unavailable`;
1637
+ return;
1638
+ }
1639
+ if (c.perturbed) {
1640
+ c.solving = false;
1641
+ this.app.error = `${this.caseName(c)}: AC OPF currently solves the canonical base case only; reset demand and rating edits first`;
1642
+ return;
1643
+ }
1644
+ const cancellation = new AbortController();
1645
+ this.acOpfSolves.set(c, cancellation);
1646
+ c.solveBackend = 'pounce-wasi';
1647
+ try {
1648
+ const response = await solveAcOpfModule(this.acOpfWasmUrl, studyInputJson, cancellation.signal);
1649
+ if (seq !== (c.solveSeq ?? 0) || cancellation.signal.aborted)
1650
+ return;
1651
+ c.solution = solveResponseToSolution(response);
1652
+ const diagnostics = response.iterations;
1653
+ c.solveDetail =
1654
+ diagnostics && !Array.isArray(diagnostics) && 'nlp_iterations' in diagnostics
1655
+ ? `${diagnostics.total_nlp_iterations ?? diagnostics.nlp_iterations} NLP iterations${diagnostics.recovered_by ? ' (recovered)' : ''}`
1656
+ : 'POUNCE WASI';
1657
+ c.solveMs = Math.round(performance.now() - t0);
1658
+ this.finishSolve(c, seq, null);
1659
+ }
1660
+ catch (error) {
1661
+ if (seq !== (c.solveSeq ?? 0) || cancellation.signal.aborted)
1662
+ return;
1663
+ c.solving = false;
1664
+ this.app.error = `${this.caseName(c)}: AC OPF failed in the isolated browser worker: ${errorText(error)}`;
1665
+ }
1666
+ finally {
1667
+ if (this.acOpfSolves.get(c) === cancellation)
1668
+ this.acOpfSolves.delete(c);
1669
+ }
1670
+ return;
1671
+ }
1577
1672
  c.solveBackend = 'clarabel-wasm';
1578
1673
  // Build-once Study path: commit the new operating point (no re-parse). The
1579
1674
  // selected nodal value sensitivity column is computed in the same
@@ -1763,12 +1858,12 @@ export class Controller {
1763
1858
  this.fail('This PowerIO instance requires its declared calculation.');
1764
1859
  return;
1765
1860
  }
1766
- if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1861
+ if (!this.formulationAvailable(c, next))
1767
1862
  return;
1768
1863
  c.formulation = next;
1769
1864
  // Demand does not depend on the calculation, so a demand colouring stays.
1770
1865
  if (this.app.displayMode !== 'demand')
1771
- this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1866
+ this.app.displayMode = next === 'acpf' || next === 'acopf' ? 'voltage' : 'price';
1772
1867
  this.bumpRevision(c);
1773
1868
  // The committed point carries over (same demand), but the model and its solution do
1774
1869
  // not; drop the Study and the cached solutions so they rebuild under `next`.
@@ -1777,6 +1872,7 @@ export class Controller {
1777
1872
  c.solution = null;
1778
1873
  c.iterations = [];
1779
1874
  c.solveMs = null;
1875
+ c.solveDetail = null;
1780
1876
  c.predictedObjective = null;
1781
1877
  this.app.previewPrices = null;
1782
1878
  this.previewObjective = null;
@@ -1953,6 +2049,7 @@ export class Controller {
1953
2049
  const label = summary.name && summary.name !== 'case'
1954
2050
  ? summary.name
1955
2051
  : file.name.replace(/\.[^.]+$/, '');
2052
+ this.logImport(file.name, summary);
1956
2053
  const local = new LocalCase({
1957
2054
  id,
1958
2055
  label,
@@ -2152,6 +2249,7 @@ export class Controller {
2152
2249
  const view = coordsKind === 'geographic'
2153
2250
  ? buildGeographicView(graph, payload.geo_layer)
2154
2251
  : buildDiagramView(graph, payload.geo_layer);
2252
+ this.logImport(fileName, summary);
2155
2253
  const c = new MulticonductorCase({
2156
2254
  id: `dist-${++this.localSeq}`,
2157
2255
  label,
@@ -2299,6 +2397,64 @@ export class Controller {
2299
2397
  const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2300
2398
  return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
2301
2399
  };
2400
+ /** Above this many buses, reading and indexing the whole operating point takes a noticeable
2401
+ * fraction of a second (evidence/diagram), so the overview is computed on request instead of
2402
+ * after every solve. */
2403
+ static AUTO_OVERVIEW_MAX_BUSES = 25_000;
2404
+ overviewSeq = 0;
2405
+ /** Whether this case computes its overview only on request. */
2406
+ overviewOnRequest = (c) => (c.graph?.buses.length ?? 0) > Controller.AUTO_OVERVIEW_MAX_BUSES;
2407
+ /** Read the whole operating point into `c.overviewResult` for the overview and the diagram. The
2408
+ * previous result stays visible until the read lands; a newer read supersedes it. */
2409
+ readOverview = (c) => this.startOverviewRead(c, ++this.overviewSeq);
2410
+ startOverviewRead(c, seq) {
2411
+ c.overviewLoading = true;
2412
+ this.multiFullResult(c).then((full) => {
2413
+ if (seq !== this.overviewSeq)
2414
+ return;
2415
+ if (full)
2416
+ c.overviewResult = full;
2417
+ c.overviewLoading = false;
2418
+ }, () => {
2419
+ if (seq === this.overviewSeq)
2420
+ c.overviewLoading = false;
2421
+ });
2422
+ }
2423
+ /** Follow the active case's solves: read its operating point once load edits settle, or clear it
2424
+ * for a case that computes the overview on request. Call from an effect that tracks the case's
2425
+ * `result` and `mcFullResult`; the returned function cancels a pending read. */
2426
+ followOverview = (c) => {
2427
+ const seq = ++this.overviewSeq;
2428
+ if (!c)
2429
+ return;
2430
+ if (c.result?.solve_count === undefined || this.overviewOnRequest(c)) {
2431
+ c.overviewResult = null;
2432
+ c.overviewLoading = false;
2433
+ return;
2434
+ }
2435
+ c.overviewLoading = true;
2436
+ const timer = setTimeout(() => this.startOverviewRead(c, seq), 300);
2437
+ return () => {
2438
+ clearTimeout(timer);
2439
+ // A read still in flight is superseded by the next one, so this case is no longer loading.
2440
+ c.overviewLoading = false;
2441
+ };
2442
+ };
2443
+ /** The complete operating point for whole-network views: the stored result, or one read from
2444
+ * the live session. Null when there is no result or it changed before the read finished. */
2445
+ multiFullResult = async (c) => {
2446
+ if (!c.result)
2447
+ return null;
2448
+ const stored = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2449
+ const session = c.mcSession;
2450
+ if (stored || !session)
2451
+ return stored;
2452
+ if (c.mcEditRunning)
2453
+ return null;
2454
+ const solve = c.result.solve_count;
2455
+ const full = await session.result();
2456
+ return session === c.mcSession && c.result?.solve_count === solve ? full : null;
2457
+ };
2302
2458
  /** Every terminal's identity, voltage, and injected current, as numeric
2303
2459
  * arrays of interleaved `[re, im]` values. */
2304
2460
  multiTerminalTable = async (c) => {
@@ -2369,6 +2525,7 @@ export class Controller {
2369
2525
  next = payload.module_json;
2370
2526
  matched += payload.report.matched_buses + payload.report.matched_branches;
2371
2527
  notes.push(`${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`, ...payload.report.notes, ...layer.diagnostics.map(formatPowerIoDiagnostic));
2528
+ this.logCoordinates(layer.name, layer.diagnostics, payload.report.notes);
2372
2529
  }
2373
2530
  if (!payload || !matched)
2374
2531
  throw new Error('No coordinates matched the selected case');
@@ -2595,7 +2752,13 @@ export class Controller {
2595
2752
  ...placeSyntheticTopology(c.topology, { lon: 0, lat: 0 }),
2596
2753
  coordinate_space: 'diagram'
2597
2754
  };
2598
- return { id: 'study', name: c.name, base_mva: c.base_mva, synthetic_coords: !c.view, ...view };
2755
+ return {
2756
+ id: 'study',
2757
+ name: c.name,
2758
+ base_mva: c.base_mva,
2759
+ synthetic_coords: !c.view,
2760
+ ...view
2761
+ };
2599
2762
  }
2600
2763
  async caseGeographyLayers(c) {
2601
2764
  if (!c.studyInputJson || !c.view)
@@ -2633,15 +2796,46 @@ export class Controller {
2633
2796
  if (snapshot.base_input !== base ||
2634
2797
  JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
2635
2798
  throw new Error('The server case changed. Reload it before saving.');
2636
- return { input: snapshot.input, solution: snapshot.solution, view: snapshot.view };
2799
+ return {
2800
+ input: snapshot.input,
2801
+ solution: snapshot.solution,
2802
+ view: snapshot.view
2803
+ };
2637
2804
  }
2638
2805
  return { input: base };
2639
2806
  }
2807
+ /** Record what PowerIO and the engine said while reading a case file. */
2808
+ logImport(fileName, summary) {
2809
+ this.app.log.record([
2810
+ ...(summary.diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Open', subject: fileName })),
2811
+ ...(summary.warnings ?? []).map((message) => ({
2812
+ severity: 'note',
2813
+ stage: 'Open',
2814
+ source: 'tellegen',
2815
+ subject: fileName,
2816
+ message
2817
+ }))
2818
+ ]);
2819
+ }
2820
+ /** Record a geographic layer's reader findings and placement notes. */
2821
+ logCoordinates(layer, diagnostics, notes) {
2822
+ this.app.log.record([
2823
+ ...(diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Coordinates', subject: layer })),
2824
+ ...(notes ?? []).map((message) => ({
2825
+ severity: 'note',
2826
+ stage: 'Coordinates',
2827
+ source: 'tellegen',
2828
+ subject: layer,
2829
+ message
2830
+ }))
2831
+ ]);
2832
+ }
2640
2833
  /** Build a local case from a balanced PowerIO ingest payload without activating
2641
2834
  * it yet; co-dropped placement data is applied before the first solve. */
2642
2835
  localFromBalancedPayload = (fileName, payload) => {
2643
2836
  const { module_json, topology, view, formulation, ...summary } = payload;
2644
2837
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
2838
+ this.logImport(fileName, summary);
2645
2839
  return new LocalCase({
2646
2840
  id: `local-${++this.localSeq}`,
2647
2841
  label,
@@ -2684,7 +2878,11 @@ export class Controller {
2684
2878
  local.formulation = input.formulation;
2685
2879
  if (input.geoLayer) {
2686
2880
  await this.applyGeoLayers(local, [
2687
- { name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
2881
+ {
2882
+ name: `${input.name}.geo.json`,
2883
+ layer: input.geoLayer,
2884
+ diagnostics: []
2885
+ }
2688
2886
  ]);
2689
2887
  }
2690
2888
  this.placeTopologyAsDiagram(local);
@@ -2736,6 +2934,11 @@ export class Controller {
2736
2934
  * save or export captures exactly what is on screen. Null (with an error set) when no
2737
2935
  * PowerIO module or Study is available. */
2738
2936
  syncedStudy = async (c) => {
2937
+ const unavailable = this.caseExportUnavailableReason(c);
2938
+ if (unavailable) {
2939
+ this.app.error = `${this.caseName(c)}: ${unavailable}`;
2940
+ return null;
2941
+ }
2739
2942
  const studyInputJson = await this.ensureStudyInputJson(c);
2740
2943
  if (!studyInputJson) {
2741
2944
  this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
@@ -2787,8 +2990,10 @@ export class Controller {
2787
2990
  return [];
2788
2991
  try {
2789
2992
  const exported = await study.export(format);
2790
- downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
2993
+ const fileName = `${this.caseFileStem(c)}.${exported.extension}`;
2994
+ downloadText(exported.text, fileName, 'text/plain');
2791
2995
  this.app.error = null;
2996
+ this.app.log.record((exported.diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Export', subject: fileName })), { repeat: false });
2792
2997
  return exported.diagnostics;
2793
2998
  }
2794
2999
  catch (e) {
@@ -2818,7 +3023,7 @@ export class Controller {
2818
3023
  // can't preview (browser fallback path, server-only cases): the map then
2819
3024
  // falls back to the JS sensitivity-times-step preview.
2820
3025
  runPreview = (c, bus, value) => {
2821
- if (c.formulation === 'acpf')
3026
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
2822
3027
  return;
2823
3028
  // Fast path: the committed price/demand column (already solved at the committed point),
2824
3029
  // scaled by the demand step, is the same first-order linearization the engine preview
@@ -2933,7 +3138,7 @@ export class Controller {
2933
3138
  // point (preview is replacement-absolute, so the absolute ratings map is built),
2934
3139
  // then scaled by the live step; null when no Study slope is available.
2935
3140
  runRatingPreview = (c, branch, value) => {
2936
- if (c.formulation === 'acpf')
3141
+ if (c.formulation === 'acpf' || c.formulation === 'acopf')
2937
3142
  return;
2938
3143
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
2939
3144
  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
+ };