@tellegen/svelte 0.3.0 → 0.4.0

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 (67) hide show
  1. package/README.md +233 -0
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +201 -47
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +36 -4
  6. package/dist/TellegenShell.svelte +14 -42
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +10 -2
  9. package/dist/api.js +2 -0
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +18 -0
  13. package/dist/colors.js +26 -0
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +21 -8
  16. package/dist/components/BindingLines.svelte +1 -1
  17. package/dist/components/BusPicker.svelte +3 -3
  18. package/dist/components/DemandSlider.svelte +16 -7
  19. package/dist/components/DiagramCanvas.svelte +1 -1
  20. package/dist/components/DisplayControls.svelte +5 -1
  21. package/dist/components/DropZone.svelte +1 -1
  22. package/dist/components/FormulationSelector.svelte +1 -1
  23. package/dist/components/LocalCaseDetails.svelte +6 -3
  24. package/dist/components/McResults.svelte +98 -35
  25. package/dist/components/McResults.svelte.d.ts +8 -2
  26. package/dist/components/ModelDetails.svelte +5 -5
  27. package/dist/components/MulticonductorDetails.svelte +9 -9
  28. package/dist/components/NotificationCenter.svelte +4 -2
  29. package/dist/components/PanelCard.svelte +72 -7
  30. package/dist/components/PanelHost.svelte +59 -34
  31. package/dist/components/PanelHost.svelte.d.ts +4 -1
  32. package/dist/components/PlacementCue.svelte +1 -1
  33. package/dist/components/PowerFlowDetails.svelte +1 -1
  34. package/dist/components/RatingSlider.svelte +14 -5
  35. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  36. package/dist/components/SavedNetworkDetails.svelte +3 -3
  37. package/dist/components/SensitivityReadout.svelte +2 -2
  38. package/dist/components/ShellEnvironment.svelte +51 -0
  39. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  40. package/dist/components/SizeLegend.svelte +2 -2
  41. package/dist/components/SolveCard.svelte +3 -3
  42. package/dist/components/TopMovers.svelte +1 -1
  43. package/dist/components.d.ts +3 -0
  44. package/dist/components.js +3 -0
  45. package/dist/context.svelte.d.ts +8 -1
  46. package/dist/context.svelte.js +8 -3
  47. package/dist/controller.svelte.d.ts +65 -5
  48. package/dist/controller.svelte.js +262 -76
  49. package/dist/format.d.ts +9 -0
  50. package/dist/format.js +12 -0
  51. package/dist/index.d.ts +5 -1
  52. package/dist/index.js +2 -0
  53. package/dist/map.d.ts +4 -2
  54. package/dist/map.js +2 -1
  55. package/dist/maplibre-worker.d.ts +5 -0
  56. package/dist/maplibre-worker.dev.d.ts +11 -0
  57. package/dist/maplibre-worker.dev.js +15 -0
  58. package/dist/maplibre-worker.js +8 -0
  59. package/dist/notices.svelte.js +5 -1
  60. package/dist/panels.svelte.d.ts +6 -0
  61. package/dist/panels.svelte.js +31 -1
  62. package/dist/slider.d.ts +12 -0
  63. package/dist/slider.js +31 -0
  64. package/dist/state.svelte.d.ts +19 -2
  65. package/dist/state.svelte.js +12 -0
  66. package/dist/styles.css +59 -6
  67. package/package.json +13 -8
@@ -6,11 +6,17 @@ 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 } from '@tellegen/engine';
10
- import { errorText, extent, formulationLabel, rgbaCss } from './format.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 { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
+ import { DEAD_ZONE_MW } from './slider.js';
11
12
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
12
13
  // Open on South Carolina by default: it has the most interesting price action.
13
14
  const DEFAULT_CASE_ID = 'case500';
15
+ /** Whether a local case renders or will render a bus with this id. */
16
+ function localHasBus(c, busId) {
17
+ const buses = c.view?.buses ?? c.diagram?.view.buses ?? c.topology?.buses ?? [];
18
+ return buses.some((b) => b.id === busId);
19
+ }
14
20
  /** Terminal copy when nothing can solve. Two facts decide the wording: whether
15
21
  * this browser's engine failure is known permanent, and that the server
16
22
  * declines compute. Server compute exists in the codebase but ships disabled;
@@ -64,7 +70,10 @@ function downloadText(text, filename, mime) {
64
70
  }
65
71
  export class Controller {
66
72
  app;
73
+ /** `null` when the controller was created with `apiBase: null`; every
74
+ * server path then reports "compute off" instead of fetching. */
67
75
  api;
76
+ loadDefaultCases;
68
77
  mcTransport;
69
78
  abort = null;
70
79
  // While set (epoch ms), the server sensitivity fallback is rate limited: skip
