File size: 11,793 Bytes
1c730d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# Loading Study — parallelised XHR waterfall + text-format base diagram

## Context (v6 trace)

Chrome DevTools trace of a full "Load Study" gesture on the large grid
(PyPSA-EUR France 400 kV, ~25 MB SVG) showed the following XHR
waterfall:

```
0.0 s  ────── /api/config              14.1 s   (server pypowsybl load)
14.1 s ─┬──── /api/voltage-levels       0.3 s
        ├──── /api/nominal-voltages     0.3 s
        └──── /api/branches             0.8 s
14.9 s ────── /api/network-diagram      6.6 s   (2.5 MB gzip, 25 MB decoded)
21.5 s ────── client render            ~3.5 s
```

Two findings in the waterfall:

1. **`/api/network-diagram` waited for `/api/branches`** — the frontend
   passed `voltageLevels.length` as a hint to `processSvg` (node/text
   upscaling on large grids), so the diagram call only fired after the
   `Promise.all([branches, voltage-levels, nominal-voltages])` settled.
   That ~0.8 s gap is pure serialisation cost.
2. **`XHRLoad` = 620 ms** on the 25 MB response — attributable to
   `JSON.parse` of the giant SVG string embedded inside the JSON envelope.
   JSON parsers have to escape-scan every byte and allocate a second
   buffer for the string value, both of which scale linearly with
   payload size.

## Changes

### #1 — Parallelise the 4 post-config XHRs (`App.tsx` + `useSession.ts`)

```diff
- const [branchRes, vlRes, nomVRes] = await Promise.all([
-   api.getBranches(),
-   api.getVoltageLevels(),
-   api.getNominalVoltages(),
- ]);
- // ... state sets ...
- diagrams.fetchBaseDiagram(vlRes.voltage_levels.length);
+ const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([
+   api.getBranches(),
+   api.getVoltageLevels(),
+   api.getNominalVoltages(),
+   api.getNetworkDiagram(),
+ ]);
+ // ... state sets ...
+ diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length);
```

Three call sites were updated:

- `App.tsx::applySettingsImmediate` (Apply Settings button)
- `App.tsx::handleLoadConfig` (Load Study button)
- `useSession.ts::handleRestoreSession` (session reload)

A new `ingestBaseDiagram(raw, vlCount)` method on `useDiagrams` does
just the `processSvg` + state-set work (no fetch), letting callers
drive the parallelism themselves. The legacy `fetchBaseDiagram(vlCount)`
helper is kept for backwards compat (tests, future single-call paths).

**Wire impact**: the diagram XHR now fires alongside
`branches`/`voltage-levels`/`nominal-voltages` at `t = config_end`,
so the critical path becomes `MAX(branches, nomV, vl, diagram) =
diagram`. Saves the ~0.8 s branches gap.

### #4 — `format=text` response for `/api/network-diagram`

Adds a `format` query parameter to the endpoint:

| `?format=json` (default) | `?format=text` (new) |
|---|---|
| `Content-Type: application/json` | `Content-Type: text/plain; charset=utf-8` |
| Body: `{"svg": "<svg>...</svg>", "metadata":..., ...}` | Body: `{"metadata":..., ...}\n<svg>...</svg>` |
| `JSON.parse(body)` scans the full 25 MB | `body.indexOf('\n')` + `JSON.parse(prefix)` — the SVG never passes through `JSON.parse` |

Gzip is still applied when the client signals `Accept-Encoding: gzip`
(same gate as `_maybe_gzip_json`), so wire bytes are unchanged.

