@tellegen/svelte 0.4.0 → 0.4.2

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 (45) hide show
  1. package/README.md +38 -10
  2. package/dist/Sparkline.svelte +4 -4
  3. package/dist/TellegenMap.svelte +80 -66
  4. package/dist/TellegenProvider.svelte +29 -13
  5. package/dist/colors.d.ts +37 -1
  6. package/dist/colors.js +63 -0
  7. package/dist/components/AppFooter.svelte +4 -4
  8. package/dist/components/AppHeader.svelte +15 -19
  9. package/dist/components/BindingLines.svelte +10 -9
  10. package/dist/components/BusPicker.svelte +5 -6
  11. package/dist/components/ControlPanel.svelte +16 -11
  12. package/dist/components/ControlPanel.svelte.d.ts +6 -1
  13. package/dist/components/DemandSlider.svelte +26 -29
  14. package/dist/components/DiagramCanvas.svelte +16 -4
  15. package/dist/components/DisplayControls.svelte +5 -24
  16. package/dist/components/DropZone.svelte +7 -3
  17. package/dist/components/DropZone.svelte.d.ts +6 -17
  18. package/dist/components/FormulationSelector.svelte +2 -7
  19. package/dist/components/LocalCaseDetails.svelte +126 -126
  20. package/dist/components/LocalCaseDetails.svelte.d.ts +6 -1
  21. package/dist/components/MulticonductorDetails.svelte +48 -38
  22. package/dist/components/NetworkStats.svelte +14 -16
  23. package/dist/components/PanelCard.svelte +1 -1
  24. package/dist/components/PlacementCue.svelte +1 -1
  25. package/dist/components/RatingSlider.svelte +22 -23
  26. package/dist/components/RestoreDefaultsButton.svelte +2 -3
  27. package/dist/components/SensitivityReadout.svelte +9 -27
  28. package/dist/components/SolveCard.svelte +69 -35
  29. package/dist/components/TopMovers.svelte +5 -5
  30. package/dist/context.svelte.d.ts +6 -2
  31. package/dist/context.svelte.js +4 -2
  32. package/dist/controller.svelte.d.ts +7 -0
  33. package/dist/controller.svelte.js +37 -8
  34. package/dist/display.d.ts +3 -2
  35. package/dist/display.js +28 -11
  36. package/dist/format.d.ts +1 -9
  37. package/dist/format.js +1 -14
  38. package/dist/index.d.ts +1 -1
  39. package/dist/map.d.ts +1 -1
  40. package/dist/map.js +1 -1
  41. package/dist/state.svelte.d.ts +6 -1
  42. package/dist/state.svelte.js +1 -0
  43. package/dist/styles.css +3 -0
  44. package/dist/types.d.ts +1 -1
  45. package/package.json +10 -6
@@ -22,34 +22,33 @@
22
22
  const neutralPos = $derived(thumbAlignedPos(neutralFrac));
23
23
  const fillLo = $derived(valFrac < neutralFrac ? valPos : neutralPos);
24
24
  const fillHi = $derived(valFrac < neutralFrac ? neutralPos : valPos);
25
- const sliderTip =
26
- 'The black tick marks the last committed rating change. Drag the knob to preview a new thermal rating; release to solve and move the tick to that point.';
27
- const disabledTip = "this line's rating is synthesized; not adjustable";
25
+ const sliderTip = 'Drag to preview, release to solve. The tick marks the committed rating.';
26
+ const disabledTip = 'Synthesized rating; not adjustable';
28
27
  const scoreTip =
29
- 'Gradient estimates the declared objective change versus base before the solve finishes, from the engine preview at the committed point. Exact is the resolved OPF objective change.';
28
+ 'Gradient: first-order estimate from the committed point. Exact: solved objective change.';
30
29
  </script>
31
30
 
