@tellegen/svelte 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/TellegenMap.svelte +63 -22
- package/dist/TellegenProvider.svelte +7 -4
- package/dist/TellegenShell.svelte +10 -1
- package/dist/TellegenViewer.svelte +2 -0
- package/dist/basemap.d.ts +14 -0
- package/dist/basemap.js +27 -0
- package/dist/colors.d.ts +6 -1
- package/dist/colors.js +11 -4
- package/dist/components/DemandSlider.svelte +0 -2
- package/dist/components/FormulationSelector.svelte +1 -1
- package/dist/components/LocalCaseDetails.svelte +18 -3
- package/dist/components/McResults.svelte +18 -3
- package/dist/components/McWorkbench.svelte +688 -0
- package/dist/components/McWorkbench.svelte.d.ts +38 -0
- package/dist/components/MultiWire.svelte +136 -0
- package/dist/components/MultiWire.svelte.d.ts +7 -0
- package/dist/components/MulticonductorDetails.svelte +7 -0
- package/dist/components/OmniSearch.svelte +163 -0
- package/dist/components/OmniSearch.svelte.d.ts +14 -0
- package/dist/components/ResultsTable.svelte +259 -0
- package/dist/components/ResultsTable.svelte.d.ts +13 -0
- package/dist/components/SchematicCanvas.svelte +1896 -0
- package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
- package/dist/components/SolveCard.svelte +6 -0
- package/dist/components/schematic/PickCard.svelte +126 -0
- package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
- package/dist/components/schematic/export.d.ts +21 -0
- package/dist/components/schematic/export.js +110 -0
- package/dist/components/schematic/layers.d.ts +161 -0
- package/dist/components/schematic/layers.js +756 -0
- package/dist/components/schematic/paint.d.ts +28 -0
- package/dist/components/schematic/paint.js +69 -0
- package/dist/components/schematic/palette.d.ts +34 -0
- package/dist/components/schematic/palette.js +54 -0
- package/dist/components/schematic/text.d.ts +83 -0
- package/dist/components/schematic/text.js +190 -0
- package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
- package/dist/components/schematic/view-prefs.svelte.js +77 -0
- package/dist/components.d.ts +1 -0
- package/dist/components.js +1 -0
- package/dist/context.svelte.d.ts +2 -0
- package/dist/context.svelte.js +2 -0
- package/dist/controller.svelte.d.ts +33 -5
- package/dist/controller.svelte.js +173 -20
- package/dist/diagram/basemap.d.ts +28 -0
- package/dist/diagram/basemap.js +39 -0
- package/dist/diagram/bus-stacks.d.ts +19 -0
- package/dist/diagram/bus-stacks.js +99 -0
- package/dist/diagram/colour-modes.d.ts +18 -0
- package/dist/diagram/colour-modes.js +33 -0
- package/dist/diagram/connections.d.ts +30 -0
- package/dist/diagram/connections.js +129 -0
- package/dist/diagram/display-tree.d.ts +21 -0
- package/dist/diagram/display-tree.js +65 -0
- package/dist/diagram/energisation.d.ts +10 -0
- package/dist/diagram/energisation.js +31 -0
- package/dist/diagram/feeders.d.ts +30 -0
- package/dist/diagram/feeders.js +57 -0
- package/dist/diagram/galvanic-zones.d.ts +60 -0
- package/dist/diagram/galvanic-zones.js +210 -0
- package/dist/diagram/graph-inputs.d.ts +18 -0
- package/dist/diagram/graph-inputs.js +103 -0
- package/dist/diagram/index.d.ts +28 -0
- package/dist/diagram/index.js +28 -0
- package/dist/diagram/landmarks.d.ts +50 -0
- package/dist/diagram/landmarks.js +153 -0
- package/dist/diagram/layout-morph.d.ts +15 -0
- package/dist/diagram/layout-morph.js +32 -0
- package/dist/diagram/multi-wire.d.ts +44 -0
- package/dist/diagram/multi-wire.js +65 -0
- package/dist/diagram/phase-bars.d.ts +35 -0
- package/dist/diagram/phase-bars.js +44 -0
- package/dist/diagram/results-table.d.ts +47 -0
- package/dist/diagram/results-table.js +173 -0
- package/dist/diagram/schematic-equipment.d.ts +133 -0
- package/dist/diagram/schematic-equipment.js +645 -0
- package/dist/diagram/schematic-layout.d.ts +51 -0
- package/dist/diagram/schematic-layout.js +240 -0
- package/dist/diagram/search.d.ts +36 -0
- package/dist/diagram/search.js +48 -0
- package/dist/diagram/shared-routes.d.ts +21 -0
- package/dist/diagram/shared-routes.js +40 -0
- package/dist/diagram/short-names.d.ts +11 -0
- package/dist/diagram/short-names.js +54 -0
- package/dist/diagram/snapshot.d.ts +115 -0
- package/dist/diagram/snapshot.js +242 -0
- package/dist/diagram/summary.d.ts +65 -0
- package/dist/diagram/summary.js +144 -0
- package/dist/diagram/svg-export.d.ts +80 -0
- package/dist/diagram/svg-export.js +108 -0
- package/dist/diagram/tree-walk.d.ts +13 -0
- package/dist/diagram/tree-walk.js +83 -0
- package/dist/diagram/value-labels.d.ts +24 -0
- package/dist/diagram/value-labels.js +69 -0
- package/dist/diagram/view-state.d.ts +12 -0
- package/dist/diagram/view-state.js +22 -0
- package/dist/diagram/voltage-levels.d.ts +14 -0
- package/dist/diagram/voltage-levels.js +20 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/state.svelte.d.ts +37 -1
- package/dist/state.svelte.js +35 -0
- package/dist/transformer-icon.d.ts +22 -13
- package/dist/transformer-icon.js +23 -13
- package/package.json +2 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { neutralTerminal } from '../multiconductor.js';
|
|
2
|
+
/** The second member of a phase–ground pair. */
|
|
3
|
+
export const GROUND = 'ground';
|
|
4
|
+
const magnitude = (z) => Math.hypot(z.re, z.im);
|
|
5
|
+
const sub = (a, b) => ({ re: a.re - b.re, im: a.im - b.im });
|
|
6
|
+
/** Voltage unbalance factor |V2| / |V1| in percent, from three phasors in phase order. The ratio
|
|
7
|
+
* is the same for phase–neutral and phase–phase voltages, so either reference works. */
|
|
8
|
+
export function sequenceUnbalance(a, b, c) {
|
|
9
|
+
// α = e^{j2π/3}: V1 = (a + α b + α² c) / 3, V2 = (a + α² b + α c) / 3.
|
|
10
|
+
const h = Math.sqrt(3) / 2;
|
|
11
|
+
const rot = (z, s) => ({
|
|
12
|
+
re: -0.5 * z.re - s * h * z.im,
|
|
13
|
+
im: s * h * z.re - 0.5 * z.im
|
|
14
|
+
});
|
|
15
|
+
const v1 = { re: a.re + rot(b, 1).re + rot(c, -1).re, im: a.im + rot(b, 1).im + rot(c, -1).im };
|
|
16
|
+
const v2 = { re: a.re + rot(b, -1).re + rot(c, 1).re, im: a.im + rot(b, -1).im + rot(c, 1).im };
|
|
17
|
+
const positive = magnitude(v1);
|
|
18
|
+
return positive > 0 ? (100 * magnitude(v2)) / positive : null;
|
|
19
|
+
}
|
|
20
|
+
function busSnapshot(bus, voltage) {
|
|
21
|
+
// A declared neutral (or a declared absence of one) wins; aliases only when nothing is declared.
|
|
22
|
+
const neutral = neutralTerminal(bus.terminals, bus.neutral);
|
|
23
|
+
const phases = bus.terminals.filter((t) => t !== neutral);
|
|
24
|
+
const limits = bus.limits ?? {};
|
|
25
|
+
const pairs = [];
|
|
26
|
+
const phasors = [];
|
|
27
|
+
const zero = { re: 0, im: 0 };
|
|
28
|
+
// Each declared reference that applies here gets its pairs. Without one, phase–neutral where
|
|
29
|
+
// there is a neutral, else phase–phase on three phases.
|
|
30
|
+
const ground = limits.phaseGround ?? [];
|
|
31
|
+
// Phase–phase pairs in the order (1,2), (2,3), (3,1), each with its declared band.
|
|
32
|
+
const triple = phases.length === 3
|
|
33
|
+
? [
|
|
34
|
+
[phases[0], phases[1]],
|
|
35
|
+
[phases[1], phases[2]],
|
|
36
|
+
[phases[2], phases[0]]
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
const declaredBands = limits.phasePhase;
|
|
40
|
+
const bands = declaredBands
|
|
41
|
+
? triple.map(([a, b]) => declaredBands.find(({ pair }) => !pair || (pair[0] === a && pair[1] === b) || (pair[0] === b && pair[1] === a)) ?? null)
|
|
42
|
+
: [];
|
|
43
|
+
const declared = (!!neutral && !!limits.phaseNeutral) || bands.some((band) => band) || ground.length > 0;
|
|
44
|
+
if (neutral && (limits.phaseNeutral || !declared)) {
|
|
45
|
+
const vn = voltage(neutral) ?? zero;
|
|
46
|
+
phases.forEach((t, i) => {
|
|
47
|
+
const v = voltage(t);
|
|
48
|
+
if (!v)
|
|
49
|
+
return;
|
|
50
|
+
pairs.push({
|
|
51
|
+
pair: [t, neutral],
|
|
52
|
+
zone: 'phase–neutral',
|
|
53
|
+
volts: magnitude(sub(v, vn)),
|
|
54
|
+
min: limits.phaseNeutral?.min[i] ?? null,
|
|
55
|
+
max: limits.phaseNeutral?.max[i] ?? null
|
|
56
|
+
});
|
|
57
|
+
phasors.push(sub(v, vn));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (triple.length && (bands.some((band) => band) || (!declared && !neutral))) {
|
|
61
|
+
triple.forEach(([a, b], k) => {
|
|
62
|
+
const va = voltage(a);
|
|
63
|
+
const vb = voltage(b);
|
|
64
|
+
if (!va || !vb)
|
|
65
|
+
return;
|
|
66
|
+
pairs.push({
|
|
67
|
+
pair: [a, b],
|
|
68
|
+
zone: 'phase–phase',
|
|
69
|
+
volts: magnitude(sub(va, vb)),
|
|
70
|
+
min: bands[k]?.min ?? null,
|
|
71
|
+
max: bands[k]?.max ?? null
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Terminal voltages are to ground, so a phase–ground pair is the terminal's own magnitude.
|
|
76
|
+
for (const { terminal, min, max } of ground) {
|
|
77
|
+
const v = voltage(terminal);
|
|
78
|
+
if (v)
|
|
79
|
+
pairs.push({ pair: [terminal, GROUND], zone: 'phase–ground', volts: magnitude(v), min, max });
|
|
80
|
+
}
|
|
81
|
+
// VUF is the same from any reference: a shared neutral shift is zero sequence, and phase–phase
|
|
82
|
+
// differences scale both sequences alike. Without phase–neutral phasors, the phase terminals'.
|
|
83
|
+
if (!phasors.length && phases.length === 3)
|
|
84
|
+
for (const t of phases) {
|
|
85
|
+
const v = voltage(t);
|
|
86
|
+
if (v)
|
|
87
|
+
phasors.push(v);
|
|
88
|
+
}
|
|
89
|
+
const vuf = phasors.length === 3 ? sequenceUnbalance(phasors[0], phasors[1], phasors[2]) : null;
|
|
90
|
+
return { pairs, unbalance: vuf === null ? null : { percent: vuf, definition: 'VUF' } };
|
|
91
|
+
}
|
|
92
|
+
/** Project a full multiconductor power-flow result onto the drawing's bus and branch order.
|
|
93
|
+
* Missing ports leave an end `null`; nothing is interpolated. */
|
|
94
|
+
export function snapshotFromMcPf(result, buses, branches, label = 'Tellegen multiconductor power flow') {
|
|
95
|
+
// Nested maps keyed by bus, then element: no composite string keys on a 100k-bus result.
|
|
96
|
+
const voltages = new Map();
|
|
97
|
+
for (const t of result.terminals) {
|
|
98
|
+
let bus = voltages.get(t.bus);
|
|
99
|
+
if (!bus)
|
|
100
|
+
voltages.set(t.bus, (bus = new Map()));
|
|
101
|
+
bus.set(t.terminal, t.voltage);
|
|
102
|
+
}
|
|
103
|
+
const ports = new Map();
|
|
104
|
+
for (const p of result.element_ports) {
|
|
105
|
+
let element = ports.get(p.element);
|
|
106
|
+
if (!element)
|
|
107
|
+
ports.set(p.element, (element = new Map()));
|
|
108
|
+
const rows = element.get(p.bus);
|
|
109
|
+
if (rows)
|
|
110
|
+
rows.push(p);
|
|
111
|
+
else
|
|
112
|
+
element.set(p.bus, [p]);
|
|
113
|
+
}
|
|
114
|
+
/** The element's port rows, of its own kind when a line and a switch share a name. A closed
|
|
115
|
+
* switch has no ports of its own (the solver merges its buses), so it must not borrow another
|
|
116
|
+
* element's rows. */
|
|
117
|
+
const elementPorts = (branch, bus) => {
|
|
118
|
+
const all = ports.get(branch.id)?.get(bus);
|
|
119
|
+
if (!all || branch.kind === undefined)
|
|
120
|
+
return all;
|
|
121
|
+
return all.filter((r) => r.kind === branch.kind);
|
|
122
|
+
};
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const branchSnapshots = branches.map((branch) => {
|
|
125
|
+
const key = `${branch.kind ?? ''}\u0000${branch.id}`;
|
|
126
|
+
const primary = !seen.has(key);
|
|
127
|
+
seen.add(key);
|
|
128
|
+
const ends = [branch.from, branch.to].map((bus, side) => {
|
|
129
|
+
const rows = elementPorts(branch, bus);
|
|
130
|
+
if (!branch.inService || !rows?.length)
|
|
131
|
+
return null;
|
|
132
|
+
let p = 0;
|
|
133
|
+
let q = 0;
|
|
134
|
+
const byTerminal = new Map();
|
|
135
|
+
for (const r of rows) {
|
|
136
|
+
p += r.power_into_element.re;
|
|
137
|
+
q += r.power_into_element.im;
|
|
138
|
+
byTerminal.set(r.terminal, r);
|
|
139
|
+
}
|
|
140
|
+
const currents = [];
|
|
141
|
+
for (const terminal of side ? branch.toTerminals : branch.fromTerminals) {
|
|
142
|
+
const row = byTerminal.get(terminal);
|
|
143
|
+
if (row)
|
|
144
|
+
currents.push({ terminal, amps: magnitude(row.current_into_element) });
|
|
145
|
+
}
|
|
146
|
+
return { p: p / 1000, q: q / 1000, currents };
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
ends,
|
|
150
|
+
...loadingOf(branch, ends, elementPorts),
|
|
151
|
+
primary,
|
|
152
|
+
loss: primary ? equipmentLoss(branch, elementPorts) : null
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
kind: 'solved',
|
|
157
|
+
label,
|
|
158
|
+
iterations: result.iterations,
|
|
159
|
+
buses: buses.map((bus) => {
|
|
160
|
+
const terminals = voltages.get(bus.id);
|
|
161
|
+
return busSnapshot(bus, (t) => terminals?.get(t));
|
|
162
|
+
}),
|
|
163
|
+
branches: branchSnapshots
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Bus results from a live session's transferred terminal arrays (`[re, im]` interleaved), for when
|
|
167
|
+
* the full result with element ports has not been fetched. Branches are left without results. */
|
|
168
|
+
export function snapshotBusesFromTerminals(ids, voltages, buses) {
|
|
169
|
+
const index = new Map();
|
|
170
|
+
for (let k = 0; k < ids.length; k++)
|
|
171
|
+
index.set(`${ids[k][0]}\u0000${ids[k][1]}`, k);
|
|
172
|
+
return buses.map((bus) => busSnapshot(bus, (t) => {
|
|
173
|
+
const k = index.get(`${bus.id}\u0000${t}`);
|
|
174
|
+
return k === undefined ? undefined : { re: voltages[2 * k], im: voltages[2 * k + 1] };
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
/** Power summed over a set of port rows, in kW and kvar. */
|
|
178
|
+
function portPower(rows) {
|
|
179
|
+
let p = 0;
|
|
180
|
+
let q = 0;
|
|
181
|
+
for (const r of rows) {
|
|
182
|
+
p += r.power_into_element.re;
|
|
183
|
+
q += r.power_into_element.im;
|
|
184
|
+
}
|
|
185
|
+
return { p: p / 1000, q: q / 1000 };
|
|
186
|
+
}
|
|
187
|
+
/** Both ends' ports are signed into the equipment, so their sum over every bus it connects is what
|
|
188
|
+
* it consumes. Each bus counts once, however many winding pairs join it. */
|
|
189
|
+
function equipmentLoss(branch, rows) {
|
|
190
|
+
if (!branch.inService)
|
|
191
|
+
return null;
|
|
192
|
+
const buses = new Set([branch.from, branch.to]);
|
|
193
|
+
if (branch.rating?.kind === 'apparent')
|
|
194
|
+
for (const bus of Object.keys(branch.rating.byBus))
|
|
195
|
+
buses.add(bus);
|
|
196
|
+
let p = 0;
|
|
197
|
+
let q = 0;
|
|
198
|
+
for (const bus of buses) {
|
|
199
|
+
const at = rows(branch, bus);
|
|
200
|
+
if (!at?.length)
|
|
201
|
+
return null;
|
|
202
|
+
const power = portPower(at);
|
|
203
|
+
p += power.p;
|
|
204
|
+
q += power.q;
|
|
205
|
+
}
|
|
206
|
+
return { p, q };
|
|
207
|
+
}
|
|
208
|
+
function loadingOf(branch, ends, rows) {
|
|
209
|
+
const rating = branch.rating;
|
|
210
|
+
if (!rating || !ends[0] || !ends[1])
|
|
211
|
+
return { loading: null, basis: null };
|
|
212
|
+
if (rating.kind === 'current') {
|
|
213
|
+
const ratios = ends.flatMap((end, side) => (end?.currents ?? []).map((c) => {
|
|
214
|
+
const k = (side ? branch.toTerminals : branch.fromTerminals).indexOf(c.terminal);
|
|
215
|
+
return rating.amps[k] > 0 ? c.amps / rating.amps[k] : NaN;
|
|
216
|
+
}));
|
|
217
|
+
return ratios.length && ratios.every(Number.isFinite)
|
|
218
|
+
? {
|
|
219
|
+
loading: 100 * Math.max(...ratios),
|
|
220
|
+
basis: 'Maximum conductor |I| / rating across both ends'
|
|
221
|
+
}
|
|
222
|
+
: { loading: null, basis: null };
|
|
223
|
+
}
|
|
224
|
+
// Judged per bus against the transformer's rating there, so a winding-pair edge whose two ends
|
|
225
|
+
// are the same bus (a centre tap's halves) is not counted twice.
|
|
226
|
+
let loading = null;
|
|
227
|
+
for (const [bus, va] of Object.entries(rating.byBus)) {
|
|
228
|
+
const at = rows(branch, bus);
|
|
229
|
+
if (!(va > 0) || !at?.length)
|
|
230
|
+
continue;
|
|
231
|
+
const { p, q } = portPower(at);
|
|
232
|
+
const percent = (100 * Math.hypot(p, q) * 1000) / va;
|
|
233
|
+
if (loading === null || percent > loading)
|
|
234
|
+
loading = percent;
|
|
235
|
+
}
|
|
236
|
+
return loading === null
|
|
237
|
+
? { loading: null, basis: null }
|
|
238
|
+
: {
|
|
239
|
+
loading,
|
|
240
|
+
basis: 'Maximum |S| at a connected bus / rating there; aggregate, not per winding'
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { BusSnapshot, PairVoltage, PairZone, ResultSnapshot, UnbalanceValue } from './snapshot.js';
|
|
2
|
+
/** Loading thresholds in percent of rating: a warning from the shared attention line, a violation
|
|
3
|
+
* above the rating itself, since the multiconductor power flow does not enforce ratings. */
|
|
4
|
+
export declare const LOADING_WARNING: number;
|
|
5
|
+
export declare const LOADING_LIMIT = 100;
|
|
6
|
+
export type VoltageState = 'normal' | 'violation' | 'unbounded' | 'missing';
|
|
7
|
+
export type LoadingState = 'normal' | 'warning' | 'violation' | 'missing';
|
|
8
|
+
export declare function pairState(p: PairVoltage): VoltageState;
|
|
9
|
+
export declare function busState(bus: BusSnapshot): VoltageState;
|
|
10
|
+
export declare function loadingState(loading: number | null): LoadingState;
|
|
11
|
+
/** Distance outside [min, max] as a percentage of the violated limit; negative inside the band. */
|
|
12
|
+
export declare function exceedance(p: PairVoltage): number;
|
|
13
|
+
/** Magnitude-only fallback: max deviation from the mean / mean (IEEE PVUR or NEMA LVUR), over the
|
|
14
|
+
* three pairs of the bus's first reference. */
|
|
15
|
+
export declare function magnitudeUnbalance(all: PairVoltage[]): UnbalanceValue | null;
|
|
16
|
+
/** Phasor unbalance when the snapshot has it, otherwise the labelled magnitude fallback. */
|
|
17
|
+
export declare const busUnbalance: (bus: BusSnapshot) => UnbalanceValue | null;
|
|
18
|
+
export interface Issue {
|
|
19
|
+
kind: 'voltage' | 'loading';
|
|
20
|
+
severity: 'violation' | 'warning';
|
|
21
|
+
target: {
|
|
22
|
+
kind: 'bus' | 'branch';
|
|
23
|
+
index: number;
|
|
24
|
+
};
|
|
25
|
+
value: number;
|
|
26
|
+
limit: number;
|
|
27
|
+
score: number;
|
|
28
|
+
pair?: [string, string];
|
|
29
|
+
low?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Violations first, then the largest relative exceedance; the index keeps ties deterministic. */
|
|
32
|
+
export declare function rankIssues(result: ResultSnapshot): Issue[];
|
|
33
|
+
export interface VoltageExtreme {
|
|
34
|
+
bus: number;
|
|
35
|
+
pair: [string, string];
|
|
36
|
+
volts: number;
|
|
37
|
+
state: VoltageState;
|
|
38
|
+
}
|
|
39
|
+
export interface ZoneSummary {
|
|
40
|
+
zone: PairZone;
|
|
41
|
+
min: VoltageExtreme;
|
|
42
|
+
max: VoltageExtreme;
|
|
43
|
+
buses: number;
|
|
44
|
+
}
|
|
45
|
+
export interface SnapshotSummary {
|
|
46
|
+
loss: {
|
|
47
|
+
p: number;
|
|
48
|
+
q: number;
|
|
49
|
+
branches: number;
|
|
50
|
+
};
|
|
51
|
+
loading: {
|
|
52
|
+
branch: number;
|
|
53
|
+
value: number;
|
|
54
|
+
} | null;
|
|
55
|
+
unbalance: (UnbalanceValue & {
|
|
56
|
+
bus: number;
|
|
57
|
+
}) | null;
|
|
58
|
+
zones: ZoneSummary[];
|
|
59
|
+
counts: {
|
|
60
|
+
voltage: number;
|
|
61
|
+
overload: number;
|
|
62
|
+
warning: number;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export declare function snapshotSummary(result: ResultSnapshot, issues?: Issue[]): SnapshotSummary;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { LOADING_ATTENTION } from '../colors.js';
|
|
2
|
+
/** Loading thresholds in percent of rating: a warning from the shared attention line, a violation
|
|
3
|
+
* above the rating itself, since the multiconductor power flow does not enforce ratings. */
|
|
4
|
+
export const LOADING_WARNING = 100 * LOADING_ATTENTION;
|
|
5
|
+
export const LOADING_LIMIT = 100;
|
|
6
|
+
export function pairState(p) {
|
|
7
|
+
if (!Number.isFinite(p.volts))
|
|
8
|
+
return 'missing';
|
|
9
|
+
if (p.min === null || p.max === null)
|
|
10
|
+
return 'unbounded';
|
|
11
|
+
return p.volts < p.min || p.volts > p.max ? 'violation' : 'normal';
|
|
12
|
+
}
|
|
13
|
+
export function busState(bus) {
|
|
14
|
+
if (!bus.pairs.length)
|
|
15
|
+
return 'missing';
|
|
16
|
+
const states = bus.pairs.map(pairState);
|
|
17
|
+
return states.includes('violation')
|
|
18
|
+
? 'violation'
|
|
19
|
+
: states.includes('normal')
|
|
20
|
+
? 'normal'
|
|
21
|
+
: states[0];
|
|
22
|
+
}
|
|
23
|
+
export function loadingState(loading) {
|
|
24
|
+
if (loading === null || !Number.isFinite(loading))
|
|
25
|
+
return 'missing';
|
|
26
|
+
return loading > LOADING_LIMIT ? 'violation' : loading >= LOADING_WARNING ? 'warning' : 'normal';
|
|
27
|
+
}
|
|
28
|
+
/** Distance outside [min, max] as a percentage of the violated limit; negative inside the band. */
|
|
29
|
+
export function exceedance(p) {
|
|
30
|
+
if (p.min === null || p.max === null)
|
|
31
|
+
return Number.NEGATIVE_INFINITY;
|
|
32
|
+
if (p.volts < p.min)
|
|
33
|
+
return (100 * (p.min - p.volts)) / p.min;
|
|
34
|
+
if (p.volts > p.max)
|
|
35
|
+
return (100 * (p.volts - p.max)) / p.max;
|
|
36
|
+
return (-100 * Math.min(p.volts - p.min, p.max - p.volts)) / (p.max - p.min);
|
|
37
|
+
}
|
|
38
|
+
/** Magnitude-only fallback: max deviation from the mean / mean (IEEE PVUR or NEMA LVUR), over the
|
|
39
|
+
* three pairs of the bus's first reference. */
|
|
40
|
+
export function magnitudeUnbalance(all) {
|
|
41
|
+
const zone = all[0]?.zone;
|
|
42
|
+
const pairs = all.filter((p) => p.zone === zone);
|
|
43
|
+
if (pairs.length !== 3)
|
|
44
|
+
return null;
|
|
45
|
+
const mean = (pairs[0].volts + pairs[1].volts + pairs[2].volts) / 3;
|
|
46
|
+
if (!(mean > 0))
|
|
47
|
+
return null;
|
|
48
|
+
const percent = (100 * Math.max(...pairs.map((p) => Math.abs(p.volts - mean)))) / mean;
|
|
49
|
+
return { percent, definition: zone === 'phase–phase' ? 'NEMA LVUR' : 'IEEE PVUR' };
|
|
50
|
+
}
|
|
51
|
+
/** Phasor unbalance when the snapshot has it, otherwise the labelled magnitude fallback. */
|
|
52
|
+
export const busUnbalance = (bus) => bus.unbalance ?? magnitudeUnbalance(bus.pairs);
|
|
53
|
+
/** Violations first, then the largest relative exceedance; the index keeps ties deterministic. */
|
|
54
|
+
export function rankIssues(result) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
result.buses.forEach((bus, index) => {
|
|
57
|
+
let worst = null;
|
|
58
|
+
for (const p of bus.pairs) {
|
|
59
|
+
if (pairState(p) === 'violation' && (!worst || exceedance(p) > exceedance(worst)))
|
|
60
|
+
worst = p;
|
|
61
|
+
}
|
|
62
|
+
if (!worst || worst.min === null || worst.max === null)
|
|
63
|
+
return;
|
|
64
|
+
const low = worst.volts < worst.min;
|
|
65
|
+
issues.push({
|
|
66
|
+
kind: 'voltage',
|
|
67
|
+
severity: 'violation',
|
|
68
|
+
target: { kind: 'bus', index },
|
|
69
|
+
pair: worst.pair,
|
|
70
|
+
value: worst.volts,
|
|
71
|
+
limit: low ? worst.min : worst.max,
|
|
72
|
+
low,
|
|
73
|
+
score: exceedance(worst)
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
result.branches.forEach((branch, index) => {
|
|
77
|
+
if (!branch.primary)
|
|
78
|
+
return;
|
|
79
|
+
const state = loadingState(branch.loading);
|
|
80
|
+
if (state !== 'warning' && state !== 'violation')
|
|
81
|
+
return;
|
|
82
|
+
const value = branch.loading;
|
|
83
|
+
issues.push({
|
|
84
|
+
kind: 'loading',
|
|
85
|
+
severity: state,
|
|
86
|
+
target: { kind: 'branch', index },
|
|
87
|
+
value,
|
|
88
|
+
limit: LOADING_LIMIT,
|
|
89
|
+
score: value - LOADING_LIMIT
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
const rank = (i) => (i.severity === 'violation' ? 0 : 1);
|
|
93
|
+
return issues.sort((a, b) => rank(a) - rank(b) || b.score - a.score || a.target.index - b.target.index);
|
|
94
|
+
}
|
|
95
|
+
export function snapshotSummary(result, issues = rankIssues(result)) {
|
|
96
|
+
const zones = new Map();
|
|
97
|
+
let unbalance = null;
|
|
98
|
+
result.buses.forEach((bus, index) => {
|
|
99
|
+
for (const p of bus.pairs) {
|
|
100
|
+
const extreme = { bus: index, pair: p.pair, volts: p.volts, state: pairState(p) };
|
|
101
|
+
const zone = zones.get(p.zone);
|
|
102
|
+
if (!zone)
|
|
103
|
+
zones.set(p.zone, { min: extreme, max: extreme, buses: new Set([index]) });
|
|
104
|
+
else {
|
|
105
|
+
zone.buses.add(index);
|
|
106
|
+
if (p.volts < zone.min.volts)
|
|
107
|
+
zone.min = extreme;
|
|
108
|
+
if (p.volts > zone.max.volts)
|
|
109
|
+
zone.max = extreme;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const u = busUnbalance(bus);
|
|
113
|
+
if (u && (!unbalance || u.percent > unbalance.percent))
|
|
114
|
+
unbalance = { ...u, bus: index };
|
|
115
|
+
});
|
|
116
|
+
const loss = { p: 0, q: 0, branches: 0 };
|
|
117
|
+
let loading = null;
|
|
118
|
+
result.branches.forEach((branch, index) => {
|
|
119
|
+
// Once per piece of equipment: a multi-winding transformer has several edges.
|
|
120
|
+
if (branch.loss) {
|
|
121
|
+
loss.p += branch.loss.p;
|
|
122
|
+
loss.q += branch.loss.q;
|
|
123
|
+
loss.branches++;
|
|
124
|
+
}
|
|
125
|
+
if (branch.loading !== null &&
|
|
126
|
+
Number.isFinite(branch.loading) &&
|
|
127
|
+
(!loading || branch.loading > loading.value))
|
|
128
|
+
loading = { branch: index, value: branch.loading };
|
|
129
|
+
});
|
|
130
|
+
const count = (kind, severity) => issues.filter((i) => i.kind === kind && i.severity === severity).length;
|
|
131
|
+
return {
|
|
132
|
+
loss,
|
|
133
|
+
loading,
|
|
134
|
+
unbalance,
|
|
135
|
+
zones: [...zones]
|
|
136
|
+
.map(([zone, z]) => ({ zone, min: z.min, max: z.max, buses: z.buses.size }))
|
|
137
|
+
.sort((a, b) => b.max.volts - a.max.volts),
|
|
138
|
+
counts: {
|
|
139
|
+
voltage: count('voltage', 'violation'),
|
|
140
|
+
overload: count('loading', 'violation'),
|
|
141
|
+
warning: count('loading', 'warning')
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Report-ready vector export of the visible view with a title block. Pure: no DOM. */
|
|
2
|
+
export declare const TITLE_BLOCK_HEIGHT = 76;
|
|
3
|
+
export declare const MAX_SVG_SEGMENTS = 200000;
|
|
4
|
+
export interface LineSet {
|
|
5
|
+
sources: ArrayLike<number>;
|
|
6
|
+
targets: ArrayLike<number>;
|
|
7
|
+
/** RGBA bytes per segment. */
|
|
8
|
+
colors: ArrayLike<number>;
|
|
9
|
+
count: number;
|
|
10
|
+
/** Per-segment pixel widths; `width` applies when absent. */
|
|
11
|
+
widths?: ArrayLike<number> | null;
|
|
12
|
+
width?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SvgText {
|
|
15
|
+
position: ArrayLike<number>;
|
|
16
|
+
text: string;
|
|
17
|
+
color?: string;
|
|
18
|
+
dx?: number;
|
|
19
|
+
dy?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SvgGlyph {
|
|
22
|
+
position: ArrayLike<number>;
|
|
23
|
+
text: string;
|
|
24
|
+
/** Counter-clockwise degrees, as text layers use. */
|
|
25
|
+
angle: number;
|
|
26
|
+
color: string;
|
|
27
|
+
size?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface TitleBlock {
|
|
30
|
+
left: string[];
|
|
31
|
+
right: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface SvgView {
|
|
34
|
+
/** World bounds `[x0, y0, x1, y1]`, y down. */
|
|
35
|
+
bounds: [number, number, number, number];
|
|
36
|
+
scale: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
background: string;
|
|
40
|
+
ink: string;
|
|
41
|
+
muted: string;
|
|
42
|
+
lineSets?: LineSet[];
|
|
43
|
+
points?: {
|
|
44
|
+
positions: ArrayLike<number>;
|
|
45
|
+
colors: ArrayLike<number>;
|
|
46
|
+
count: number;
|
|
47
|
+
radius: number;
|
|
48
|
+
} | null;
|
|
49
|
+
markers?: {
|
|
50
|
+
position: ArrayLike<number>;
|
|
51
|
+
radius: number;
|
|
52
|
+
fill?: string;
|
|
53
|
+
stroke?: string;
|
|
54
|
+
}[];
|
|
55
|
+
glyphs?: SvgGlyph[];
|
|
56
|
+
texts?: SvgText[];
|
|
57
|
+
title: TitleBlock;
|
|
58
|
+
}
|
|
59
|
+
/** Fit a title-block line into its column; monospace makes character count a safe width proxy. */
|
|
60
|
+
export declare function fitText(text: string, px: number): string;
|
|
61
|
+
/** Visible segments only, one path per colour and width. Throws rather than writing a partial
|
|
62
|
+
* drawing when the view holds more than {@link MAX_SVG_SEGMENTS}. */
|
|
63
|
+
export declare function viewportSvg(view: SvgView): string;
|
|
64
|
+
/** What was drawn, which view, and the provenance needed to reproduce it. */
|
|
65
|
+
export declare function titleBlock(t: {
|
|
66
|
+
dataset: string;
|
|
67
|
+
layout: string;
|
|
68
|
+
view: string;
|
|
69
|
+
camera: {
|
|
70
|
+
target: ArrayLike<number>;
|
|
71
|
+
zoom: number;
|
|
72
|
+
};
|
|
73
|
+
result: {
|
|
74
|
+
kind: 'solved' | 'stress';
|
|
75
|
+
label: string;
|
|
76
|
+
iterations: number | null;
|
|
77
|
+
revision: string;
|
|
78
|
+
} | null;
|
|
79
|
+
exported: string;
|
|
80
|
+
}): TitleBlock;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Report-ready vector export of the visible view with a title block. Pure: no DOM. */
|
|
2
|
+
export const TITLE_BLOCK_HEIGHT = 76;
|
|
3
|
+
export const MAX_SVG_SEGMENTS = 200_000;
|
|
4
|
+
const CHAR_PX = 6.7;
|
|
5
|
+
const escapeXml = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
6
|
+
const rgb = (c, i = 0) => `rgb(${c[i]} ${c[i + 1]} ${c[i + 2]})`;
|
|
7
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
8
|
+
/** Fit a title-block line into its column; monospace makes character count a safe width proxy. */
|
|
9
|
+
export function fitText(text, px) {
|
|
10
|
+
const n = Math.max(4, Math.floor((px - 24) / CHAR_PX));
|
|
11
|
+
return text.length > n ? `${text.slice(0, n - 1)}…` : text;
|
|
12
|
+
}
|
|
13
|
+
/** Visible segments only, one path per colour and width. Throws rather than writing a partial
|
|
14
|
+
* drawing when the view holds more than {@link MAX_SVG_SEGMENTS}. */
|
|
15
|
+
export function viewportSvg(view) {
|
|
16
|
+
const [x0, y0, x1, y1] = view.bounds;
|
|
17
|
+
const X = (x) => r1((x - x0) * view.scale);
|
|
18
|
+
const Y = (y) => r1((y - y0) * view.scale);
|
|
19
|
+
const inside = (p) => p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1;
|
|
20
|
+
const groups = new Map();
|
|
21
|
+
let segments = 0;
|
|
22
|
+
for (const set of view.lineSets ?? []) {
|
|
23
|
+
const { sources: s, targets: t, colors } = set;
|
|
24
|
+
for (let i = 0; i < set.count; i++) {
|
|
25
|
+
const [ax, ay, bx, by] = [s[i * 3], s[i * 3 + 1], t[i * 3], t[i * 3 + 1]];
|
|
26
|
+
if (Math.max(ax, bx) < x0 ||
|
|
27
|
+
Math.min(ax, bx) > x1 ||
|
|
28
|
+
Math.max(ay, by) < y0 ||
|
|
29
|
+
Math.min(ay, by) > y1)
|
|
30
|
+
continue;
|
|
31
|
+
if (++segments > MAX_SVG_SEGMENTS)
|
|
32
|
+
throw new Error(`More than ${MAX_SVG_SEGMENTS.toLocaleString()} segments in view; zoom in to export a vector drawing`);
|
|
33
|
+
if (colors[i * 4 + 3] === 0)
|
|
34
|
+
continue;
|
|
35
|
+
const key = `${rgb(colors, i * 4)}|${set.widths ? r1(set.widths[i]) : (set.width ?? 1)}|${r1(colors[i * 4 + 3] / 255)}`;
|
|
36
|
+
const d = `M${X(ax)} ${Y(ay)}L${X(bx)} ${Y(by)}`;
|
|
37
|
+
const group = groups.get(key);
|
|
38
|
+
if (group)
|
|
39
|
+
group.push(d);
|
|
40
|
+
else
|
|
41
|
+
groups.set(key, [d]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const paths = [...groups].map(([key, d]) => {
|
|
45
|
+
const [stroke, width, alpha] = key.split('|');
|
|
46
|
+
const opacity = Number(alpha) < 1 ? ` stroke-opacity="${alpha}"` : '';
|
|
47
|
+
return `<path d="${d.join('')}" stroke="${stroke}" stroke-width="${width}"${opacity}/>`;
|
|
48
|
+
});
|
|
49
|
+
const dots = [];
|
|
50
|
+
const points = view.points;
|
|
51
|
+
if (points)
|
|
52
|
+
for (let i = 0; i < points.count; i++) {
|
|
53
|
+
const p = [points.positions[i * 3], points.positions[i * 3 + 1]];
|
|
54
|
+
if (inside(p))
|
|
55
|
+
dots.push(`<circle cx="${X(p[0])}" cy="${Y(p[1])}" r="${points.radius}" fill="${rgb(points.colors, i * 4)}"/>`);
|
|
56
|
+
}
|
|
57
|
+
for (const m of view.markers ?? [])
|
|
58
|
+
if (inside(m.position)) {
|
|
59
|
+
const stroke = m.stroke ? ` stroke="${m.stroke}" stroke-width="1.5"` : '';
|
|
60
|
+
dots.push(`<circle cx="${X(m.position[0])}" cy="${Y(m.position[1])}" r="${m.radius}" fill="${m.fill ?? 'none'}"${stroke}/>`);
|
|
61
|
+
}
|
|
62
|
+
// Text-layer angles are counter-clockwise; SVG rotation is clockwise.
|
|
63
|
+
const arrows = (view.glyphs ?? [])
|
|
64
|
+
.filter((g) => inside(g.position))
|
|
65
|
+
.map((g) => {
|
|
66
|
+
const [x, y] = [X(g.position[0]), Y(g.position[1])];
|
|
67
|
+
return `<text x="${x}" y="${y}" fill="${g.color}" font-size="${g.size ?? 22}" text-anchor="middle" dominant-baseline="central" transform="rotate(${r1(-g.angle)} ${x} ${y})">${escapeXml(g.text)}</text>`;
|
|
68
|
+
});
|
|
69
|
+
const labels = (view.texts ?? [])
|
|
70
|
+
.filter((t) => inside(t.position))
|
|
71
|
+
.map((t) => `<text x="${X(t.position[0]) + (t.dx ?? 0)}" y="${Y(t.position[1]) + (t.dy ?? 0)}" fill="${t.color ?? view.ink}">${escapeXml(t.text)}</text>`);
|
|
72
|
+
const { width, height } = view;
|
|
73
|
+
const column = width / 2;
|
|
74
|
+
const line = (x, y, text, cls) => `<text x="${x}" y="${y}" class="${cls}">${escapeXml(fitText(text, column))}</text>`;
|
|
75
|
+
const block = [
|
|
76
|
+
`<rect x="0" y="${height}" width="${width}" height="${TITLE_BLOCK_HEIGHT}" fill="${view.background}"/>`,
|
|
77
|
+
`<line x1="0" x2="${width}" y1="${height + 0.5}" y2="${height + 0.5}" stroke="${view.muted}"/>`,
|
|
78
|
+
...view.title.left.map((t, i) => line(12, height + 18 + i * 15, t, i ? 'muted' : 'strong')),
|
|
79
|
+
...view.title.right.map((t, i) => line(column + 12, height + 18 + i * 15, t, 'muted'))
|
|
80
|
+
];
|
|
81
|
+
const total = height + TITLE_BLOCK_HEIGHT;
|
|
82
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${total}" viewBox="0 0 ${width} ${total}" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="11">` +
|
|
83
|
+
`<style>path{fill:none;stroke-linecap:round}.muted{fill:${view.muted}}.strong{fill:${view.ink};font-size:12px;font-weight:600}</style>` +
|
|
84
|
+
`<rect width="${width}" height="${height}" fill="${view.background}"/>` +
|
|
85
|
+
`<g>${paths.join('')}</g><g>${dots.join('')}</g><g>${arrows.join('')}</g><g>${labels.join('')}</g>${block.join('')}</svg>`);
|
|
86
|
+
}
|
|
87
|
+
/** What was drawn, which view, and the provenance needed to reproduce it. */
|
|
88
|
+
export function titleBlock(t) {
|
|
89
|
+
const r = t.result;
|
|
90
|
+
return {
|
|
91
|
+
left: [
|
|
92
|
+
t.dataset,
|
|
93
|
+
t.layout,
|
|
94
|
+
t.view,
|
|
95
|
+
`Centre ${t.camera.target[0].toFixed(1)}, ${t.camera.target[1].toFixed(1)} · ${(2 ** t.camera.zoom).toPrecision(3)}×`
|
|
96
|
+
],
|
|
97
|
+
right: [
|
|
98
|
+
r?.kind === 'stress'
|
|
99
|
+
? 'ARTIFICIAL STRESS: NOT A SOLUTION'
|
|
100
|
+
: r
|
|
101
|
+
? `${r.label}${r.iterations === null ? '' : ` · ${r.iterations} iterations`}`
|
|
102
|
+
: 'No result snapshot',
|
|
103
|
+
r ? `Result revision ${r.revision.slice(0, 16)}${r.revision.length > 16 ? '…' : ''}` : '',
|
|
104
|
+
'P/Q positive into equipment at each end',
|
|
105
|
+
`Exported ${t.exported}`
|
|
106
|
+
].filter(Boolean)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DistGraph } from '@tellegen/engine';
|
|
2
|
+
import type { DisplayTree } from './display-tree.js';
|
|
3
|
+
export type WalkKey = 'parent' | 'child' | 'previous' | 'next' | 'root';
|
|
4
|
+
/** Keyboard navigation over the display tree, ordered as drawn: siblings and roots by their
|
|
5
|
+
* position (top to bottom, then left to right), so Up and Down move as the eye does. */
|
|
6
|
+
export interface TreeWalk {
|
|
7
|
+
step(from: number, key: WalkKey): number;
|
|
8
|
+
children: number[][];
|
|
9
|
+
}
|
|
10
|
+
export declare function treeWalk(tree: DisplayTree, positions: ArrayLike<number>, stride?: number): TreeWalk;
|
|
11
|
+
/** A spoken description of bus `i` for the keyboard cursor: where it sits in the tree, what hangs
|
|
12
|
+
* off it, and an optional result note (for example the worst voltage against its band). */
|
|
13
|
+
export declare function describeBus(graph: DistGraph, tree: DisplayTree, walk: TreeWalk, i: number, note?: string | null): string;
|