The JSON default is preserved for backwards compatibility:
- `standalone_interface.html` (uses axios's JSON mode directly)
- third-party API consumers
- backend test `test_success` that still asserts on `response.json()`

The frontend `api.getNetworkDiagram` helper switches to
`?format=text` via `fetch` (axios would try to JSON-parse the body).

## Invariants (tested)

Backend (`test_api_endpoints.py::TestGetNetworkDiagram`):

- `test_success` — default `format=json` still returns `{"svg": ..., "metadata": ...}`.
- `test_text_format_returns_header_plus_svg``format=text` returns
  `Content-Type: text/plain`, the first line parses as JSON with all
  non-`svg` fields (`lines_overloaded`, `lines_overloaded_rho`, `metadata`),
  and the SVG is the rest of the body verbatim (NOT JSON-escaped).
- `test_text_format_gzip` — large text-format responses are
  `Content-Encoding: gzip` when the client sends `Accept-Encoding: gzip`.

Frontend (`api.test.ts::getNetworkDiagram`):

- Parses header + SVG correctly from the `text/plain` body.
- Surfaces non-2xx responses as thrown errors (preserves existing
  try/catch in App.tsx).

## Measured impact

Baseline (v6 trace, current main tip before this change):

| Segment | v6 wall-clock |
|---|---|
| `/api/config` | 14 103 ms |
| `/api/branches` (serial gate) | 775 ms |
| `/api/network-diagram` | 6 645 ms |
| XHRLoad (`JSON.parse`) | 618 ms |
| **Load Study total** | **~24.0 s** |

### v7 trace — after this change

#### Waterfall (XHRs)

| XHR | v6 timing | v7 timing | Δ |
|---|---|---|---|
| `/api/config` | 0 → 14 103 | 0 → 14 790 | +687 ms (server variance) |
| `/api/branches` | 14 101 → 14 876 | 14 788 → **15 553** | now runs **in parallel** with diagram |
| `/api/voltage-levels` | 14 102 → 14 428 | 14 788 → 15 158 | parallel |
| `/api/nominal-voltages` | 14 102 → 14 410 | 14 789 → 15 136 | parallel |
| `/api/network-diagram` | **start 14 875** (after branches) | **start 14 789** (parallel) | start −86 ms |
| `/api/network-diagram` | end 21 520 | end 21 174 | **−346 ms end** |
| `/api/network-diagram` server-side | 6 645 ms | 6 385 ms | −260 ms (body size same, likely encoding CPU saved) |

✅ Parallelisation confirmed: the base-diagram XHR now starts at the
same timestamp as `branches` / `voltage-levels` / `nominal-voltages`.

#### Render window (3 s post-diagram-XHR, matched between traces)

| Metric | v6 | v7 | Δ |
|---|---|---|---|
| `RasterTask` | 1 610 ms | 1 411 ms | −199 ms |
| `Paint` | 749 ms | 855 ms | +106 ms (variance) |
| `UpdateLayoutTree` | 340 ms | 357 ms | +17 ms |
| `Layout` | 285 ms | 292 ms | +7 ms |
| `Layerize` | 131 ms | 102 ms | −29 ms |
| `PrePaint` | 160 ms | 87 ms | −73 ms |
| **`ParseHTML`** | **414 ms** | **293 ms** | **−121 ms** ✅ |
| **Long tasks cumulés (fenêtre 3 s)** | **4 818 ms** | **2 405 ms** | **−2 413 ms (−50 %)** 🎯 |

✅ Text-format confirmed: `ParseHTML` drops by 121 ms because the SVG
bytes go straight to Blink's HTML parser instead of being unwrapped
from a JSON-encoded string first. `RasterTask`, `PrePaint`, and
`Layerize` all drop slightly — indirect benefit of less string
allocation pressure during the render.

#### Critical-path summary

| | v6 | v7 | Δ |
|---|---|---|---|
| config end → diagram XHR end | 7 417 ms | **6 384 ms** | **−1 033 ms (−14 %)** |
| Full Load Study wall-clock | ~24.0 s | **~21.2 s** | **−2.8 s (−12 %)** |

The ~1 s saved on the XHR critical path is the sum of (a) parallelising
the branches gap, (b) shaving JSON encoding server-side because the SVG
bypasses JSON serialisation in `format=text` mode. The ~2 s saved post-XHR
is pure client-side rendering budget (less `ParseHTML`, less GC pressure,
less allocation thrash on the 25 MB string).

Expected before the change:

> Load Study total: ~24.0 s → ~20.9 s (−12 %).

Measured: ~24.0 s → **~21.2 s** (−12 %). On target.

### v8-v17 follow-up: NAD prefetch, network mutualisation & vectorisation

Cumulative gains delivered by subsequent commits on the same branch
(documented in their own files — `docs/performance/history/nad-prefetch.md`,
`docs/performance/history/shared-network.md`, `docs/performance/history/grid2op-shared-network.md`,
`docs/performance/history/vectorize-topology-cache.md`,
`docs/performance/history/topology-cache-iter2.md`,
`docs/performance/history/nad-prefetch-earlier-spawn.md`,
`docs/performance/history/initial-lf-dc-init.md`,
`docs/performance/history/narrow-voltage-level-queries.md`):

| Trace | Last XHR end | Δ vs v7 | Key change |
|---|---|---|---|
| v7 | 21 174 ms | baseline | parallel XHRs + text-format |
| v8 | 20 535 ms | −639 ms | NAD prefetch during `/api/config` |
| v9 | 17 966 ms | −3 208 ms | mutualise `_base_network``network_service.network` |
| v10 | 17 384 ms | −3 790 ms | share Network with grid2op backend (eliminate 3rd parse) |
| v11 (attempt) | 19 071 ms | −2 103 ms | ⚠️ **REVERTED** — isolated Network per thread regressed by +1.7 s vs v10. See `docs/performance/history/isolated-nad-worker-rejected.md`. |
| v12 (attempt) | 19 185 ms | −1 989 ms | ⚠️ **REVERTED** — deferred `detect_non_reconnectable_lines` to a background worker, but JVM contention inflated the 4 parallel XHRs by +2 s. |
| v13 | 21 804 ms | +630 ms | ⚠️ No measurable gain — initial 4× bench was a JIT cold-start artifact. Upstream patch KEPT as defensive dead-code fix. See `docs/performance/history/detect-non-reconnectable-fast-path.md`. |
| v14 | 11 723 ms | **−9 451 ms** | skip initial `env.get_obs()` (0.2.0.post3) + vectorise `NetworkTopologyCache` (0.2.0.post4) |
| v15 | 10 190 ms | −10 984 ms | NAD prefetch spawned earlier in `update_config` (`docs/performance/history/nad-prefetch-earlier-spawn.md`) |
| v16 | 9 578 ms | −11 596 ms | `NetworkTopologyCache` iter 2: `groupby→raw loop` + narrow attrs (0.2.0.post5/post6) |
| v17 | 8 945 ms | −12 229 ms | initial LF `DC_VALUES` init (0.2.0.post7) + GEOGRAPHICAL NAD layout |
| **v18** | **~8.8 s** (projected) | **−12 374 ms (−58 %)** | narrow voltage-level queries + drop unused `kind` attr (**this commit**) — `/api/nominal-voltages` 144 → 5.7 ms (~25×), `/api/voltage-levels` 7.5 → 4.5 ms, `_get_switches_with_topology` 174 → 141 ms |

**Current optimised state: v18 ≈ 8.8 s.**

Critical path v6 → v18: **24.0 s → ~8.8 s (−15.2 s / −63 %)**. 🎯

## What this does NOT change

- **Server compute time**: pypowsybl network load + NAD generation are
  untouched. The only way to shorten these is #2 (NAD generation during
  config) or #3 (disk cache of NAD on network hash+mtime) — see
  the analysis notes, out of scope for this commit.