32
31
  {#if ctrl.activeSolvable}
33
32
  {@const c = ctrl.activeSolvable}
34
33
  {@const branch = ctrl.selectedBranchData}
35
34
  <div class="slider-block">
36
- <div class="slider-head mono">
37
- <span>&Delta; rating</span>
38
- <span class="val">{signed(ctrl.ratingSliderValue)} MW</span>
35
+ <div class="slider-head">
36
+ <span>Rating change</span>
37
+ <span class="val mono">{signed(ctrl.ratingSliderValue)} MW</span>
39
38
  </div>
40
- <div class="line-row mono">
39
+ <div class="line-row">
41
40
  {#if branch}
42
41
  <span class="dim"
43
- >line {branch.from}&#8201;&ndash;&#8201;{branch.to}, {fmt.format(branch.rate_mw)} MW</span
42
+ >Line <span class="mono"
43
+ >{branch.from}&#8201;&ndash;&#8201;{branch.to}, {fmt.format(branch.rate_mw)} MW</span
44
+ ></span
44
45
  >
45
46
  {/if}
46
- <span class="dim">
47
- {#if bounds.disabled}
48
- not adjustable
49
- {:else}
50
- {fmt.format(bounds.min)} to +{fmt.format(bounds.max)} MW
51
- {/if}
52
- </span>
47
+ {#if bounds.disabled}
48
+ <span class="dim">Not adjustable</span>
49
+ {:else}
50
+ <span class="mono dim">{fmt.format(bounds.min)} to +{fmt.format(bounds.max)} MW</span>
51
+ {/if}
53
52
  </div>
54
53
  <div
55
54
  class="slider-track"
@@ -63,7 +62,7 @@
63
62
  step="any"
64
63
  disabled={bounds.disabled}
65
64
  bind:value={ctrl.ratingSliderCurrent, ctrl.setRatingPreview}
66
- aria-label="rating delta at selected line"
65
+ aria-label="Rating delta at selected line"
67
66
  onpointerdown={() => ctrl.setRatingPreview(ctrl.ratingSliderValue)}
68
67
  onkeydown={(e) => {
69
68
  const next = sliderKeyValue(e.key, ctrl.ratingSliderValue, bounds.min, bounds.max);
@@ -80,18 +79,18 @@
80
79
  />
81
80
  </div>
82
81
  <div class="rating-feedback" class:idle={!ctrl.previewing && !ctrl.isPerturbed(c)}>
83
- <p class="pred mono dim" aria-hidden={!(ctrl.predictedDeltaObj !== null && ctrl.previewing)}>
82
+ <p class="pred dim" aria-hidden={!(ctrl.predictedDeltaObj !== null && ctrl.previewing)}>
84
83
  {#if ctrl.predictedDeltaObj !== null && ctrl.previewing}
85
- predicted &Delta;objective {signed(ctrl.predictedDeltaObj)}
84
+ Predicted objective change <span class="mono">{signed(ctrl.predictedDeltaObj)}</span>
86
85
  {:else}
87
86
  &nbsp;
88
87
  {/if}
89
88
  </p>
90
- <p class="score mono" aria-hidden={!(ctrl.gradientScore && ctrl.isPerturbed(c))}>
89
+ <p class="score" aria-hidden={!(ctrl.gradientScore && ctrl.isPerturbed(c))}>
91
90
  {#if ctrl.gradientScore && ctrl.isPerturbed(c)}
92
91
  <span title={scoreTip}>
93
- gradient {signed(ctrl.gradientScore.pred)}, exact {signed(ctrl.gradientScore.exact)} objective
94
- units
92
+ Gradient <span class="mono">{signed(ctrl.gradientScore.pred)}</span>, exact
93
+ <span class="mono">{signed(ctrl.gradientScore.exact)}</span> objective units
95
94
  </span>
96
95
  {:else}
97
96
  &nbsp;
@@ -99,7 +98,7 @@
99
98
  </p>
100
99
  <div class="reset-row">
101
100
  {#if ctrl.isPerturbed(c)}
102
- <button class="reset mono" onclick={() => ctrl.resetCase(c)}>reset rating</button>
101
+ <button class="reset" onclick={() => ctrl.resetCase(c)}>Reset rating</button>
103
102
  {/if}
104
103
  </div>
105
104
  </div>
@@ -6,9 +6,7 @@
6
6
  </script>
7
7
 
8
8
  {#if config.loadDefaultCases && ctrl.casesLoaded && ctrl.hiddenDefaults.size > 0}
9
- <button class="restore-defaults mono" onclick={ctrl.restoreDefaultCases}>
10
- &#8634; restore default cases
11
- </button>
9
+ <button class="restore-defaults" onclick={ctrl.restoreDefaultCases}>Restore default cases</button>
12
10
  {/if}
13
11
 
14
12
  <style>
@@ -22,6 +20,7 @@
22
20
  border: 1px solid var(--line);
23
21
  border-radius: 3px;
24
22
  color: var(--text-secondary);
23
+ font-family: inherit;
25
24
  font-size: 11px;
26
25
  cursor: pointer;
27
26
  box-shadow: var(--elev-1);
@@ -11,17 +11,11 @@
11
11
  'objective units'
12
12
  )
13
13
  );
14
- const busUnitTitle = $derived(
15
- `LMP response to active demand, in ${reportedUnits}. The response is small while the active set stays unchanged.`
16
- );
17
- const branchUnitTitle = $derived(
18
- `LMP response to the thermal rating, in ${reportedUnits}. The column is zero when the limit is inactive.`
19
- );
14
+ const unitTitle = $derived(`Units: ${reportedUnits}`);
20
15
 
21
16
  // Branch mode: the selection is a line and the column is price/rating; the
22
17
  // legend and preview pipeline are identical (both columns are bus-keyed).
23
18
  const branchMode = $derived(app.selectedBranch !== null);
24
- const unitTitle = $derived(branchMode ? branchUnitTitle : busUnitTitle);
25
19
  const previewUnits = $derived(app.previewPrices?.units ?? 'objective units/MW');
26
20
 
27
21
  const previewStep = $derived(
@@ -42,25 +36,22 @@
42
36
  <span class="chip">
43
37
  {ctrl.previewing ? 'LMP preview' : branchMode ? '∂LMP/∂rating' : '∂LMP/∂d'}
44
38
  </span>
45
- <span class="mono dim">
46
- {#if branchMode}line {branchLabel}{:else}bus {app.selectedBus}{/if}
39
+ <span class="dim">
40
+ {#if branchMode}Line <span class="mono">{branchLabel}</span>{:else}Bus
41
+ <span class="mono">{app.selectedBus}</span>{/if}
47
42
  </span>
48
- <button class="mono" onclick={ctrl.clearSelection}
49
- ><span class="key-hint">esc&nbsp;</span>clear</button
50
- >
43
+ <button onclick={ctrl.clearSelection} aria-keyshortcuts="Escape" title="Esc">Clear</button>
51
44
  </div>
52
45
  <div class="sensitivity-readout" aria-live="polite">
53
46
  {#if ctrl.previewing}
54
47
  <p class="dim small">
55
- {ctrl.activeSolvable?.solving
56
- ? 'Exact solve running; the map keeps the LMP preview.'
57
- : 'First order LMP preview. Release for the exact solve.'}
48
+ {ctrl.activeSolvable?.solving ? 'Solving…' : 'First-order preview. Release to solve.'}
58
49
  </p>
59
50
  {#if ctrl.sensSummary?.flat}
60
51
  <div class="legend flat" style:background={ctrl.flatSensBackground}></div>
61
52
  <div class="legend-labels mono single">
62
53
  <span
63
- >uniform {signedExp(ctrl.sensSummary.mean * previewStep)}
54
+ >Uniform {signedExp(ctrl.sensSummary.mean * previewStep)}
64
55
  {previewUnits}</span
65
56
  >
66
57
  </div>
@@ -88,7 +79,7 @@
88
79
  {#if ctrl.sensSummary?.flat}
89
80
  <div class="legend flat" style:background={ctrl.flatSensBackground}></div>
90
81
  <div class="legend-labels mono single">
91
- <span>uniform {signedExp(ctrl.sensSummary.mean)} {reportedUnits}</span>
82
+ <span>Uniform {signedExp(ctrl.sensSummary.mean)} {reportedUnits}</span>
92
83
  </div>
93
84
  {:else if ctrl.sensSummary}
94
85
  <div class="legend" style:background={sensGradient}></div>
@@ -100,9 +91,7 @@
100
91
  {:else if app.sensitivityLoading}
101
92
  <div class="legend" style:background="var(--line)" style:opacity="0.4"></div>
102
93
  <div class="legend-labels mono single">
103
- <span class="blink">
104
- computing {#if branchMode}&part;value/&part;fmax{:else}&part;value/&part;d{/if}&hellip;
105
- </span>
94
+ <span class="blink">Computing&hellip;</span>
106
95
  </div>
107
96
  {/if}
108
97
  {/if}
@@ -133,11 +122,4 @@
133
122
  line-height: 1.35;
134
123
  white-space: nowrap;
135
124
  }
136
-
137
- /* No Esc key to name on a touch device. */
138
- @media (hover: none), (pointer: coarse) {
139
- .key-hint {
140
- display: none;
141
- }
142
- }
143
125
  </style>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { getAppState, getController } from '../context.svelte.js';
3
- import { formulationLabel, solveMetaLabel } from '../format.js';
3
+ import { formulationLabel } from '../format.js';
4
4
  import Sparkline from '../Sparkline.svelte';
5
5
  import PanelFrame from './PanelFrame.svelte';
6
6
  let open = $state(true);
@@ -16,67 +16,101 @@
16
16
  : []
17
17
  : (active?.iterations ?? [])
18
18
  );
19
+ /** Named only when a server took part; the browser engine is the default. */
20
+ const server = $derived(
21
+ active?.solveBackend === 'rust-server'
22
+ ? 'Server'
23
+ : active?.solveBackend === 'clarabel-wasm-server-sensitivity'
24
+ ? 'Server price sensitivity'
25
+ : null
26
+ );
19
27
  </script>
20
28
 
21
29
  {#if saved || (active && (active.solving || active.solveMs != null))}
22
30
  <PanelFrame id="solver" title="Solver" side="right" order={10} width={320} bind:open>
23
31
  <div class="solvecard">
24
- <div class="solve-label mono">
25
- {saved ? 'Saved result' : active?.formulation === 'acpf' ? 'Power flow' : 'OPF solve'}
26
- </div>
27
32
  {#if iterations.length > 1}
28
33
  {#key iterations}
29
34
  <Sparkline {iterations} />
30
35
  {/key}
31
36
  {/if}
32
37
  {#if saved}
33
- <div class="solve-meta mono dim">
34
- <span class="solve-formulation">{formulationLabel(saved.formulation)}</span>
35
- <span>{saved.solution?.status ?? 'Not solved'}</span>
36
- <span>Saved state: {saved.label}</span>
37
- </div>
38
+ <dl>
39
+ <div>
40
+ <dt>Calculation</dt>
41
+ <dd>{formulationLabel(saved.formulation)}</dd>
42
+ </div>
43
+ <div>
44
+ <dt>Result</dt>
45
+ <dd>{saved.solution?.status ?? 'Not solved'}</dd>
46
+ </div>
47
+ <div>
48
+ <dt>Saved state</dt>
49
+ <dd>{saved.label}</dd>
50
+ </div>
51
+ </dl>
38
52
  {:else if active}
39
- <div class="solve-meta mono dim">
40
- <span class="solve-formulation">{formulationLabel(active.formulation)}</span>
41
- <span>{solveMetaLabel(active)}</span>
42
- {#if active.solveMs != null}<span>{active.solveMs} ms</span>{/if}
43
- </div>
44
- {#if active.solveBackend === 'rust-server' && active.solveFallbackReason}
45
- <p class="fallback-reason mono dim" title={active.solveFallbackReason}>
46
- fallback: {active.solveFallbackReason}
47
- </p>
48
- {/if}
53
+ <dl>
54
+ <div>
55
+ <dt>Calculation</dt>
56
+ <dd>{formulationLabel(active.formulation)}</dd>
57
+ </div>
58
+ {#if iterations.length > 1}
59
+ <div>
60
+ <dt>Iterations</dt>
61
+ <dd class="mono">{iterations.length}</dd>
62
+ </div>
63
+ {/if}
64
+ {#if server}
65
+ <div>
66
+ <dt>Solver</dt>
67
+ <dd>{server}</dd>
68
+ </div>
69
+ {/if}
70
+ {#if active.solveMs != null}
71
+ <div>
72
+ <dt>Time</dt>
73
+ <dd class="mono">{active.solveMs} ms</dd>
74
+ </div>
75
+ {/if}
76
+ {#if active.solveBackend === 'rust-server' && active.solveFallbackReason}
77
+ <div>
78
+ <dt>Fallback</dt>
79
+ <dd title={active.solveFallbackReason}>{active.solveFallbackReason}</dd>
80
+ </div>
81
+ {/if}
82
+ </dl>
49
83
  {/if}
50
84
  </div>
51
85
  </PanelFrame>
52
86
  {/if}
53
87
 
54
88
  <style>
55
- .solve-label {
56
- font-size: 13px;
57
- margin-bottom: 8px;
58
- }
59
89
  .solvecard {
60
90
  padding: 13px 15px 11px;
61
91
  }
62
92
 
63
- .solve-meta {
64
- display: flex;
65
- align-items: center;
66
- flex-wrap: wrap;
67
- gap: 10px 14px;
93
+ dl {
94
+ margin: 6px 0 0;
68
95
  font-size: 12px;
69
- margin-top: 6px;
70
96
  }
71
97
 
72
- .solve-formulation {
73
- color: var(--text-accent);
98
+ dl div {
99
+ display: flex;
100
+ justify-content: space-between;
101
+ gap: 12px;
102
+ padding: 2px 0;
74
103
  }
75
104
 
76
- .fallback-reason {
77
- margin: 6px 0 0;
78
- font-size: 12px;
79
- line-height: 1.35;
105
+ dt {
106
+ flex: none;
107
+ color: var(--text-secondary);
108
+ }
109
+
110
+ dd {
111
+ min-width: 0;
112
+ margin: 0;
113
+ text-align: right;
80
114
  overflow-wrap: anywhere;
81
115
  }
82
116
  </style>
@@ -14,15 +14,15 @@
14
14
 
15
15
  <div class="movers-block">
16
16
  {#if !ctrl.previewing && ctrl.topMovers.length > 0}
17
- <table class="mono">
18
- <caption class="mono dim" title={unitTitle}>
19
- largest LMP response <span class="unit">{units}</span>
17
+ <table>
18
+ <caption class="dim" title={unitTitle}>
19
+ Largest LMP response <span class="mono unit">{units}</span>
20
20
  </caption>
21
21
  <tbody>
22
22
  {#each ctrl.topMovers as mover (mover.bus)}
23
23
  <tr>
24
- <td>bus {mover.bus}</td>
25
- <td class:pos={mover.value > 0} class:neg={mover.value < 0}>
24
+ <td>Bus <span class="mono">{mover.bus}</span></td>
25
+ <td class="mono" class:pos={mover.value > 0} class:neg={mover.value < 0}>
26
26
  {signedExp(mover.value)}
27
27
  </td>
28
28
  </tr>
@@ -9,15 +9,19 @@ export interface TellegenUiConfig {
9
9
  orgLabel: string;
10
10
  showFooter: boolean;
11
11
  /** `'dark'` sets `data-theme="dark"` on the document root while the
12
- * provider is mounted, which switches the `styles.css` tokens. */
12
+ * provider is mounted, which switches the `styles.css` tokens. The
13
+ * provider keeps it in step with its prop. */
13
14
  theme: 'light' | 'dark';
14
15
  /** CARTO basemap key for the default shell's map, trimmed; empty for
15
- * none. See `TellegenMap`'s `cartoApiKey`. */
16
+ * none. See `TellegenMap`'s `cartoApiKey`. The provider keeps it in step
17
+ * with its prop. */
16
18
  cartoApiKey: string;
17
19
  }
18
20
  export type TellegenUiOptions = Partial<TellegenUiConfig>;
19
21
  export declare const DEFAULT_TELLEGEN_UI_CONFIG: TellegenUiConfig;
20
22
  export declare function resolveTellegenUiConfig(options?: TellegenUiOptions): TellegenUiConfig;
23
+ export declare const resolveTheme: (theme: TellegenUiOptions["theme"]) => TellegenUiConfig["theme"];
24
+ export declare const resolveCartoApiKey: (key: TellegenUiOptions["cartoApiKey"]) => string;
21
25
  export declare const setAppState: (a: AppState) => AppState;
22
26
  export declare const setController: (c: Controller) => Controller;
23
27
  export declare const setUiConfig: (config: TellegenUiConfig) => TellegenUiConfig;
@@ -21,10 +21,12 @@ export function resolveTellegenUiConfig(options = {}) {
21
21
  orgHref: options.orgHref ?? DEFAULT_TELLEGEN_UI_CONFIG.orgHref,
22
22
  orgLabel: options.orgLabel ?? DEFAULT_TELLEGEN_UI_CONFIG.orgLabel,
23
23
  showFooter: options.showFooter ?? DEFAULT_TELLEGEN_UI_CONFIG.showFooter,
24
- theme: options.theme ?? DEFAULT_TELLEGEN_UI_CONFIG.theme,
25
- cartoApiKey: normalizeCartoApiKey(options.cartoApiKey) ?? DEFAULT_TELLEGEN_UI_CONFIG.cartoApiKey
24
+ theme: resolveTheme(options.theme),
25
+ cartoApiKey: resolveCartoApiKey(options.cartoApiKey)
26
26
  };
27
27
  }
28
+ export const resolveTheme = (theme) => theme ?? DEFAULT_TELLEGEN_UI_CONFIG.theme;
29
+ export const resolveCartoApiKey = (key) => normalizeCartoApiKey(key) ?? DEFAULT_TELLEGEN_UI_CONFIG.cartoApiKey;
28
30
  export const setAppState = (a) => setContext(APP, a);
29
31
  export const setController = (c) => setContext(CTRL, c);
30
32
  export const setUiConfig = (config) => setContext(CONFIG, config);
@@ -83,6 +83,7 @@ export declare class Controller {
83
83
  localSeq: number;
84
84
  loading: Promise<void> | null;
85
85
  private readonly dropBatchGate;
86
+ disposed: boolean;
86
87
  nearbyRangeAnchor: DemandRangeAnchor | null;
87
88
  hiddenDefaults: Set<string>;
88
89
  casesLoaded: boolean;
@@ -129,6 +130,12 @@ export declare class Controller {
129
130
  maybeStartLocalSolve(id: string): void;
130
131
  getStudy(c: SolvableCase, studyInputJson: string): Promise<BrowserStudy | null>;
131
132
  browserSensitivity(c: SolvableCase, studyInputJson: string, target: SensTarget): Promise<SensitivityColumn | null>;
133
+ /** 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. */
138
+ dispose(): void;
132
139
  disposeStudy(c: SolvableCase): void;
133
140
  acceptSensitivity(c: SolvableCase, col: SensitivityColumn | null, target: SensTarget | null, sensitivitySeq?: number): void;
134
141
  finishSolve(c: SolvableCase, seq: number, target: SensTarget | null): void;
@@ -19,10 +19,9 @@ function localHasBus(c, busId) {
19
19
  }
20
20
  /** Terminal copy when nothing can solve. Two facts decide the wording: whether
21
21
  * this browser's engine failure is known permanent, and that the server
22
- * declines compute. Server compute exists in the codebase but ships disabled;
23
- * interest routes to email until it is offered. */
24
- const WASM_REQUIRED_NOTICE = 'this browser cannot run the WebAssembly engine that solves locally, and server side compute is disabled in this demo. it may be offered later; to express interest, email Samuel Talkington at talks@umich.edu';
25
- const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server side compute is disabled in this demo. retry to run the browser engine again, or email Samuel Talkington at talks@umich.edu to express interest in server compute';
22
+ * declines compute. */
23
+ const WASM_REQUIRED_NOTICE = 'This browser cannot run the WebAssembly engine, and server compute is disabled';
24
+ const COMPUTE_OFF_NOTICE = 'The browser solve did not complete, and server compute is disabled. Retry to run it again';
26
25
  /** Upper bound on waiting for a camera fly-to before the sensitivity solve
27
26
  * starts anyway: a missing map or a cue that never appears must not stall the
28
27
  * selection. */
@@ -107,6 +106,8 @@ export class Controller {
107
106
  // File reads and worker messages retain whole buffers. Admit one validated
108
107
  // batch at a time so repeated drops cannot multiply the aggregate byte cap.
109
108
  dropBatchGate = new DropBatchGate();
109
+ // Set by dispose(). Not $state; nothing renders it.
110
+ disposed = false;
110
111
  nearbyRangeAnchor = $state(null);
111
112
  // Default cases the user has closed; drives the restore affordance. Seeded in load().
112
113
  hiddenDefaults = $state(new Set());
@@ -268,6 +269,9 @@ export class Controller {
268
269
  // under that formulation. Returns null when the sens module can't load; the caller then
269
270
  // falls back to the server, surfacing solveFallbackReason.
270
271
  async getStudy(c, studyInputJson) {
272
+ // A disposed controller builds nothing that would outlive it.
273
+ if (this.disposed)
274
+ return null;
271
275
  const latched = this.studyUnavailable.get(c);
272
276
  if (latched) {
273
277
  c.solveFallbackReason ??= latched;
@@ -347,6 +351,28 @@ export class Controller {
347
351
  const study = await this.getStudy(c, studyInputJson);
348
352
  return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
349
353
  }
354
+ /** 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. */
359
+ dispose() {
360
+ if (this.disposed)
361
+ return;
362
+ this.disposed = true;
363
+ this.abort?.abort();
364
+ this.abort = null;
365
+ for (const c of [...this.app.cases, ...this.app.localCases])
366
+ this.disposeStudy(c);
367
+ for (const c of this.app.multiCases) {
368
+ c.solveAbort?.abort();
369
+ if (c.mcEditTimer !== null)
370
+ clearTimeout(c.mcEditTimer);
371
+ c.mcEditTimer = null;
372
+ c.mcSession?.free();
373
+ c.mcSession = null;
374
+ }
375
+ }
350
376
  // Release a case's Study (if any) when the case is removed.
351
377
  disposeStudy(c) {
352
378
  const cached = this.caseStudies.get(c);
@@ -487,7 +513,7 @@ export class Controller {
487
513
  return null;
488
514
  const domain = scalarDomain(this.activeDisplay.mode, values);
489
515
  const { min, max } = extent(values);
490
- const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
516
+ const flatThreshold = this.activeDisplay.mode === 'price' || this.activeDisplay.mode === 'demand' ? 1 : 1e-5;
491
517
  return {
492
518
  lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
493
519
  hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
@@ -1740,7 +1766,9 @@ export class Controller {
1740
1766
  if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1741
1767
  return;
1742
1768
  c.formulation = next;
1743
- this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1769
+ // Demand does not depend on the calculation, so a demand colouring stays.
1770
+ if (this.app.displayMode !== 'demand')
1771
+ this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1744
1772
  this.bumpRevision(c);
1745
1773
  // The committed point carries over (same demand), but the model and its solution do
1746
1774
  // not; drop the Study and the cached solutions so they rebuild under `next`.
@@ -2142,7 +2170,7 @@ export class Controller {
2142
2170
  }
2143
2171
  /** Compute terminal quantities only for the retained input and current case revision. */
2144
2172
  solveMultiCase = async (c, options = {}, signal) => {
2145
- if (signal?.aborted)
2173
+ if (signal?.aborted || this.disposed)
2146
2174
  throw new DOMException('Calculation cancelled', 'AbortError');
2147
2175
  if (c.solving)
2148
2176
  throw new Error('A calculation is already running for this case');
@@ -2185,7 +2213,8 @@ export class Controller {
2185
2213
  snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
2186
2214
  result = summarizeMcPfResult(fullResult);
2187
2215
  }
2188
- if (abort.signal.aborted)
2216
+ // dispose() aborts too; a session that arrives after it is freed below.
2217
+ if (abort.signal.aborted || this.disposed)
2189
2218
  throw new DOMException('Calculation cancelled', 'AbortError');
2190
2219
  if (seq !== c.solveSeq ||
2191
2220
  (retainedAtStart ? retainedAtStart !== c.mcSession : input !== c.moduleJson) ||
package/dist/display.d.ts CHANGED
@@ -12,8 +12,9 @@ export type DisplayOption = {
12
12
  };
13
13
  /** Variables present in the current result. Power flow has no LMP values. */
14
14
  export declare function displayMetaFor(c: SolvableCase | null): DisplayOption[];
15
- /** Bus LMPs, voltage angles in radians, or voltage magnitudes in per unit.
16
- * SOCWR supplies squared voltages, which are converted to magnitudes here. */
15
+ /** Bus LMPs, voltage angles in radians, voltage magnitudes in per unit, or
16
+ * demand in MW with the case's committed changes. SOCWR supplies squared
17
+ * voltages, which are converted to magnitudes here. */
17
18
  export declare function displaySeriesFor(c: SolvableCase | null, mode: DisplayMode): {
18
19
  bus: number;
19
20
  value: number;
package/dist/display.js CHANGED
@@ -1,5 +1,4 @@
1
- import { priceGradient } from './colors.js';
2
- import { priceCopy } from './format.js';
1
+ import { demandGradient, priceGradient } from './colors.js';
3
2
  import { CaseState } from './state.svelte.js';
4
3
  /** Variables present in the current result. Power flow has no LMP values. */
5
4
  export function displayMetaFor(c) {
@@ -11,7 +10,9 @@ export function displayMetaFor(c) {
11
10
  mode: 'price',
12
11
  label: 'LMP',
13
12
  unit: 'objective units/MW',
14
- copy: priceCopy(c.formulation),
13
+ copy: c.formulation === 'socwr'
14
+ ? 'Locational marginal price, SOCWR relaxation'
15
+ : 'Locational marginal price',
15
16
  gradient: priceGradient
16
17
  });
17
18
  }
@@ -20,29 +21,45 @@ export function displayMetaFor(c) {
20
21
  mode: 'voltage',
21
22
  label: '|V|',
22
23
  unit: 'pu',
23
- copy: c.formulation === 'socwr'
24
- ? 'SOCWR voltage magnitude from the current relaxed solution.'
25
- : 'Bus voltage magnitude from the current power-flow result.',
24
+ copy: c.formulation === 'socwr' ? 'Voltage magnitude, SOCWR relaxation' : 'Voltage magnitude',
26
25
  gradient: priceGradient
27
26
  });
28
27
  }
29
28
  if (c.solution.va.length > 0) {
30
29
  options.push({
31
30
  mode: 'angle',
32
- label: 'angle',
31
+ label: 'Angle',
33
32
  unit: 'rad',
34
- copy: 'Bus voltage angle from the current result.',
33
+ copy: 'Voltage angle',
35
34
  gradient: priceGradient
36
35
  });
37
36
  }
37
+ // Last, so a mode the result cannot show still falls back to a result variable.
38
+ if (c.network?.buses.length) {
39
+ options.push({
40
+ mode: 'demand',
41
+ label: 'Demand',
42
+ unit: 'MW',
43
+ copy: 'Demand, square-root scale',
44
+ gradient: demandGradient
45
+ });
46
+ }
38
47
  return options;
39
48
  }
40
- /** Bus LMPs, voltage angles in radians, or voltage magnitudes in per unit.
41
- * SOCWR supplies squared voltages, which are converted to magnitudes here. */
49
+ /** Bus LMPs, voltage angles in radians, voltage magnitudes in per unit, or
50
+ * demand in MW with the case's committed changes. SOCWR supplies squared
51
+ * voltages, which are converted to magnitudes here. */
42
52
  export function displaySeriesFor(c, mode) {
43
53
  const sol = c?.solution;
44
- if (!sol)
54
+ if (!c || !sol)
45
55
  return [];
56
+ if (mode === 'demand') {
57
+ const deltas = caseDeltas(c);
58
+ return (c.network?.buses ?? []).map((b) => ({
59
+ bus: b.id,
60
+ value: b.demand_mw + (deltas[b.id] ?? 0)
61
+ }));
62
+ }
46
63
  if (mode === 'angle')
47
64
  return sol.va;
48
65
  if (mode === 'voltage') {
package/dist/format.d.ts CHANGED
@@ -1,19 +1,11 @@
1
- import type { SolveIteration } from './api.js';
2
1
  import type { RGBA } from './colors.js';
3
- import type { DisplayMode, SolveBackend } from './state.svelte.js';
2
+ import type { DisplayMode } from './state.svelte.js';
4
3
  import { type Formulation } from '@tellegen/engine';
5
4
  export { errorText } from '@tellegen/engine';
6
5
  /** The short menu label for a formulation tag (e.g. `acopf` -> `AC OPF`). */
7
6
  export declare function formulationLabel(id: Formulation): string;
8
7
  export declare function formulationHint(id: Formulation): string;
9
- export declare function priceCopy(id: Formulation): string;
10
8
  export declare function splitName(name: string): [string, string];
11
- /** Structural shape `solveMetaLabel` needs, satisfied by `CaseState` and `LocalCase`. */
12
- type SolveMeta = {
13
- iterations?: SolveIteration[];
14
- solveBackend: SolveBackend | null;
15
- };
16
- export declare function solveMetaLabel(c: SolveMeta): string;
17
9
  export declare const INFEASIBLE_DISPATCH = "No feasible dispatch: demand cannot be served within line and generator limits";
18
10
  /** The message for a local case whose browser solve failed, and the
19
11
  * technical detail to disclose behind it. An infeasible DC OPF means the
package/dist/format.js CHANGED
@@ -9,23 +9,10 @@ export function formulationLabel(id) {
9
9
  export function formulationHint(id) {
10
10
  return FORMULATIONS.find((f) => f.id === id)?.hint ?? id;
11
11
  }
12
- export function priceCopy(id) {
13
- const definition = 'Locational marginal price (LMP): the change in the OPF objective for an additional MW at a bus, in objective units/MW.';
14
- return id === 'socwr'
15
- ? `${definition} Prices come from the SOCWR relaxation. Select a bus to see their sensitivity.`
16
- : `${definition} Select a bus to see its demand sensitivity.`;
17
- }
18
12
  export function splitName(name) {
19
13
  const m = name.match(/^(.*?)\s*\((.*)\)$/);
20
14
  return m ? [m[1], m[2]] : [name, ''];
21
15
  }
22
- export function solveMetaLabel(c) {
23
- if ((c.iterations ?? []).length > 1)
24
- return `${c.iterations?.length} iterations`;
25
- if (c.solveBackend === 'clarabel-wasm-server-sensitivity')
26
- return 'server price sensitivity';
27
- return c.solveBackend === 'rust-server' ? 'server solve' : 'browser solve';
28
- }
29
16
  export const INFEASIBLE_DISPATCH = 'No feasible dispatch: demand cannot be served within line and generator limits';
30
17
  /** The message for a local case whose browser solve failed, and the
31
18
  * technical detail to disclose behind it. An infeasible DC OPF means the
@@ -59,4 +46,4 @@ export function extent(values) {
59
46
  export const fmt = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
60
47
  export const signed = (v) => `${v < 0 ? '−' : '+'}${fmt.format(Math.abs(v))}`;
61
48
  export const signedExp = (v) => `${v < 0 ? '−' : '+'}${Math.abs(v).toExponential(2)}`;
62
- export const displayFmt = (mode, value) => mode === 'price' ? fmt.format(value) : value.toFixed(3);
49
+ export const displayFmt = (mode, value) => mode === 'price' || mode === 'demand' ? fmt.format(value) : value.toFixed(3);