@@ -119,6 +128,7 @@ export class Controller {
119
128
  constructor(app, options = {}) {
120
129
  this.app = app;
121
130
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
131
+ this.loadDefaultCases = options.loadDefaultCases ?? true;
122
132
  this.mcTransport = options.mcTransport ?? browserWasmTransport;
123
133
  }
124
134
  // ===== helpers =====
@@ -388,8 +398,13 @@ export class Controller {
388
398
  return c.studyInputJson;
389
399
  if (!this.isBackendCase(c))
390
400
  return null;
401
+ const api = this.api;
402
+ if (!api) {
403
+ c.solveFallbackReason = 'no API configured';
404
+ return null;
405
+ }
391
406
  try {
392
- const moduleJson = await this.api.getCaseModuleJson(c.id);
407
+ const moduleJson = await api.getCaseModuleJson(c.id);
393
408
  c.studyInputJson = moduleJson;
394
409
  return moduleJson;
395
410
  }
@@ -403,7 +418,7 @@ export class Controller {
403
418
  // reads as "no change at this bus".
404
419
  previewDeltas(c, bus, value) {
405
420
  const deltas = { ...this.caseDeltas(c) };
406
- if (Math.abs(value) < 0.25)
421
+ if (Math.abs(value) < DEAD_ZONE_MW)
407
422
  delete deltas[bus];
408
423
  else
409
424
  deltas[bus] = value;
@@ -621,11 +636,20 @@ export class Controller {
621
636
  previewing = $derived.by(() => Boolean(this.activeSolvable?.solving ||
622
637
  this.app.previewActive ||
623
638
  (this.app.previewDeltaMw !== null &&
624
- Math.abs(this.sliderValue - this.committedDelta) >= 0.25) ||
639
+ Math.abs(this.sliderValue - this.committedDelta) >= DEAD_ZONE_MW) ||
625
640
  (this.app.previewRatingMw !== null &&
626
- Math.abs(this.ratingSliderValue - this.committedRating) >= 0.25)));
641
+ Math.abs(this.ratingSliderValue - this.committedRating) >= DEAD_ZONE_MW)));
627
642
  // ===== actions =====
628
643
  load = () => {
644
+ // Default cases off, or no backend at all: nothing to fetch. The shell
645
+ // still needs its loaded state and a framed map, and a host that opens
646
+ // its own cases must never see a request go out.
647
+ const api = this.api;
648
+ if (!this.loadDefaultCases || api === null) {
649
+ this.casesLoaded = true;
650
+ void this.app.requestFrame('all');
651
+ return Promise.resolve();
652
+ }
629
653
  // A remount can call load() again before the first getCases() resolves; dedupe
630
654
  // so two concurrent loads can't double-fetch the case list and double-fit the map.
631
655
  if (this.loading)
@@ -635,14 +659,14 @@ export class Controller {
635
659
  // copy and skip doomed requests (the SSE stream cannot see a 403). On
636
660
  // failure assume enabled; the 403 latch in fetchServerColumn still
637
661
  // catches a disabled deploy.
638
- void this.api
662
+ void api
639
663
  .getComputeStatus()
640
664
  .then((s) => {
641
665
  this.serverComputeOff = !s.enabled;
642
666
  })
643
667
  .catch(() => { });
644
668
  try {
645
- const summaries = await this.api.getCases();
669
+ const summaries = await api.getCases();
646
670
  const hidden = this.readHiddenDefaultCases();
647
671
  this.hiddenDefaults = new Set(hidden);
648
672
  // Tear down the cases being replaced so their wasm Studies are freed and any
@@ -689,15 +713,20 @@ export class Controller {
689
713
  this.app.requestFrame(c.id);
690
714
  return;
691
715
  }
716
+ const api = this.api;
717
+ if (!api) {
718
+ this.fail(`${c.name}: no API configured`);
719
+ return;
720
+ }
692
721
  this.loadingBackendCase = c.id;
693
722
  const requestedFormulation = c.formulation;
694
723
  try {
695
724
  const [network, dcBaseSolution] = await Promise.all([
696
- c.network ? Promise.resolve(c.network) : this.api.getNetwork(c.id),
725
+ c.network ? Promise.resolve(c.network) : api.getNetwork(c.id),
697
726
  // The server caches only the DC OPF base solution. Other formulations
698
727
  // must hydrate from the browser Study so their objective, nodal values, flows, and
699
728
  // voltage fields all come from the selected formulation.
700
- requestedFormulation === 'dcopf' ? this.api.getSolution(c.id) : Promise.resolve(null)
729
+ requestedFormulation === 'dcopf' ? api.getSolution(c.id) : Promise.resolve(null)
701
730
  ]);
702
731
  if (!this.app.byId(c.id))
703
732
  return;
@@ -805,7 +834,9 @@ export class Controller {
805
834
  this.disposeStudy(c);
806
835
  await this.hydrateFallback(this.app.removeLocal(c.id));
807
836
  };
808
- addAndActivateLocal = (c) => {
837
+ /** Add a prepared local case, make it active, frame it (unless `frame` is
838
+ * false) and start its browser solve. */
839
+ addAndActivateLocal = (c, options = {}) => {
809
840
  this.app.studyView = null;
810
841
  this.clearSelection();
811
842
  this.app.activeCaseId = null;
@@ -815,7 +846,7 @@ export class Controller {
815
846
  this.app.displayMode = 'voltage';
816
847
  if (c.diagram)
817
848
  this.app.placingLocalId = null;
818
- if (c.view || c.diagram || c.substations)
849
+ if ((options.frame ?? true) && (c.view || c.diagram || c.substations))
819
850
  this.app.requestFrame(c.id);
820
851
  this.maybeStartLocalSolve(c.id);
821
852
  };
@@ -1291,7 +1322,8 @@ export class Controller {
1291
1322
  const retryOp = 'bus' in target
1292
1323
  ? () => this.selectBus(c.id, target.bus)
1293
1324
  : () => this.selectBranch(c.id, target.branch);
1294
- if (this.serverComputeOff) {
1325
+ const api = this.api;
1326
+ if (this.serverComputeOff || !api) {
1295
1327
  this.fail(this.computeOffNotice(c), retryOp);
1296
1328
  return;
1297
1329
  }
@@ -1301,8 +1333,8 @@ export class Controller {
1301
1333
  }
1302
1334
  try {
1303
1335
  const col = 'bus' in target
1304
- ? await this.api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1305
- : await this.api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
1336
+ ? await api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1337
+ : await api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
1306
1338
  if (!ac.signal.aborted) {
1307
1339
  // The solution on screen came from the browser or the served base
1308
1340
  // solve; the column came from the server. Label the mix.
@@ -1497,6 +1529,13 @@ export class Controller {
1497
1529
  return;
1498
1530
  if (!studyInputJson) {
1499
1531
  c.solveFallbackReason ??= 'PowerIO module unavailable';
1532
+ // The server fallback only implements DC OPF. Preserve the requested
1533
+ // calculation when its browser input cannot be loaded.
1534
+ if (c.formulation !== 'dcopf') {
1535
+ c.solving = false;
1536
+ this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} requires its PowerIO module; ${c.solveFallbackReason}`;
1537
+ return;
1538
+ }
1500
1539
  if (this.hasRatingEdits(c)) {
1501
1540
  c.solving = false;
1502
1541
  this.app.error = this.ratingEditsFallbackError(c);
@@ -1588,13 +1627,17 @@ export class Controller {
1588
1627
  return;
1589
1628
  }
1590
1629
  c.solving = false;
1591
- this.app.error = `${c.label}: browser solve unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`;
1630
+ const failure = browserSolveFailure(c.label, c.solveFallbackReason);
1631
+ this.app.error = failure.message;
1632
+ this.app.errorDetail = failure.detail;
1592
1633
  });
1593
1634
  };
1594
1635
  serverSolve = (c, target, seq = c.solveSeq) => {
1595
- // Known-disabled server compute: show the notice instead of opening a
1596
- // stream that answers 403 without a readable body (EventSource hides it).
1597
- if (this.serverComputeOff) {
1636
+ // Known-disabled server compute (or no backend at all): show the notice
1637
+ // instead of opening a stream that answers 403 without a readable body
1638
+ // (EventSource hides it).
1639
+ const api = this.api;
1640
+ if (this.serverComputeOff || !api) {
1598
1641
  if (seq !== c.solveSeq)
1599
1642
  return;
1600
1643
  c.solving = false;
@@ -1610,7 +1653,7 @@ export class Controller {
1610
1653
  c.solveFallbackReason ??= 'browser solve unavailable';
1611
1654
  // The server computes bus columns only; a branch target streams no sensitivity.
1612
1655
  const sensBus = target && 'bus' in target ? target.bus : null;
1613
- c.closeStream = this.api.openSolveStream(c.id, c.deltas, sensBus, {
1656
+ c.closeStream = api.openSolveStream(c.id, c.deltas, sensBus, {
1614
1657
  onsolution: (sol) => {
1615
1658
  if (seq !== c.solveSeq)
1616
1659
  return;
@@ -1659,7 +1702,7 @@ export class Controller {
1659
1702
  finishDemandInput = (value) => {
1660
1703
  if (this.sliderDisabled)
1661
1704
  return;
1662
- if (Math.abs(value - this.committedDelta) < 0.25) {
1705
+ if (Math.abs(value - this.committedDelta) < DEAD_ZONE_MW) {
1663
1706
  if (!this.activeSolvable?.solving) {
1664
1707
  this.app.previewActive = false;
1665
1708
  this.app.previewDeltaMw = null;
@@ -1932,42 +1975,47 @@ export class Controller {
1932
1975
  if (local.studyInputJson && displays.length > 0) {
1933
1976
  await this.fillFromDisplaySibling(local, displays);
1934
1977
  }
1935
- if (!local.view && !local.diagram && local.topology) {
1936
- const view = {
1937
- ...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
1938
- coordinate_space: 'diagram'
1939
- };
1940
- const layer = JSON.stringify({
1941
- type: 'FeatureCollection',
1942
- powerio_geo: { space: 'diagram', kind: 'synthetic' },
1943
- features: [
1944
- ...view.buses.map((b) => ({
1945
- type: 'Feature',
1946
- properties: { target: 'bus', id: String(b.id) },
1947
- geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
1948
- })),
1949
- ...view.branches.map((b) => ({
1950
- type: 'Feature',
1951
- properties: {
1952
- target: 'branch',
1953
- branch_id: String(b.id),
1954
- from: String(b.from),
1955
- to: String(b.to)
1956
- },
1957
- geometry: { type: 'LineString', coordinates: b.path }
1958
- }))
1959
- ]
1960
- });
1961
- local.diagram = {
1962
- view,
1963
- layer,
1964
- name: 'Network diagram',
1965
- warnings: ['No geographic coordinates; positions follow network connectivity.']
1966
- };
1967
- local.displayMode = 'diagram';
1968
- }
1978
+ this.placeTopologyAsDiagram(local);
1969
1979
  return { geoLayersConsumed, error };
1970
1980
  };
1981
+ /** A case that still has no geometry gets a connectivity layout as its
1982
+ * diagram, so it can render and solve without a placement step. */
1983
+ placeTopologyAsDiagram(local) {
1984
+ if (local.view || local.diagram || !local.topology)
1985
+ return;
1986
+ const view = {
1987
+ ...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
1988
+ coordinate_space: 'diagram'
1989
+ };
1990
+ const layer = JSON.stringify({
1991
+ type: 'FeatureCollection',
1992
+ powerio_geo: { space: 'diagram', kind: 'synthetic' },
1993
+ features: [
1994
+ ...view.buses.map((b) => ({
1995
+ type: 'Feature',
1996
+ properties: { target: 'bus', id: String(b.id) },
1997
+ geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
1998
+ })),
1999
+ ...view.branches.map((b) => ({
2000
+ type: 'Feature',
2001
+ properties: {
2002
+ target: 'branch',
2003
+ branch_id: String(b.id),
2004
+ from: String(b.from),
2005
+ to: String(b.to)
2006
+ },
2007
+ geometry: { type: 'LineString', coordinates: b.path }
2008
+ }))
2009
+ ]
2010
+ });
2011
+ local.diagram = {
2012
+ view,
2013
+ layer,
2014
+ name: 'Network diagram',
2015
+ warnings: ['No geographic coordinates; positions follow network connectivity.']
2016
+ };
2017
+ local.displayMode = 'diagram';
2018
+ }
1971
2019
  /** Retain diagram geometry separately from the electrical case's geographic data. */
1972
2020
  fillFromDisplaySibling = async (c, displays) => {
1973
2021
  if (!c.studyInputJson)
@@ -2120,20 +2168,22 @@ export class Controller {
2120
2168
  input = await retainedAtStart.inputModule();
2121
2169
  let snapshot = null;
2122
2170
  let result;
2171
+ let fullResult = null;
2123
2172
  let loadBranches = c.mcLoadBranches;
2124
2173
  if (this.mcTransport.createMcPfSession) {
2174
+ // The session answers with its summary; terminal and equipment
2175
+ // detail is fetched only when a view asks for it.
2125
2176
  nextSession = await this.mcTransport.createMcPfSession(input, options, abort.signal);
2126
- [result, loadBranches] = await Promise.all([
2127
- nextSession.result(),
2128
- nextSession.loadBranches()
2129
- ]);
2177
+ result = nextSession.initialSummary;
2178
+ loadBranches = await nextSession.loadBranches();
2130
2179
  }
2131
2180
  else {
2132
2181
  snapshot = this.mcTransport.solveMcStudy
2133
2182
  ? await this.mcTransport.solveMcStudy(input, crypto.randomUUID(), c.label, options, abort.signal)
2134
2183
  : null;
2135
- result =
2184
+ fullResult =
2136
2185
  snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
2186
+ result = summarizeMcPfResult(fullResult);
2137
2187
  }
2138
2188
  if (abort.signal.aborted)
2139
2189
  throw new DOMException('Calculation cancelled', 'AbortError');
@@ -2157,6 +2207,8 @@ export class Controller {
2157
2207
  c.mcLoadEdits = [];
2158
2208
  c.moduleJson = input;
2159
2209
  c.result = result;
2210
+ c.mcFullResult = fullResult;
2211
+ c.mcTiming = null;
2160
2212
  c.mcSnapshot = snapshot;
2161
2213
  c.mcSavedAt = null;
2162
2214
  c.solveMs = performance.now() - started;
@@ -2201,6 +2253,60 @@ export class Controller {
2201
2253
  c.mcSnapshot = snapshot;
2202
2254
  return snapshot;
2203
2255
  };
2256
+ /** One bus's terminals and a page of equipment ports for the displayed
2257
+ * operating point, from the live session or the stored full result. Null
2258
+ * when the operating point changed before the page arrived. */
2259
+ multiResultDetail = async (c, query) => {
2260
+ const summary = c.result;
2261
+ if (!summary)
2262
+ return null;
2263
+ const session = c.mcSession;
2264
+ if (session && !c.mcFullResult) {
2265
+ const detail = await session.detail(query);
2266
+ return session === c.mcSession && c.result?.solve_count === detail.solve_count
2267
+ ? detail
2268
+ : null;
2269
+ }
2270
+ const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2271
+ return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
2272
+ };
2273
+ /** Every terminal's identity, voltage, and injected current, as numeric
2274
+ * arrays of interleaved `[re, im]` values. */
2275
+ multiTerminalTable = async (c) => {
2276
+ if (!c.result)
2277
+ return null;
2278
+ const session = c.mcSession;
2279
+ if (session && !c.mcFullResult) {
2280
+ // Arrays carry no solve count, and a running flush may discard its solve.
2281
+ if (c.mcEditRunning)
2282
+ return null;
2283
+ const solve = c.result.solve_count;
2284
+ const [ids, voltages, currents] = await Promise.all([
2285
+ session.terminalIds(),
2286
+ session.terminalVoltages(),
2287
+ session.terminalCurrents()
2288
+ ]);
2289
+ return session === c.mcSession && c.result?.solve_count === solve
2290
+ ? { ids, voltages, currents }
2291
+ : null;
2292
+ }
2293
+ const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2294
+ if (!full)
2295
+ return null;
2296
+ const voltages = new Float64Array(2 * full.terminals.length);
2297
+ const currents = new Float64Array(2 * full.terminals.length);
2298
+ full.terminals.forEach((terminal, k) => {
2299
+ voltages[2 * k] = terminal.voltage.re;
2300
+ voltages[2 * k + 1] = terminal.voltage.im;
2301
+ currents[2 * k] = terminal.current_into_network.re;
2302
+ currents[2 * k + 1] = terminal.current_into_network.im;
2303
+ });
2304
+ return {
2305
+ ids: full.terminals.map((terminal) => [terminal.bus, terminal.terminal]),
2306
+ voltages,
2307
+ currents
2308
+ };
2309
+ };
2204
2310
  /** Apply coordinates atomically to the explicitly selected conductor-resolved case. */
2205
2311
  applyMultiGeoLayers = async (c, layers) => {
2206
2312
  if (!layers.length)
@@ -2251,6 +2357,8 @@ export class Controller {
2251
2357
  }
2252
2358
  c.mcSession?.free();
2253
2359
  c.mcSession = null;
2360
+ // Without a session, result detail comes from the saved result.
2361
+ c.mcFullResult = snapshot?.result ?? c.mcFullResult;
2254
2362
  c.mcLoadBranches = [];
2255
2363
  c.mcLoadEdits = [];
2256
2364
  c.moduleJson = next;
@@ -2320,19 +2428,20 @@ export class Controller {
2320
2428
  const revision = c.mcEditRevision;
2321
2429
  const edits = c.mcLoadEdits.map((edit) => ({ ...edit }));
2322
2430
  const started = performance.now();
2323
- const result = await session.replaceLoadPowers(edits);
2431
+ const summary = await session.replaceLoadPowers(edits);
2324
2432
  if (session !== c.mcSession || !this.app.multiCases.includes(c))
2325
2433
  return;
2326
2434
  if (revision !== c.mcEditRevision)
2327
2435
  continue;
2328
- const loadBranches = await session.loadBranches();
2329
- if (revision !== c.mcEditRevision)
2330
- continue;
2331
- c.mcLoadBranches = loadBranches;
2332
- c.result = result;
2436
+ // One round trip: the queued edit already set the branch state
2437
+ // this successful solve confirms. Only a failure refetches it.
2438
+ c.result = summary;
2439
+ c.mcFullResult = null;
2333
2440
  c.mcSnapshot = null;
2334
2441
  c.mcSavedAt = null;
2335
- c.solveMs = performance.now() - started;
2442
+ const total = performance.now() - started;
2443
+ c.solveMs = total;
2444
+ c.mcTiming = session.lastTiming ? { ...session.lastTiming, total_ms: total } : null;
2336
2445
  c.revisionGeneration++;
2337
2446
  break;
2338
2447
  }
@@ -2489,11 +2598,9 @@ export class Controller {
2489
2598
  Object.values(this.caseRatings(c)).some((v) => v !== 0)) {
2490
2599
  throw new Error('The displayed edits have no matching saved result. Finish solving before saving them.');
2491
2600
  }
2492
- if (c instanceof CaseState &&
2493
- c.solution &&
2494
- c.formulation === 'dcopf' &&
2495
- this.api.getSavedCase) {
2496
- const snapshot = await this.api.getSavedCase(c.id);
2601
+ const api = this.api;
2602
+ if (c instanceof CaseState && c.solution && c.formulation === 'dcopf' && api?.getSavedCase) {
2603
+ const snapshot = await api.getSavedCase(c.id);
2497
2604
  if (snapshot.base_input !== base ||
2498
2605
  JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
2499
2606
  throw new Error('The server case changed. Reload it before saving.');
@@ -2519,6 +2626,83 @@ export class Controller {
2519
2626
  view
2520
2627
  });
2521
2628
  };
2629
+ /** Ingest module text through the engine and require a balanced network. */
2630
+ async ingestBalancedModule(name, moduleJson) {
2631
+ if (moduleJson === undefined)
2632
+ throw new Error(`${name}: openBalancedModule needs moduleJson or payload`);
2633
+ const result = await ingestJsonDrop(new TextEncoder().encode(moduleJson));
2634
+ if (result.kind === 'transmission')
2635
+ return result.payload;
2636
+ if (result.kind === 'module' && !('model' in result.payload))
2637
+ return result.payload;
2638
+ throw new Error(`${name}: not a balanced network module (${result.kind})`);
2639
+ }
2640
+ /** Open a balanced powerio module from memory, the programmatic twin of
2641
+ * dropping a `.pio.json` file: ingest (unless `payload` is given), apply an
2642
+ * optional geographic layer, lay out a module without coordinates as a
2643
+ * diagram, then add and activate it. Resolves to the new case; throws when
2644
+ * the module is not a balanced network or the geographic layer matches
2645
+ * nothing. */
2646
+ openBalancedModule = async (input) => {
2647
+ this.app.parsingFile = true;
2648
+ try {
2649
+ const payload = input.payload ?? (await this.ingestBalancedModule(input.name, input.moduleJson));
2650
+ const local = this.localFromBalancedPayload(`${input.name}.pio.json`, {
2651
+ ...payload,
2652
+ name: input.name
2653
+ });
2654
+ if (input.formulation)
2655
+ local.formulation = input.formulation;
2656
+ if (input.geoLayer) {
2657
+ await this.applyGeoLayers(local, [
2658
+ { name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
2659
+ ]);
2660
+ }
2661
+ this.placeTopologyAsDiagram(local);
2662
+ if (input.activate !== false)
2663
+ this.addAndActivateLocal(local, { frame: input.frame });
2664
+ return local;
2665
+ }
2666
+ finally {
2667
+ this.app.parsingFile = false;
2668
+ }
2669
+ };
2670
+ /** Swap a local case for a new module in place: same slot in the case list,
2671
+ * same formulation unless `input.formulation` says otherwise, same display
2672
+ * mode when the new case supports it, and the selected bus kept when the
2673
+ * new case has it. The previous case's Study is freed. */
2674
+ replaceLocalCase = async (previous, input) => {
2675
+ const wasActive = this.app.activeLocalId === previous.id;
2676
+ const selectedBus = wasActive ? this.app.selectedBus : null;
2677
+ const displayMode = previous.displayMode;
2678
+ const next = await this.openBalancedModule({
2679
+ ...input,
2680
+ formulation: input.formulation ?? previous.formulation,
2681
+ activate: false
2682
+ });
2683
+ if (displayMode === 'diagram' ? next.diagram : next.view)
2684
+ next.displayMode = displayMode;
2685
+ const slot = this.app.localCases.findIndex((c) => c.id === previous.id);
2686
+ // A seq bump invalidates any in-flight browser solve of the old case.
2687
+ previous.solveSeq = (previous.solveSeq ?? 0) + 1;
2688
+ this.disposeStudy(previous);
2689
+ if (wasActive) {
2690
+ this.addAndActivateLocal(next, { frame: input.frame });
2691
+ }
2692
+ else {
2693
+ this.app.localCases = [...this.app.localCases, next];
2694
+ this.maybeStartLocalSolve(next.id);
2695
+ }
2696
+ const cases = this.app.localCases.filter((c) => c.id !== previous.id && c.id !== next.id);
2697
+ cases.splice(slot < 0 ? cases.length : slot, 0, next);
2698
+ this.app.localCases = cases;
2699
+ if (this.app.placingLocalId === previous.id)
2700
+ this.app.placingLocalId = null;
2701
+ if (selectedBus !== null && localHasBus(next, selectedBus)) {
2702
+ void this.selectLocalBus(next.id, selectedBus);
2703
+ }
2704
+ return next;
2705
+ };
2522
2706
  /** Build (or reuse) the case's Study and commit it at the current edit state, so a
2523
2707
  * save or export captures exactly what is on screen. Null (with an error set) when no
2524
2708
  * PowerIO module or Study is available. */
@@ -2615,7 +2799,7 @@ export class Controller {
2615
2799
  const col = c.sensitivity;
2616
2800
  if (col && col.bus === bus) {
2617
2801
  const committedAtBus = this.caseDeltas(c)[bus] ?? 0;
2618
- const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBus;
2802
+ const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBus;
2619
2803
  const delta = new Map();
2620
2804
  for (const v of col.values)
2621
2805
  delta.set(v.bus, v.value * step);
@@ -2707,7 +2891,7 @@ export class Controller {
2707
2891
  // tiny nudge reads as "no change at this line".
2708
2892
  previewRatings(c, branch, value) {
2709
2893
  const ratings = { ...this.caseRatings(c) };
2710
- if (Math.abs(value) < 0.25)
2894
+ if (Math.abs(value) < DEAD_ZONE_MW)
2711
2895
  delete ratings[branch];
2712
2896
  else
2713
2897
  ratings[branch] = value;
@@ -2723,7 +2907,7 @@ export class Controller {
2723
2907
  if (c.formulation === 'acpf')
2724
2908
  return;
2725
2909
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
2726
- const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBranch;
2910
+ const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBranch;
2727
2911
  const col = c.sensitivity;
2728
2912
  if (col && col.branch === branch) {
2729
2913
  const delta = new Map();
@@ -2827,7 +3011,7 @@ export class Controller {
2827
3011
  this.runSolve(c, this.selectionTarget);
2828
3012
  };
2829
3013
  finishRatingInput = (value) => {
2830
- if (Math.abs(value - this.committedRating) < 0.25) {
3014
+ if (Math.abs(value - this.committedRating) < DEAD_ZONE_MW) {
2831
3015
  if (!this.activeSolvable?.solving) {
2832
3016
  this.app.previewActive = false;
2833
3017
  this.app.previewRatingMw = null;
@@ -2837,6 +3021,8 @@ export class Controller {
2837
3021
  this.commitRating(value);
2838
3022
  };
2839
3023
  restoreDefaultCases = () => {
3024
+ if (!this.loadDefaultCases)
3025
+ return;
2840
3026
  try {
2841
3027
  if (typeof localStorage !== 'undefined')
2842
3028
  localStorage.removeItem(HIDDEN_DEFAULT_CASES_KEY);
package/dist/format.d.ts CHANGED
@@ -14,6 +14,15 @@ type SolveMeta = {
14
14
  solveBackend: SolveBackend | null;
15
15
  };
16
16
  export declare function solveMetaLabel(c: SolveMeta): string;
17
+ export declare const INFEASIBLE_DISPATCH = "No feasible dispatch: demand cannot be served within line and generator limits";
18
+ /** The message for a local case whose browser solve failed, and the
19
+ * technical detail to disclose behind it. An infeasible DC OPF means the
20
+ * network cannot carry the demand, which deserves plain words. The engine
21
+ * spells it `DC OPF solve infeasible`, and an unbounded one as `unbounded`. */
22
+ export declare function browserSolveFailure(label: string, reason: string | null): {
23
+ message: string;
24
+ detail: string | null;
25
+ };
17
26
  export declare function rgbaCss([r, g, b, a]: RGBA): string;
18
27
  /** Min and max of a numeric array in one pass. Used instead of
19
28
  * `Math.min(...values)` / `Math.max(...values)`, which throw a RangeError once the
package/dist/format.js CHANGED
@@ -26,6 +26,18 @@ export function solveMetaLabel(c) {
26
26
  return 'server price sensitivity';
27
27
  return c.solveBackend === 'rust-server' ? 'server solve' : 'browser solve';
28
28
  }
29
+ export const INFEASIBLE_DISPATCH = 'No feasible dispatch: demand cannot be served within line and generator limits';
30
+ /** The message for a local case whose browser solve failed, and the
31
+ * technical detail to disclose behind it. An infeasible DC OPF means the
32
+ * network cannot carry the demand, which deserves plain words. The engine
33
+ * spells it `DC OPF solve infeasible`, and an unbounded one as `unbounded`. */
34
+ export function browserSolveFailure(label, reason) {
35
+ const technical = `${label}: browser solve unavailable${reason ? `: ${reason}` : ''}`;
36
+ if (reason && /\binfeasible\b/i.test(reason)) {
37
+ return { message: INFEASIBLE_DISPATCH, detail: technical };
38
+ }
39
+ return { message: technical, detail: null };
40
+ }
29
41
  export function rgbaCss([r, g, b, a]) {
30
42
  return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
31
43
  }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@ export { default as TellegenViewer } from './TellegenViewer.svelte';
9
9
  export { default as TellegenMap } from './TellegenMap.svelte';
10
10
  export { DEFAULT_TELLEGEN_UI_CONFIG, getAppState, getController, getUiConfig, resolveTellegenUiConfig, setAppState, setController, setUiConfig } from './context.svelte.js';
11
11
  export { ApiError, createApiClient } from './api.js';
12
+ export { CARTO_STYLES, OPENFREEMAP_STYLES, attachBasemapFallback, cartoStyleUrl, defaultBasemap, isOpenFreeMapStyle, openFreeMapEdits, resolveBasemap } from './basemap.js';
13
+ export { MAP_PALETTE } from './colors.js';
12
14
  export { Controller, createController } from './controller.svelte.js';
13
15
  export { AppState, CaseState, LocalCase, MulticonductorCase, createAppState } from './state.svelte.js';
14
16
  export { attachmentColor, attachmentGlyph, edgeColor, edgeWidth, phaseColor, neutralTerminal, placeMultiView, buildGeographicView } from './multiconductor.js';
@@ -17,7 +19,9 @@ export { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './dis
17
19
  export { DEFAULT_FORMULATION, FORMULATIONS, BrowserStudy, Study, capabilities, createStudy, errorText, formatOf, classifyJson, ingestJsonDrop, ingestCase, isDisplayFile, isPermanentEngineFailure, parseDisplay, solveModule } from '@tellegen/engine';
18
20
  export type { BranchRatingDeltas, CaseSummary, DemandDeltas, Network, NetworkBranch, NetworkBus, SensitivityColumn, Solution, SolveIteration, SolveStreamHandlers, TellegenApiClient, TellegenApiClientOptions } from './api.js';
19
21
  export type { TellegenUiConfig, TellegenUiOptions } from './context.svelte.js';
20
- export type { ControllerOptions } from './controller.svelte.js';
22
+ export type { ControllerOptions, OpenBalancedModuleInput } from './controller.svelte.js';
23
+ export type { BasemapFallbackReason, BasemapMapLike, BasemapProvider, BasemapSpec, BasemapStyle, BasemapSwitch, BasemapTheme, DefaultBasemap, MapProjection, MapTheme, ResolvedBasemap, StyleEdit } from './basemap.js';
24
+ export type { MapPalette } from './colors.js';
21
25
  export type { DisplayOption } from './display.js';
22
26
  export type { CameraSnapshot, DiagramCameraSnapshot, DemandRangeMode, DisplayMode, FallbackTarget, LocalCaseInit, LocalSubstations, MultiCaseSummary, MultiCoordsKind, SolvableCase, StudyDisplaySnapshot, SolveBackend } from './state.svelte.js';
23
27
  export type { MultiView, PlacedMultiBus, PlacedMultiEdge } from './multiconductor.js';
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ export { default as TellegenViewer } from './TellegenViewer.svelte';
8
8
  export { default as TellegenMap } from './TellegenMap.svelte';
9
9
  export { DEFAULT_TELLEGEN_UI_CONFIG, getAppState, getController, getUiConfig, resolveTellegenUiConfig, setAppState, setController, setUiConfig } from './context.svelte.js';
10
10
  export { ApiError, createApiClient } from './api.js';
11
+ export { CARTO_STYLES, OPENFREEMAP_STYLES, attachBasemapFallback, cartoStyleUrl, defaultBasemap, isOpenFreeMapStyle, openFreeMapEdits, resolveBasemap } from './basemap.js';
12
+ export { MAP_PALETTE } from './colors.js';
11
13
  export { Controller, createController } from './controller.svelte.js';
12
14
  export { AppState, CaseState, LocalCase, MulticonductorCase, createAppState } from './state.svelte.js';
13
15
  export { attachmentColor, attachmentGlyph, edgeColor, edgeWidth, phaseColor, neutralTerminal, placeMultiView, buildGeographicView } from './multiconductor.js';
package/dist/map.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ComponentProps } from 'svelte';
2
2
  import TellegenMap from './TellegenMap.svelte';
3
3
  export { default as TellegenMap } from './TellegenMap.svelte';
4
- export { branchColor, branchWidth, busNeutral, busRadius, priceColor, priceDomain, priceGradient, scalarDomain, sensColor, sensFlatColor, sensGradient, sensNeutral, sensitivityDomain } from './colors.js';
4
+ export { CARTO_STYLES, OPENFREEMAP_STYLES, attachBasemapFallback, cartoStyleUrl, defaultBasemap, isOpenFreeMapStyle, openFreeMapEdits, resolveBasemap } from './basemap.js';
5
+ export type { BasemapFallbackReason, BasemapMapLike, BasemapProvider, BasemapSpec, BasemapStyle, BasemapSwitch, BasemapTheme, DefaultBasemap, MapProjection, MapTheme, ResolvedBasemap, StyleEdit } from './basemap.js';
6
+ export { MAP_PALETTE, branchColor, branchWidth, busNeutral, busRadius, priceColor, priceDomain, priceGradient, scalarDomain, sensColor, sensFlatColor, sensGradient, sensNeutral, sensitivityDomain } from './colors.js';
5
7
  export { caseDeltas, displayMetaFor, displaySeriesFor } from './display.js';
6
- export type { RGBA, SensitivityDomain } from './colors.js';
8
+ export type { MapPalette, RGBA, SensitivityDomain } from './colors.js';
7
9
  export type { DisplayOption } from './display.js';
8
10
  export type TellegenMapProps = ComponentProps<typeof TellegenMap>;