- **Client render time** (~3.5 s Paint/Layout/Raster): untouched. The
  `ParseHTML` step that inserts the SVG into the DOM still runs — that
  is Blink parsing HTML, not the JSON parser.
- **standalone_interface.html** keeps using the legacy JSON endpoint
  because it is a self-contained fallback UI that does not share the
  fetch helper. Mirroring the parsing change would require replicating
  the header/body split logic there too; not worth the duplication.

## Files changed

| File | Change |
|---|---|
| `expert_backend/main.py` | Added `_maybe_gzip_svg_text` helper; `/api/network-diagram` now accepts `format=text` to return the header+SVG body. |
| `frontend/src/api.ts` | `getNetworkDiagram` uses `fetch` + `?format=text`, splits body on first `\n`, JSON-parses the header, treats the rest as SVG. |
| `frontend/src/hooks/useDiagrams.ts` | New `ingestBaseDiagram(raw, vlCount)` method that does `processSvg` + state-set without the fetch. Returned alongside `fetchBaseDiagram`. |
| `frontend/src/hooks/useSession.ts` | `RestoreContext` gains `ingestBaseDiagram`; session restore fires the diagram XHR in parallel with the other 3 and ingests the raw payload. |
| `frontend/src/App.tsx` | `applySettingsImmediate` and `handleLoadConfig` now `Promise.all` 4 XHRs and call `diagrams.ingestBaseDiagram` instead of `fetchBaseDiagram`. `ingestBaseDiagram` wired into `restoreContext`. |
| `frontend/src/api.test.ts` | `getNetworkDiagram` test rewritten to assert `fetch(..., '?format=text')` + header/body split. Added a non-ok-status test. |
| `frontend/src/hooks/useSession.test.ts` | Mock api now includes `getNetworkDiagram`; context mock includes `ingestBaseDiagram`. |
| `expert_backend/tests/test_api_endpoints.py` | Two new tests under `TestGetNetworkDiagram` covering `format=text` body shape and gzip. |