github-actions[bot] commited on
Commit
178e60f
·
0 Parent(s):

Deploy 0f87067

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/hooks/init-sync-check.sh +108 -0
  2. .claude/settings.json +14 -0
  3. .dockerignore +33 -0
  4. .editorconfig +15 -0
  5. .env.example +32 -0
  6. .gitattributes +7 -0
  7. .github/workflows/canary.yml +34 -0
  8. .github/workflows/code-quality.yml +64 -0
  9. .github/workflows/deploy-huggingface.yml +124 -0
  10. .github/workflows/parity.yml +253 -0
  11. .github/workflows/test.yml +163 -0
  12. .gitignore +50 -0
  13. AUTHORS.txt +15 -0
  14. CHANGELOG.md +0 -0
  15. CLAUDE.md +549 -0
  16. CONTRIBUTING.md +153 -0
  17. Dockerfile +108 -0
  18. LICENSE.md +373 -0
  19. Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html +0 -0
  20. README.md +64 -0
  21. benchmarks/README.md +123 -0
  22. benchmarks/_bench_common.py +137 -0
  23. benchmarks/bench_analyze_suggest.py +390 -0
  24. benchmarks/bench_load_flow_modes.py +132 -0
  25. benchmarks/bench_load_study.py +103 -0
  26. benchmarks/bench_n1_diagram.py +110 -0
  27. benchmarks/bench_n1_diagram_patch.py +152 -0
  28. benchmarks/bench_nad_n_state.py +140 -0
  29. benchmarks/bench_nad_toggles.py +189 -0
  30. benchmarks/bench_topology_cache.py +70 -0
  31. benchmarks/bench_voltage_level_queries.py +73 -0
  32. benchmarks/interaction_paint/.gitignore +8 -0
  33. benchmarks/interaction_paint/FLUIDITY_FINDINGS.md +75 -0
  34. benchmarks/interaction_paint/README.md +65 -0
  35. benchmarks/interaction_paint/app_bitmap_driver.mjs +60 -0
  36. benchmarks/interaction_paint/app_bitmap_n1_driver.mjs +56 -0
  37. benchmarks/interaction_paint/app_gesture_driver.mjs +126 -0
  38. benchmarks/interaction_paint/app_measure_snapshot.mjs +41 -0
  39. benchmarks/interaction_paint/app_n1_driver.mjs +51 -0
  40. benchmarks/interaction_paint/app_render_driver.mjs +75 -0
  41. benchmarks/interaction_paint/app_toggle_driver.mjs +48 -0
  42. benchmarks/interaction_paint/bench_candidates.html +145 -0
  43. benchmarks/interaction_paint/bench_fluidity.html +308 -0
  44. benchmarks/interaction_paint/bench_pan_zoom.mjs +111 -0
  45. benchmarks/interaction_paint/capture.mjs +13 -0
  46. benchmarks/interaction_paint/cdp_driver.mjs +76 -0
  47. benchmarks/interaction_paint/cdp_pipe_driver.mjs +73 -0
  48. benchmarks/interaction_paint/fluidity_result.phaseA.json +3 -0
  49. benchmarks/interaction_paint/fluidity_result.realApp.json +3 -0
  50. benchmarks/interaction_paint/fluidity_result.realNAD.json +4 -0
.claude/hooks/init-sync-check.sh ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # SessionStart init check — git sync verification for marota-fork sessions.
4
+ #
5
+ # Runs automatically at the start of every Claude Code session. It performs a
6
+ # REAL `git fetch` of the repository's default branch and reports whether the
7
+ # current working branch is in sync with it (ahead / behind counts), so a new
8
+ # session never assumes it is up to date based on a possibly-stale local
9
+ # `origin/<default>` ref.
10
+ #
11
+ # Design contract:
12
+ # * READ-ONLY — never mutates the working tree, index, or any branch
13
+ # (only `git fetch`, which updates remote-tracking refs).
14
+ # * NON-FATAL — any failure (no network, no git, detached HEAD, …) exits 0
15
+ # so it can never block or break session startup.
16
+ # * SCOPED — only emits the detailed check for `marota/*` origins, per
17
+ # the "starting a new session from a marota fork" request;
18
+ # a no-op for every other remote.
19
+ # * SYNCHRONOUS — fast (one time-boxed fetch); stdout is surfaced to the
20
+ # session as startup context.
21
+ #
22
+ set -uo pipefail
23
+
24
+ # Drain hook stdin (SessionStart passes a JSON payload we don't need) so the
25
+ # pipe closes cleanly.
26
+ cat >/dev/null 2>&1 || true
27
+
28
+ repo_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || true)}"
29
+ [ -z "${repo_root}" ] && exit 0
30
+ cd "${repo_root}" 2>/dev/null || exit 0
31
+
32
+ # Only act inside a git work tree.
33
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
34
+
35
+ origin_url="$(git remote get-url origin 2>/dev/null || true)"
36
+
37
+ # Guard: only run for marota forks. Everything else is a silent no-op.
38
+ case "${origin_url}" in
39
+ *marota/*) : ;;
40
+ *) exit 0 ;;
41
+ esac
42
+
43
+ # Resolve the default branch: origin/HEAD -> `git remote show` -> main.
44
+ default_branch="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
45
+ if [ -z "${default_branch}" ]; then
46
+ default_branch="$(git remote show origin 2>/dev/null | sed -n 's/.*HEAD branch: //p' | head -1)"
47
+ fi
48
+ [ -z "${default_branch}" ] && default_branch="main"
49
+
50
+ current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '(detached)')"
51
+
52
+ # Do a REAL fetch — never trust the local origin ref. Time-boxed + non-fatal.
53
+ fetch_status="ok"
54
+ if command -v timeout >/dev/null 2>&1; then
55
+ timeout 60 git fetch --quiet origin "${default_branch}" 2>/dev/null || fetch_status="failed"
56
+ else
57
+ git fetch --quiet origin "${default_branch}" 2>/dev/null || fetch_status="failed"
58
+ fi
59
+
60
+ counts="$(git rev-list --left-right --count "origin/${default_branch}...HEAD" 2>/dev/null || true)"
61
+ behind="$(printf '%s' "${counts}" | awk '{print $1}')"
62
+ ahead="$(printf '%s' "${counts}" | awk '{print $2}')"
63
+
64
+ echo "── Session init: git sync check (marota fork) ─────────────────────────"
65
+ echo "repo: $(basename "${repo_root}")"
66
+ echo "current branch: ${current_branch}"
67
+ echo "default branch: origin/${default_branch}"
68
+ echo "origin fetch: ${fetch_status}"
69
+ if [ -n "${counts}" ]; then
70
+ echo "vs origin/${default_branch}: ${behind:-?} behind, ${ahead:-?} ahead"
71
+ if [ "${behind:-0}" -gt 0 ] 2>/dev/null; then
72
+ echo "ACTION: branch is BEHIND origin/${default_branch} by ${behind} commit(s)."
73
+ echo " Rebase onto the latest default before pushing follow-up work:"
74
+ echo " git fetch origin ${default_branch} && git rebase origin/${default_branch}"
75
+ elif [ "${ahead:-0}" -eq 0 ] 2>/dev/null; then
76
+ echo "STATUS: in sync — branch tip equals origin/${default_branch}"
77
+ echo " (fresh branch, no unmerged work). If a prior PR for this branch"
78
+ echo " was already merged, treat new work as a fresh change on this base."
79
+ else
80
+ echo "STATUS: ${ahead} local commit(s) ahead of origin/${default_branch}, 0 behind — up to date."
81
+ fi
82
+ else
83
+ echo "NOTE: could not compute ahead/behind (origin/${default_branch} missing or fetch failed)."
84
+ fi
85
+ echo "PRINCIPLE: always run a real 'git fetch' to confirm sync against the remote —"
86
+ echo " do not rely on a possibly-stale local origin ref."
87
+ echo "───────────────────────────────────────────────────────────────────────"
88
+
89
+ # ── PR target reminder ─────────────────────────────────────────────────
90
+ # Pull requests for Co-Study4Grid are opened SYSTEMATICALLY on the upstream
91
+ # `ainetus` repo (base = ainetus default branch, head = marota:<branch>),
92
+ # never on the `marota` fork. A cross-fork PR into `ainetus` can only be
93
+ # CREATED from a session that has the `ainetus` repo in scope, and
94
+ # cross-tier repo adds are blocked mid-session — so a marota-only session
95
+ # CANNOT open the PR (the create step fails with an access-denied error).
96
+ echo "── PR target: ALWAYS ainetus (upstream), never the marota fork ────────"
97
+ echo " * Open PRs against ainetus/Co-Study4Grid (base = its default branch,"
98
+ echo " head = marota:<branch>). Same rule for Expert_op4grid_recommender."
99
+ echo " * To create one, START THE SESSION with ainetus/Co-Study4Grid (and"
100
+ echo " ainetus/Expert_op4grid_recommender) as the INITIAL sources — a"
101
+ echo " session rooted only at marota cannot add ainetus later (cross-tier"
102
+ echo " add is blocked) and the PR step will fail access-denied."
103
+ echo " * From a marota-only session: push the branch, then hand off a ready"
104
+ echo " PR compare link:"
105
+ echo " https://github.com/ainetus/Co-Study4Grid/compare/${default_branch}...marota:<branch>?expand=1"
106
+ echo "───────────────────────────────────────────────────────────────────────"
107
+
108
+ exit 0
.claude/settings.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/init-sync-check.sh"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
.dockerignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build artifacts / caches — rebuilt inside the image.
2
+ frontend/node_modules
3
+ frontend/dist
4
+ frontend/dist-standalone
5
+ **/__pycache__
6
+ **/*.pyc
7
+ **/*.pyo
8
+
9
+ # Local virtualenvs.
10
+ venv
11
+ .venv
12
+ env
13
+
14
+ # VCS / CI / editor.
15
+ .git
16
+ .github
17
+ .circleci
18
+ .idea
19
+ .vscode
20
+
21
+ # Runtime + report output (regenerated at runtime).
22
+ Overflow_Graph
23
+ reports
24
+ test-results
25
+
26
+ # Docs and the frozen legacy mirror are not needed at runtime.
27
+ docs
28
+ standalone_interface_legacy.html
29
+
30
+ # Test suites are dead weight in the runtime image (QW25) — the app never
31
+ # imports them, and `COPY expert_backend/` would otherwise carry tests/ along.
32
+ expert_backend/tests
33
+ expert_backend/test_backend.py
.editorconfig ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+ indent_style = space
9
+ indent_size = 4
10
+
11
+ [*.{ts,tsx,js,jsx,mjs,cjs,json,yml,yaml,html,css,md}]
12
+ indent_size = 2
13
+
14
+ [Makefile]
15
+ indent_style = tab
.env.example ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Co-Study4Grid backend environment variables
2
+ # Copy to `.env` (ignored by git) or export directly in your shell.
3
+
4
+ # Comma-separated list of allowed CORS origins. Default (unset): the local
5
+ # Vite dev/preview origins on loopback (localhost/127.0.0.1 :5173 and :4173).
6
+ # Set to the browser origin of the frontend in non-local deployments, e.g.
7
+ # CORS_ALLOWED_ORIGINS=https://costudy.example.com,https://ops.example.com
8
+ # Wide-open dev mode is now explicit opt-in (a wildcard lets any web page read
9
+ # /api/* off a localhost backend):
10
+ # CORS_ALLOWED_ORIGINS=*
11
+ # CORS_ALLOWED_ORIGINS=
12
+
13
+ # Deployment lockdown (D7): when truthy (1/true/yes/on), disable the
14
+ # desktop-era filesystem RPCs (custom config-file path, session
15
+ # save/list/load, native file picker) with a 403 {code: LOCKED_DOWN}.
16
+ # The Dockerfile sets this for the public HuggingFace Space; leave it
17
+ # unset for local/dev installs. Read-only app config stays available.
18
+ # COSTUDY4GRID_LOCKDOWN=1
19
+
20
+ # pypowsybl load-flow fast mode toggle (also set per-study from the UI).
21
+ # Unset or empty = default; "1" / "true" = enabled; "0" / "false" = disabled.
22
+ # PYPOWSYBL_FAST_MODE=
23
+
24
+ # Optional log level for the FastAPI process (DEBUG, INFO, WARNING, ERROR).
25
+ # LOG_LEVEL=INFO
26
+
27
+ # Persistent data root for the Game Mode shared solution base (retained
28
+ # propositions + novelty/frequency stats). On a HuggingFace Space with
29
+ # persistent storage, set it to /data. Unset = repo-local ./game_solutions.
30
+ # COSTUDY4GRID_DATA_DIR=
31
+ # Explicit override of the solution-base directory (wins over the data root).
32
+ # COSTUDY4GRID_GAME_SOLUTIONS_DIR=
.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Binary assets are stored via Git LFS / HuggingFace Xet so they can be pushed
2
+ # to a HuggingFace Space (its git endpoint rejects non-LFS binary files).
3
+ # Run `git lfs install` once; see deploy/huggingface/SETUP.md.
4
+ *.zip filter=lfs diff=lfs merge=lfs -text
5
+ *.png filter=lfs diff=lfs merge=lfs -text
6
+ *.jpg filter=lfs diff=lfs merge=lfs -text
7
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
.github/workflows/canary.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Recommender canary
2
+
3
+ # QW8: PR/deploy installs pin expert_op4grid_recommender (recommender-pin.txt).
4
+ # This weekly canary floats to the LATEST release and runs the backend suite,
5
+ # so a breaking upstream release surfaces here (a red canary = "bump the pin
6
+ # after checking") instead of turning an unrelated PR red or shipping a
7
+ # regressed image on a zero-change rebuild.
8
+
9
+ on:
10
+ schedule:
11
+ - cron: '0 6 * * 1' # Mondays 06:00 UTC
12
+ workflow_dispatch: {}
13
+
14
+ jobs:
15
+ canary:
16
+ name: Backend tests against latest recommender
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - name: Set up Python 3.10
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.10"
24
+ cache: 'pip'
25
+ - name: Install dependencies (float recommender to latest)
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install ".[test]"
29
+ # Deliberately NOT pinned: resolve the newest published release.
30
+ pip install --no-deps --no-cache-dir --upgrade "expert_op4grid_recommender"
31
+ - name: Show resolved recommender version
32
+ run: pip show expert_op4grid_recommender | grep -i version
33
+ - name: Run backend Pytest (excluding graphviz-dependent tests)
34
+ run: pytest --ignore=expert_backend/tests/test_overflow_html_dim_logic.py
.github/workflows/code-quality.yml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Code Quality
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ # Run on every PR regardless of its base branch (e.g. PRs stacked onto a
7
+ # `claude/*` working branch, not just those targeting main).
8
+ pull_request:
9
+ branches: ['**']
10
+
11
+ jobs:
12
+ gate:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python 3.10
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.10"
21
+
22
+ - name: Install quality tooling
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install ".[quality]"
26
+ pip install pytest
27
+
28
+ - name: Run ruff (lightweight lint)
29
+ run: ruff check expert_backend scripts
30
+
31
+ - name: Run code-quality gate
32
+ run: python scripts/check_code_quality.py
33
+
34
+ - name: Run docs-tree gate (D9)
35
+ # Fails when a CLAUDE.md reference points at a file that no longer exists
36
+ # or reintroduces a rotting `file.py:NNN` line anchor. See
37
+ # docs/architecture/code-quality-analysis.md §23.
38
+ run: python scripts/check_docs_tree.py
39
+
40
+ - name: Run reporter + docs-tree unit tests
41
+ run: pytest scripts/test_code_quality_report.py scripts/test_check_docs_tree.py -q
42
+
43
+ - name: Run mypy (gate)
44
+ # GATES the build: the shared-state base (services/_recommender_state.py)
45
+ # makes the mixin composition type-check cleanly, so mypy is at 0 and
46
+ # any new type error fails CI (see pyproject [tool.mypy] + §19).
47
+ run: mypy
48
+
49
+ - name: Generate report artifacts
50
+ run: |
51
+ python scripts/code_quality_report.py \
52
+ --output reports/code-quality.json \
53
+ --markdown reports/code-quality.md
54
+
55
+ - name: Publish report to workflow summary
56
+ if: always()
57
+ run: cat reports/code-quality.md >> "$GITHUB_STEP_SUMMARY"
58
+
59
+ - name: Upload report
60
+ uses: actions/upload-artifact@v4
61
+ with:
62
+ name: code-quality-report
63
+ path: reports/
64
+ retention-days: 30
.github/workflows/deploy-huggingface.yml ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HuggingFace Space
2
+
3
+ # Redeploys the HuggingFace Docker Space on every merge to main (a merged
4
+ # PR pushes to main). Mirrors the manual flow in deploy/huggingface/SETUP.md:
5
+ # HF's git endpoint rejects the >10 MiB .xiidm blobs that sit in branch
6
+ # *history*, so we push ONE squashed, history-free commit of the current
7
+ # tree (large binaries ride along via Git LFS).
8
+ #
9
+ # Required repo configuration (Settings → Secrets and variables → Actions):
10
+ # - secret HF_TOKEN : a HuggingFace WRITE access token with access to the Space
11
+ # - variable HF_SPACE : the Space path, e.g. "your-user/co-study4grid-game"
12
+ # - variable HF_USERNAME : (optional) the token owner's HF username — only
13
+ # needed when the Space lives under an ORG and so
14
+ # differs from the owner part of HF_SPACE.
15
+ #
16
+ # The job no-ops (does not fail) when HF_TOKEN / HF_SPACE are absent, so the
17
+ # workflow is inert until you opt in by setting them.
18
+
19
+ # Test-gated (D7): deploy runs ONLY after the "Tests" workflow completes
20
+ # successfully on main — a green build is the gate, not just a merge. A
21
+ # manual `workflow_dispatch` (optionally from an older commit) is the
22
+ # rollback path; see deploy/huggingface/SETUP.md → "Rolling back".
23
+ on:
24
+ workflow_run:
25
+ workflows: [ "Tests" ]
26
+ types: [ completed ]
27
+ branches: [ main ]
28
+ workflow_dispatch: {}
29
+
30
+ # Never run two deploys at once; the latest merge wins.
31
+ concurrency:
32
+ group: huggingface-space-deploy
33
+ cancel-in-progress: true
34
+
35
+ permissions:
36
+ contents: write # push the per-deploy rollback tag back to origin
37
+
38
+ jobs:
39
+ deploy:
40
+ name: Push snapshot to HF Space
41
+ runs-on: ubuntu-latest
42
+ # workflow_run fires on completion regardless of result — only deploy a
43
+ # SUCCESSFUL Tests run. workflow_dispatch always proceeds (rollback).
44
+ if: >-
45
+ github.event_name == 'workflow_dispatch' ||
46
+ github.event.workflow_run.conclusion == 'success'
47
+ steps:
48
+ - name: Check the deploy is configured
49
+ id: guard
50
+ env:
51
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
52
+ HF_SPACE: ${{ vars.HF_SPACE }}
53
+ run: |
54
+ if [ -z "$HF_TOKEN" ] || [ -z "$HF_SPACE" ]; then
55
+ echo "::notice::HF_TOKEN secret and/or HF_SPACE variable not set — skipping deploy."
56
+ echo "configured=false" >> "$GITHUB_OUTPUT"
57
+ else
58
+ echo "configured=true" >> "$GITHUB_OUTPUT"
59
+ fi
60
+
61
+ # The tested commit: the head of the Tests run (workflow_run), or the
62
+ # dispatched ref (workflow_dispatch / rollback).
63
+ - name: Resolve the deployed commit
64
+ if: steps.guard.outputs.configured == 'true'
65
+ id: sha
66
+ run: echo "sha=${{ github.event.workflow_run.head_sha || github.sha }}" >> "$GITHUB_OUTPUT"
67
+
68
+ - name: Checkout the tested tree + LFS objects
69
+ if: steps.guard.outputs.configured == 'true'
70
+ uses: actions/checkout@v4
71
+ with:
72
+ ref: ${{ steps.sha.outputs.sha }}
73
+ lfs: true
74
+ fetch-depth: 1 # an orphan snapshot only needs the current tree
75
+
76
+ - name: Configure git + LFS
77
+ if: steps.guard.outputs.configured == 'true'
78
+ run: |
79
+ git lfs install --local
80
+ git config user.name "github-actions[bot]"
81
+ git config user.email "github-actions[bot]@users.noreply.github.com"
82
+
83
+ - name: Build a history-free snapshot
84
+ if: steps.guard.outputs.configured == 'true'
85
+ env:
86
+ DEPLOY_SHA: ${{ steps.sha.outputs.sha }}
87
+ run: |
88
+ cp deploy/huggingface/README.md README.md # HF needs the frontmatter at repo root
89
+ git checkout --orphan hf-deploy
90
+ git add -A
91
+ # Drop inert local dev artifacts from the deployed snapshot.
92
+ git rm -q --cached --ignore-unmatch \
93
+ .claude/plan.md profiling_patch_results.json || true
94
+ git commit -q -m "Deploy ${DEPLOY_SHA::7}"
95
+ test "$(git rev-list --count HEAD)" -eq 1 # sanity: exactly one commit
96
+
97
+ - name: Push to the HuggingFace Space
98
+ if: steps.guard.outputs.configured == 'true'
99
+ env:
100
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
101
+ HF_SPACE: ${{ vars.HF_SPACE }}
102
+ HF_USERNAME: ${{ vars.HF_USERNAME }}
103
+ DEPLOY_SHA: ${{ steps.sha.outputs.sha }}
104
+ run: |
105
+ user="${HF_USERNAME:-${HF_SPACE%%/*}}"
106
+ # The token is a GitHub secret → masked in logs even inside the URL.
107
+ git remote add space "https://${user}:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}"
108
+ git -c protocol.version=0 push -f space hf-deploy:main
109
+ echo "::notice::Pushed snapshot of ${DEPLOY_SHA::7} to spaces/${HF_SPACE}; HF will rebuild."
110
+
111
+ # Rollback pointer (D7): tag the exact commit deployed to the Space,
112
+ # so a bad deploy can be reverted by re-dispatching this workflow from
113
+ # a prior `space-deploy-*` tag. The Space push itself is force-pushed
114
+ # and history-free, so this origin tag is the only durable record of
115
+ # what shipped. Never fails the deploy.
116
+ - name: Tag the deployed commit on origin
117
+ if: steps.guard.outputs.configured == 'true'
118
+ env:
119
+ DEPLOY_SHA: ${{ steps.sha.outputs.sha }}
120
+ run: |
121
+ tag="space-deploy-$(date -u +%Y%m%d-%H%M%S)-${DEPLOY_SHA::7}"
122
+ git tag -a "$tag" "$DEPLOY_SHA" -m "Deployed to HF Space" || { echo "::warning::tag create failed"; exit 0; }
123
+ git push origin "refs/tags/$tag" || echo "::warning::tag push failed (non-fatal)"
124
+ echo "::notice::Tagged deployed commit as $tag"
.github/workflows/parity.yml ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Parity checks between the React frontend and standalone_interface.html,
2
+ # plus the demo-scenario replay suite (config_small_grid coverage).
3
+ #
4
+ # See scripts/PARITY_README.md for the four-layer parity design and
5
+ # scripts/parity_e2e/DEMO_REPLAY_README.md for the demo-replay layers.
6
+ # Summary:
7
+ # Layer 1 — static inventory (events, API paths, settings, schema drift)
8
+ # Layer 2 — session-reload fidelity (save-vs-restore symmetry)
9
+ # Layer 3a — gesture-sequence static proxy (ordered event emission)
10
+ # Layer 3b — behavioural E2E + demo-replay (real DOM, mocked backend):
11
+ # * `e2e_parity.spec.ts` — React ↔ standalone parity
12
+ # * `demo_replay.spec.ts` — fiche-as-data scenario walker
13
+ # * `demo_visual_snapshots.spec.ts` — normalised SVG/HTML diffs
14
+ # * `demo_meta_invariants.spec.ts` — console errors, empty
15
+ # text, pin-count sanity
16
+ # Layer 4 — user-observable invariants (static source patterns)
17
+ #
18
+ # Layers 1 + 2 + 3a + 4 run on every PR (fast, deterministic, no browser).
19
+ # The cheap `demo_meta_invariants` slice runs on every PR too (~1 min
20
+ # Chromium install + ~20 s test) — it catches console errors and
21
+ # undefined-id leaks that no static pattern can see.
22
+ # The full Layer 3b suite (parity + replay + snapshots) runs nightly +
23
+ # on PRs labelled `e2e`. ~3 min per run.
24
+ name: Parity
25
+
26
+ on:
27
+ push:
28
+ branches: [main]
29
+ # Run on every PR regardless of its base branch (e.g. PRs stacked onto a
30
+ # `claude/*` working branch, not just those targeting main).
31
+ pull_request:
32
+ branches: ['**']
33
+ schedule:
34
+ # Nightly at 02:30 UTC — catches drift introduced between PRs
35
+ # (e.g. docs/features/interaction-logging.md updates without a code change).
36
+ - cron: '30 2 * * *'
37
+
38
+ # Always allow a re-run to supersede an in-flight one for the same PR.
39
+ concurrency:
40
+ group: parity-${{ github.ref }}
41
+ cancel-in-progress: true
42
+
43
+ jobs:
44
+ # -------------------------------------------------------------------
45
+ # Layer 1 — static inventory parity (events, API paths, settings,
46
+ # three-way spec diff). No backend, no browser.
47
+ # -------------------------------------------------------------------
48
+ layer1-static-inventory:
49
+ name: 'Layer 1 · static inventory parity'
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-python@v5
54
+ with:
55
+ python-version: '3.11'
56
+ # The parity check needs an up-to-date standalone bundle to
57
+ # diff against. `frontend/dist-standalone/` is gitignored, so
58
+ # in CI we have to (re)build it from source — otherwise the
59
+ # script falls back to the frozen `standalone_interface_legacy.html`
60
+ # which lags behind the React source by design.
61
+ - uses: actions/setup-node@v4
62
+ with:
63
+ node-version: '20'
64
+ cache: 'npm'
65
+ cache-dependency-path: frontend/package-lock.json
66
+ - name: Build standalone bundle
67
+ working-directory: frontend
68
+ run: |
69
+ npm ci
70
+ npm run build:standalone
71
+ - name: Run parity check
72
+ run: python scripts/check_standalone_parity.py
73
+ - name: Emit Markdown summary
74
+ if: always()
75
+ run: |
76
+ python scripts/check_standalone_parity.py --emit-markdown \
77
+ >> "$GITHUB_STEP_SUMMARY" || true
78
+
79
+ # -------------------------------------------------------------------
80
+ # Layer 2 — session-reload fidelity (save/restore symmetry).
81
+ # -------------------------------------------------------------------
82
+ layer2-session-fidelity:
83
+ name: 'Layer 2 · session-reload fidelity'
84
+ runs-on: ubuntu-latest
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ - uses: actions/setup-python@v5
88
+ with:
89
+ python-version: '3.11'
90
+ - uses: actions/setup-node@v4
91
+ with:
92
+ node-version: '20'
93
+ cache: 'npm'
94
+ cache-dependency-path: frontend/package-lock.json
95
+ - name: Build standalone bundle
96
+ working-directory: frontend
97
+ run: |
98
+ npm ci
99
+ npm run build:standalone
100
+ - name: Run session-fidelity check
101
+ run: python scripts/check_session_fidelity.py
102
+
103
+ # -------------------------------------------------------------------
104
+ # Layer 3a — gesture-sequence static proxy. Lightweight sequence-
105
+ # aware check; complements Layer 1's set-based diff.
106
+ # -------------------------------------------------------------------
107
+ layer3a-gesture-sequence:
108
+ name: 'Layer 3a · gesture-sequence static proxy'
109
+ runs-on: ubuntu-latest
110
+ steps:
111
+ - uses: actions/checkout@v4
112
+ - uses: actions/setup-python@v5
113
+ with:
114
+ python-version: '3.11'
115
+ - uses: actions/setup-node@v4
116
+ with:
117
+ node-version: '20'
118
+ cache: 'npm'
119
+ cache-dependency-path: frontend/package-lock.json
120
+ - name: Build standalone bundle
121
+ working-directory: frontend
122
+ run: |
123
+ npm ci
124
+ npm run build:standalone
125
+ - name: Run gesture-sequence check
126
+ run: python scripts/check_gesture_sequence.py
127
+
128
+ # -------------------------------------------------------------------
129
+ # Layer 4 — user-observable invariants. Static source-pattern
130
+ # assertions for the six bug classes Layers 1-3a cannot catch by
131
+ # construction (visual thresholds, conditional rendering,
132
+ # field semantics, auto-effects, loading-state, performance).
133
+ # -------------------------------------------------------------------
134
+ layer4-invariants:
135
+ name: 'Layer 4 · user-observable invariants'
136
+ runs-on: ubuntu-latest
137
+ steps:
138
+ - uses: actions/checkout@v4
139
+ - uses: actions/setup-python@v5
140
+ with:
141
+ python-version: '3.11'
142
+ - uses: actions/setup-node@v4
143
+ with:
144
+ node-version: '20'
145
+ cache: 'npm'
146
+ cache-dependency-path: frontend/package-lock.json
147
+ - name: Build standalone bundle
148
+ working-directory: frontend
149
+ run: |
150
+ npm ci
151
+ npm run build:standalone
152
+ - name: Run invariants check
153
+ run: python scripts/check_invariants.py
154
+
155
+ # -------------------------------------------------------------------
156
+ # Demo meta-invariants — cheap Playwright slice that catches console
157
+ # errors, empty visible text, undefined-id leaks and pin-count
158
+ # inconsistencies on the small_grid demo flow. Runs on every PR
159
+ # because the signal-to-cost ratio is high (~1 min total: Chromium
160
+ # install + ~20 s test). The full Layer 3b suite still gates on
161
+ # the `e2e` label / nightly schedule.
162
+ # -------------------------------------------------------------------
163
+ demo-meta-invariants:
164
+ name: 'Demo · meta-invariants (Playwright, fast)'
165
+ runs-on: ubuntu-latest
166
+ timeout-minutes: 6
167
+ steps:
168
+ - uses: actions/checkout@v4
169
+ - uses: actions/setup-node@v4
170
+ with:
171
+ node-version: '20'
172
+ cache: 'npm'
173
+ cache-dependency-path: |
174
+ frontend/package-lock.json
175
+ scripts/parity_e2e/package-lock.json
176
+ - name: Install frontend deps + build
177
+ working-directory: frontend
178
+ run: |
179
+ npm ci
180
+ npm run build
181
+ - name: Install E2E deps
182
+ working-directory: scripts/parity_e2e
183
+ run: npm ci
184
+ - name: Install Playwright Chromium
185
+ working-directory: scripts/parity_e2e
186
+ run: npx playwright install --with-deps chromium
187
+ - name: Run demo meta-invariants only
188
+ working-directory: scripts/parity_e2e
189
+ run: npx playwright test demo_meta_invariants.spec.ts
190
+ - name: Upload Playwright report on failure
191
+ if: failure()
192
+ uses: actions/upload-artifact@v4
193
+ with:
194
+ name: playwright-demo-meta-report
195
+ path: scripts/parity_e2e/playwright-report/
196
+ if-no-files-found: ignore
197
+ retention-days: 7
198
+
199
+ # -------------------------------------------------------------------
200
+ # Layer 3b — behavioural E2E + demo-replay full suite with
201
+ # Playwright. Runs every spec matched by `playwright.config.ts`'s
202
+ # testMatch (`*parity.spec.ts` + `demo_*.spec.ts`): the React ↔
203
+ # standalone parity check, the fiche-as-data scenario walker, the
204
+ # normalised SVG/HTML snapshot diffs, and the meta-invariants
205
+ # battery. Browser install + 4 specs ≈ 3 min per run, so this job
206
+ # only fires nightly or on PRs carrying the `e2e` label.
207
+ # -------------------------------------------------------------------
208
+ layer3b-behavioural-e2e:
209
+ name: 'Layer 3b · behavioural E2E + demo replay (Playwright)'
210
+ runs-on: ubuntu-latest
211
+ if: |
212
+ github.event_name == 'schedule'
213
+ || (github.event_name == 'pull_request'
214
+ && contains(github.event.pull_request.labels.*.name, 'e2e'))
215
+ timeout-minutes: 15
216
+ steps:
217
+ - uses: actions/checkout@v4
218
+ - uses: actions/setup-node@v4
219
+ with:
220
+ node-version: '20'
221
+ cache: 'npm'
222
+ cache-dependency-path: |
223
+ frontend/package-lock.json
224
+ scripts/parity_e2e/package-lock.json
225
+
226
+ - name: Install frontend deps + build
227
+ working-directory: frontend
228
+ run: |
229
+ npm ci
230
+ npm run build
231
+
232
+ - name: Install E2E deps
233
+ working-directory: scripts/parity_e2e
234
+ run: npm ci
235
+
236
+ - name: Install Playwright browsers
237
+ working-directory: scripts/parity_e2e
238
+ run: npx playwright install --with-deps chromium
239
+
240
+ - name: Run E2E parity spec
241
+ working-directory: scripts/parity_e2e
242
+ run: npx playwright test
243
+
244
+ - name: Upload Playwright report + artefacts
245
+ if: always()
246
+ uses: actions/upload-artifact@v4
247
+ with:
248
+ name: playwright-report
249
+ path: |
250
+ scripts/parity_e2e/playwright-report/
251
+ scripts/parity_e2e/artefacts.json
252
+ if-no-files-found: ignore
253
+ retention-days: 14
.github/workflows/test.yml ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ # Run on every PR regardless of its base branch (e.g. PRs stacked onto a
7
+ # `claude/*` working branch, not just those targeting main).
8
+ pull_request:
9
+ branches: [ '**' ]
10
+
11
+ jobs:
12
+ # ------------------------------------------------------------------
13
+ # Fast lane: every backend test that does NOT need the graphviz
14
+ # `dot` binary. Skips the system-package install entirely (which
15
+ # used to hang for ~8 minutes on the Azure apt mirror) and runs the
16
+ # ~720 pytest items in ~15 seconds.
17
+ # ------------------------------------------------------------------
18
+ test-backend:
19
+ name: Backend tests (no graphviz)
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - name: Set up Python 3.10
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.10"
27
+ cache: 'pip'
28
+ - name: Install dependencies
29
+ run: |
30
+ python -m pip install --upgrade pip
31
+ pip install ".[test]"
32
+ # Pin expert_op4grid_recommender to the exact version in
33
+ # recommender-pin.txt (QW8) so an upstream release can't silently turn
34
+ # a green PR red; the weekly canary.yml floats to latest and flags
35
+ # regressions. --no-deps skips the recommender's heavy transitive
36
+ # stack (pypowsybl already comes from co_study4grid's own deps).
37
+ pip install --no-deps -r recommender-pin.txt
38
+ - name: Run Pytest (excluding graphviz-dependent tests)
39
+ # Coverage GATES at fail_under=72 (pyproject [tool.coverage.report]).
40
+ # See docs/architecture/code-quality-analysis.md §20.
41
+ run: >-
42
+ pytest --ignore=expert_backend/tests/test_overflow_html_dim_logic.py
43
+ --cov=expert_backend --cov-report=xml --cov-report=term-missing
44
+ - name: Upload backend coverage
45
+ if: always()
46
+ uses: actions/upload-artifact@v4
47
+ with:
48
+ name: backend-coverage
49
+ path: coverage.xml
50
+ if-no-files-found: ignore
51
+ retention-days: 30
52
+
53
+ # ------------------------------------------------------------------
54
+ # Data-pipeline + benchmark supply chain (D8). Runs the HERMETIC slice
55
+ # of the PyPSA-EUR pipeline suite (scripts/pypsa_eur) and the Codabench
56
+ # scorer parity guard (scripts/game_mode). These live outside
57
+ # pytest.ini's testpaths (expert_backend/tests), so they need an
58
+ # explicit path. Tests that require the uncommitted raw OSM CSVs skip
59
+ # gracefully (conftest osm_dir / regenerate_grid_layout guards), so the
60
+ # suite is green from a fresh clone. scripts/game_mode/test_score.py is
61
+ # pure stdlib and pins score.py to the shared golden fixture the
62
+ # frontend's scoring.test.ts also asserts — locking cross-language parity.
63
+ # ------------------------------------------------------------------
64
+ test-data-pipeline:
65
+ name: Data pipeline + scorer parity
66
+ runs-on: ubuntu-latest
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ - name: Set up Python 3.10
70
+ uses: actions/setup-python@v5
71
+ with:
72
+ python-version: "3.10"
73
+ cache: 'pip'
74
+ - name: Install dependencies
75
+ run: |
76
+ python -m pip install --upgrade pip
77
+ pip install ".[test]"
78
+ - name: Run pipeline + scorer + config-consistency tests (hermetic slice)
79
+ run: >-
80
+ pytest scripts/pypsa_eur scripts/game_mode
81
+ scripts/test_recommender_pin_consistency.py
82
+ scripts/test_extract_network_zip.py -q
83
+
84
+ test-frontend:
85
+ runs-on: ubuntu-latest
86
+ steps:
87
+ - uses: actions/checkout@v4
88
+ - name: Set up Node.js
89
+ uses: actions/setup-node@v4
90
+ with:
91
+ node-version: '20'
92
+ cache: 'npm'
93
+ cache-dependency-path: frontend/package-lock.json
94
+ - name: Install dependencies
95
+ run: |
96
+ cd frontend
97
+ npm install
98
+ - name: Run Vitest + coverage gate
99
+ # Runs the full suite WITH coverage and enforces the floor in
100
+ # vite.config.ts (coverage.thresholds). Replaces the plain run —
101
+ # same tests, plus the gate. See §19.
102
+ run: |
103
+ cd frontend
104
+ npm run test:coverage
105
+ - name: Upload frontend coverage
106
+ if: always()
107
+ continue-on-error: true
108
+ uses: actions/upload-artifact@v4
109
+ with:
110
+ name: frontend-coverage
111
+ path: frontend/coverage
112
+ if-no-files-found: ignore
113
+ retention-days: 30
114
+ - name: Run Linter
115
+ run: |
116
+ cd frontend
117
+ npm run lint
118
+
119
+ # ------------------------------------------------------------------
120
+ # Slow lane: the small subset of backend tests that exercise the
121
+ # upstream interactive-HTML viewer through pydot → graphviz `dot`.
122
+ # Gated behind the fast lanes so a regression in non-graphviz code
123
+ # is reported in ~1 minute, and only the prod/load-layer fixtures
124
+ # pay the (cached) apt install cost.
125
+ #
126
+ # `awalsh128/cache-apt-pkgs-action` caches the resolved .deb files
127
+ # across runs — first run is ~30 s, subsequent runs restore from
128
+ # cache in ~5 s, vs. the ~8 minutes the bare `apt-get update` was
129
+ # taking on the Azure mirror.
130
+ # ------------------------------------------------------------------
131
+ test-backend-graphviz:
132
+ name: Backend tests requiring graphviz
133
+ needs: [test-backend, test-frontend]
134
+ runs-on: ubuntu-latest
135
+ steps:
136
+ - uses: actions/checkout@v4
137
+ - name: Set up Python 3.10
138
+ uses: actions/setup-python@v5
139
+ with:
140
+ python-version: "3.10"
141
+ cache: 'pip'
142
+ - name: Install Graphviz
143
+ # Direct ``apt-get install`` skips the slow ``apt-get update``
144
+ # (the original 8-minute Azure-mirror hang) — the GH runner
145
+ # image already ships an up-to-date apt cache. We previously
146
+ # used ``awalsh128/cache-apt-pkgs-action`` but the cached
147
+ # ``.deb`` ended up out of sync with the runner's libstdc++
148
+ # / libltdl, making ``dot`` exit 1 on every render. A direct
149
+ # install lets apt resolve the packages against the current
150
+ # runner image so dot links cleanly every time.
151
+ run: sudo apt-get install -y --no-install-recommends graphviz
152
+ - name: Install dependencies
153
+ run: |
154
+ python -m pip install --upgrade pip
155
+ pip install ".[test]"
156
+ # Pin expert_op4grid_recommender to the exact version in
157
+ # recommender-pin.txt (QW8) so an upstream release can't silently turn
158
+ # a green PR red; the weekly canary.yml floats to latest and flags
159
+ # regressions. --no-deps skips the recommender's heavy transitive
160
+ # stack (pypowsybl already comes from co_study4grid's own deps).
161
+ pip install --no-deps -r recommender-pin.txt
162
+ - name: Run graphviz-dependent Pytest subset
163
+ run: pytest expert_backend/tests/test_overflow_html_dim_logic.py
.gitignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ session*
2
+ # Anchor `data/` to the project root so it doesn't accidentally match
3
+ # `docs/data/` (where pipeline / coordinate-scale design docs live).
4
+ /data/
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+
9
+ # Build artifacts
10
+ build/
11
+ *.egg-info/
12
+ dist/
13
+
14
+ # Generated outputs
15
+ Overflow_Graph/
16
+
17
+ # Game Mode shared solution base (dev fallback of COSTUDY4GRID_DATA_DIR)
18
+ /game_solutions/
19
+
20
+ # Code-quality reports produced by scripts/code_quality_report.py
21
+ reports/
22
+
23
+ # Test-coverage artifacts (pytest-cov / vitest v8 — report-only, see
24
+ # docs/architecture/code-quality-analysis.md §18)
25
+ coverage.xml
26
+ .coverage
27
+ .coverage.*
28
+ htmlcov/
29
+
30
+ # Environment overrides (template is tracked as .env.example)
31
+ .env
32
+
33
+ # User config (default template is tracked as config.default.json)
34
+ config.json
35
+ config_path.txt
36
+
37
+ # Node modules
38
+ node_modules/
39
+
40
+ # Virtual environments
41
+ venv/
42
+ venv*/
43
+ .venv/
44
+ env/
45
+ .env/
46
+ *.odt
47
+ *.docx
48
+ config_*.json
49
+ .idea/
50
+ *DS_Store
AUTHORS.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
2
+ This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
3
+ If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
4
+ you can obtain one at http://mozilla.org/MPL/2.0/.
5
+ SPDX-License-Identifier: MPL-2.0
6
+ This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
7
+
8
+ Lead Developers:
9
+ - Antoine Marot
10
+
11
+ Main Contributers:
12
+ -
13
+
14
+ Further Contributions by:
15
+ -
CHANGELOG.md ADDED
The diff for this file is too large to render. See raw diff
 
CLAUDE.md ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md - Co-Study4Grid
2
+
3
+ ## Project Overview
4
+
5
+ Co-Study4Grid is a full-stack web application for **power grid contingency analysis and N-1 planning**. It provides an interface to the `expert_op4grid_recommender` library, allowing operators to simulate element disconnections, visualize network overflow graphs, and receive prioritized remedial action recommendations.
6
+
7
+ ## Architecture
8
+
9
+ **Monorepo** with two main components plus a standalone HTML mirror:
10
+
11
+ ```
12
+ Co-Study4Grid/
13
+ ├── CLAUDE.md # This file — project overview + standalone parity audit
14
+ ├── README.md # User-facing project description + quick start
15
+ ├── CHANGELOG.md # Per-release changelog (current: 0.9.0)
16
+ ├── CONTRIBUTING.md # Contributor setup, code-quality gate
17
+ ├── pyproject.toml # Python project metadata + ruff config (E9/F ruleset)
18
+ ├── pytest.ini # Pytest config (testpaths = expert_backend/tests)
19
+ ├── expert_backend/ # Python FastAPI backend
20
+ │ ├── CLAUDE.md # Backend-scoped guide (singletons, mixins, lifecycle)
21
+ │ ├── main.py # FastAPI app: endpoints, CORS, gzip helpers, NDJSON streaming
22
+ │ ├── test_backend.py # Ad-hoc integration script (not part of pytest)
23
+ │ ├── recommenders/ # Pluggable recommendation-model registry: registry.py,
24
+ │ │ │ # random_basic / random_overflow canonical examples,
25
+ │ │ │ # overflow_path_filter + network_existence sampling
26
+ │ │ │ # filters, synthetic_actions builders. Models are
27
+ │ │ │ # registered at package import; the service integration
28
+ │ │ │ # is EXPLICIT composition (RecommenderService inherits
29
+ │ │ │ # ModelSelectionMixin; AnalysisMixin.run_analysis_step2
30
+ │ │ │ # consumes the registry — no import-time patching).
31
+ │ │ │ # Full reference: docs/backend/recommender_models.md
32
+ │ ├── services/
33
+ │ │ ├── network_service.py # pypowsybl Network singleton + metadata queries
34
+ │ │ ├── recommender_service.py # Analysis orchestrator (composes the 4 mixins below)
35
+ │ │ ├── diagram_mixin.py # NAD/SLD orchestrator — delegates to services/diagram/
36
+ │ │ ├── analysis_mixin.py # Two-step analysis orchestrator (model-aware step-2
37
+ │ │ │ # dispatch through the recommenders registry) —
38
+ │ │ │ # delegates to services/analysis/
39
+ │ │ ├── simulation_mixin.py # Manual-action + superposition orchestrator
40
+ │ │ ├── model_selection_mixin.py # Active recommender model + overflow-graph toggle
41
+ │ │ ├── simulation_helpers.py # Stateless helpers extracted from simulation_mixin (PR #104)
42
+ │ │ ├── overflow_overlay.py # Pin / filter overlay injector for the interactive
43
+ │ │ │ # HTML overflow viewer (PR #116, 0.7.0)
44
+ │ │ ├── game_solutions.py # Game Mode shared solution base (per-context
45
+ │ │ │ # JSON records, novelty/bonus + usage
46
+ │ │ │ # frequencies; root via COSTUDY4GRID_DATA_DIR)
47
+ │ │ ├── sanitize.py # NumPy → native-Python recursive coercion
48
+ │ │ ├── analysis/ # PR #104 decomposition — action_enrichment,
49
+ │ │ │ # mw_start_scoring, analysis_runner, pdf_watcher,
50
+ │ │ │ # overflow_geo_transform (PR #116 — geo-layout SVG
51
+ │ │ │ # transform for /api/regenerate-overflow-graph)
52
+ │ │ └── diagram/ # PR #104 decomposition — layout_cache, nad_params,
53
+ │ │ # nad_render, sld_render, overloads, flows, deltas,
54
+ │ │ # obs_prewarm (post-contingency obs cache prewarm
55
+ │ │ # that lets run_analysis_step1 skip the LF),
56
+ │ │ # action_patch (extracted action-variant patch
57
+ │ │ # pipeline — keeps diagram_mixin under the LoC
58
+ │ │ # ceiling)
59
+ │ └── tests/ # pytest suite — see tests/CLAUDE.md for the mock layer
60
+ ├── frontend/ # React 19 + TypeScript 5.9 + Vite 7 frontend
61
+ │ ├── CLAUDE.md # Frontend-scoped guide (App.tsx hub, hooks, SVG levers)
62
+ │ ├── package.json, vite.config.ts, vite.config.standalone.ts,
63
+ │ │ # eslint.config.js, tsconfig*.json
64
+ │ └── src/
65
+ │ ├── App.tsx # State orchestration hub (~1400 lines)
66
+ │ ├── api.ts # Axios HTTP client (base URL: 127.0.0.1:8000)
67
+ │ ├── types.ts # All TypeScript interfaces (one file)
68
+ │ ├── styles/ # Design-token palette (PR #120, 0.7.0):
69
+ │ │ # tokens.css (CSS custom properties) +
70
+ │ │ # tokens.ts (typed colors / space / text /
71
+ │ │ # radius / pinColors* constants). Single
72
+ │ │ # source of truth for the entire UI; the
73
+ │ │ # code-quality gate enforces zero hex
74
+ │ │ # literals outside these two files.
75
+ │ ├── hooks/ # useSettings / useActions / useAnalysis / useDiagrams /
76
+ │ │ # useSession / useDetachedTabs / useTiedTabsSync /
77
+ │ │ # usePanZoom / useSldOverlay / useContingencyFetch (svgPatch fast-
78
+ │ │ # path + full fallback) / useDiagramHighlights (per-tab
79
+ │ │ # highlight pipeline + Flow/Impacts view-mode state) /
80
+ │ │ # useOverflowIframe (PR #116 — iframe lifecycle, layer
81
+ │ │ # toggles, postMessage bridge, pin overlay payload) /
82
+ │ │ # useSldTopologyEdit (interactive SLD switch-edit →
83
+ │ │ # manual action, 0.8.0) / useTheme (light/dark theme
84
+ │ │ # toggle + persistence, 0.8.0)
85
+ │ ├── components/ # Header, ActionFeed, ActionCard, ActionCardPopover,
86
+ │ │ # ActionSearchDropdown (editable Δ MW column
87
+ │ │ # for redispatch in score table),
88
+ │ │ # ActionTypeFilterChips,
89
+ │ │ # ActionFilterRings (shared severity + action-type +
90
+ │ │ # Max-loading ring strip, 0.8.0), ActionTypeIcon /
91
+ │ │ # SeverityIcon (action-type + severity pictograms),
92
+ │ │ # AdditionalLinesPicker,
93
+ │ │ # ActionOverviewDiagram, AppSidebar (collapsible
94
+ │ │ # shell — readability-feed PR), SidebarSummary
95
+ │ │ # (sticky strip — hosts Clear button + overload
96
+ │ │ # info bubble that absorbed the legacy
97
+ │ │ # OverloadPanel affordances),
98
+ │ │ # NotificationHost (typed toast store —
99
+ │ │ # severity/dismiss/aria-live, PR D5),
100
+ │ │ # VisualizationPanel, OverloadPanel
101
+ │ │ # (kept on disk for unit-test backwards-compat;
102
+ │ │ # no longer rendered from App.tsx),
103
+ │ │ # CombinedActionsModal, ComputedPairsTable,
104
+ │ │ # ExplorePairsTab, SldOverlay, SldEditPanel
105
+ │ │ # (interactive maneuver list, 0.8.0),
106
+ │ │ # SldInjectionPopover (SLD load/gen active-
107
+ │ │ # power editor bubble), DetachableTabHost,
108
+ │ │ # MemoizedSvgContainer, ErrorBoundary, NoticesPanel
109
+ │ │ # (PR #122 tier system), DiagramLegend (PR #122),
110
+ │ │ # InspectSearchField + DetachedPlaceholder (PR #116
111
+ │ │ # — extracted from VisualizationPanel)
112
+ │ │ # + modals/ (SettingsModal, ReloadSessionModal,
113
+ │ │ # ConfirmationDialog)
114
+ │ ├── game/ # Timed, scored Game Mode (0.8.0; active only
115
+ │ │ # with ?game=1) — GameShell / useGameSession /
116
+ │ │ # gameBridge / GameConfigScreen / GameHud /
117
+ │ │ # GameResults / GameNoveltyToast /
118
+ │ │ # GameHintsPanel (beginner assistance:
119
+ │ │ # community's most-used levers) / scoring /
120
+ │ │ # gameLog / solutionLog (solution
121
+ │ │ # capitalisation: levers + novelty bonus +
122
+ │ │ # usage-frequency feedback) / presets /
123
+ │ │ # types. See docs/features/game-mode-codabench.md
124
+ │ └── utils/ # svgUtils (barrel re-exporting utils/svg/*),
125
+ │ # svgPatch (DOM-recycling patch applier),
126
+ │ # overloadHighlights, sessionUtils, interactionLogger,
127
+ │ # popoverPlacement, mergeAnalysisResult, actionTypes
128
+ │ # (classifyActionType + DEFAULT_ACTION_OVERVIEW_FILTERS),
129
+ │ # inspectables (filterInspectables — match an element
130
+ │ # by its displayed name, not just its raw id),
131
+ │ # ndjsonStream (single NDJSON reader, D5),
132
+ │ # notifications (typed toast store, D5),
133
+ │ # fileRegistry (structure regression guard)
134
+ │ └── svg/ # PR #104 decomposition — idMap, metadataIndex,
135
+ │ # svgBoost, fitRect, deltaVisuals, actionPinData,
136
+ │ # actionPinRender, highlights, vlInteractions (VL-disk
137
+ │ # hover name / click-to-inspect / dbl-click SLD),
138
+ │ # overflowPinPayload
139
+ │ # + overflowOverlayRender + pinGlyph (PR #116 —
140
+ │ # iframe-overlay pin pipeline)
141
+ ├── standalone_interface_legacy.html # DECOMMISSIONED 2026-04-20 — hand-maintained
142
+ │ # single-file mirror frozen at its last version and
143
+ │ # tracked here for reference only. Replaced by the
144
+ │ # auto-generated `frontend/dist-standalone/standalone.html`
145
+ │ # (`npm run build:standalone`). New UI changes land ONLY
146
+ │ # in `frontend/src/` — do NOT edit this file further.
147
+ ├── docs/ # Design docs — organized into features/, performance/
148
+ │ # (+ history/), architecture/, proposals/, data/.
149
+ │ # See `docs/README.md` for the index.
150
+ ├── data/ # Sample grids: bare_env_small_grid_test, pypsa_eur_fr400,
151
+ │ # pypsa_eur_fr225_400 (France 225/400 kV — its large
152
+ │ # network ships compressed as network.xiidm.zip,
153
+ │ # auto-decompressed by network_service on load)
154
+ ├── benchmarks/ # Perf scripts (bench_load_study, _bench_common)
155
+ ├── Overflow_Graph/ # Generated PDFs (created at runtime)
156
+ ├── overrides.txt # Pinned versions for transitive Python deps
157
+ ├── requirements_py310.txt # Python 3.10-pinned requirements superset
158
+ ├── scripts/ # Integration / parity / build helpers —
159
+ │ # `check_standalone_parity.py`,
160
+ │ # `check_session_fidelity.py`,
161
+ │ # `check_gesture_sequence.py`,
162
+ │ # `check_invariants.py`, `check_code_quality.py`,
163
+ │ # `check_openapi_contract.py` (D2 — diffs the live
164
+ │ # app.openapi() against expert_backend/openapi.snapshot.json),
165
+ │ # `code_quality_report.py`, `profile_diagram_perf.py`,
166
+ │ # `test_code_quality_report.py`,
167
+ │ # `test_estimation_vs_simulation_small_grid.py`,
168
+ │ # `pypsa_eur/` (full PyPSA-EUR → XIIDM pipeline
169
+ │ # with its own pytest coverage; `build_pipeline.py`
170
+ │ # writes a per-bundle provenance.json, D8), and
171
+ │ # `game_mode/` (`e2e_game_session.py` — real-backend
172
+ │ # Game Mode replay; `scoring_program/score.py` —
173
+ │ # in-repo Codabench scorer twin of scoring.ts,
174
+ │ # pinned to `scoring_golden.json` by test_score.py
175
+ │ # + scoring.test.ts for cross-language parity, D8)
176
+ ├── Dockerfile # Single-container HuggingFace Docker Space image —
177
+ │ # same-origin SPA + FastAPI on :7860, game mode on
178
+ ├── .dockerignore
179
+ ├── deploy/ # HuggingFace Space README + step-by-step SETUP.md
180
+ ├── config.default.json # Bundled first-run settings (fr225_400 grid +
181
+ │ # per-action-type recommender minima)
182
+ ├── .editorconfig # Cross-editor indent / EOL defaults
183
+ ├── .env.example # Template for backend env vars (CORS, …)
184
+ ├── .gitattributes # Git LFS tracking for *.zip / *.png / *.jpg(eg)
185
+ │ # (HuggingFace Space git endpoint requires LFS)
186
+ └── .gitignore # Excludes __pycache__/, *.pyc, *.pyo, node_modules/
187
+ ```
188
+
189
+ ### Per-subtree docs
190
+
191
+ | File | Scope |
192
+ |------|-------|
193
+ | `CLAUDE.md` (this file) | Project overview, API table, conventions, parity-audit pointer |
194
+ | `frontend/PARITY_AUDIT.md` | Full standalone-parity audit: feature inventory, mirror-status table, Layer 1–4 conformity findings, gap-priority list, deltas. Split out of this file 2026-04-20. |
195
+ | `expert_backend/CLAUDE.md` | Backend internals: singletons, mixin composition, state lifecycle, NDJSON streaming, gzip helpers, layout cache invariants, NAD prefetch |
196
+ | `expert_backend/tests/CLAUDE.md` | Test conventions, the `conftest.py` mock layer for `pypowsybl` / `expert_op4grid_recommender`, frontend Vitest patterns |
197
+ | `frontend/CLAUDE.md` | Frontend internals: hook split, data flow, state reset, SVG performance levers, detached/tied tabs, interaction logger contract |
198
+ | `docs/README.md` | Index of design/feature/perf/architecture/proposal docs. Start here for any `docs/**` lookup. |
199
+ | `docs/features/save-results.md` | Save / reload session contract (JSON schema, reload flow, regression-guard matrix) |
200
+ | `docs/features/adding-action-type.md` | Cross-cutting checklist for adding/upgrading a remedial-action type (lib → backend → frontend → save/log/reload triad → regression specs) |
201
+ | `docs/features/interaction-logging.md` | Replay-ready event log contract |
202
+ | `docs/features/sld-topology-edit.md` | Interactive SLD topology edit → manual action card |
203
+ | `docs/features/sld-diagram-feeder-labels.md` | SLD feeders relabelled by far-end VL name (+ parallel index), overload-halo friendly-name↔IIDM-id bridge, and the charging-current annotation explaining the "after" loading of a line opened at one end |
204
+ | `docs/features/vl-disk-interactions.md` | Interactive VL disks on the NAD (hover name / click → Inspect / double-click → SLD) + delegation performance contract |
205
+ | `docs/features/game-mode-codabench.md` | Timed, scored Game Mode (`?game=1`) + Codabench benchmark bundle |
206
+ | `deploy/huggingface/` | HuggingFace Docker Space deployment (Space README + `SETUP.md`) |
207
+
208
+ ## Tech Stack
209
+
210
+ ### Backend
211
+ - **Python** with **FastAPI** + **Uvicorn**
212
+ - **pypowsybl** - Power system network loading, load flow, and diagram generation
213
+ - **expert_op4grid_recommender** - Domain-specific grid optimization recommendations
214
+ - **grid2op** / **pandapower** / **lightsim2grid** - Grid simulation backends
215
+
216
+ ### Frontend
217
+ - **React 19** with **TypeScript 5.9**
218
+ - **Vite 7** - Build tool and dev server
219
+ - **axios** - HTTP client
220
+ - **react-select** - Searchable dropdown for branch selection
221
+ - **vite-plugin-singlefile** - Auto-generated single-file standalone bundle
222
+ - **Vitest** + **React Testing Library** - Unit / integration tests
223
+
224
+ ## Development Workflow
225
+
226
+ ### Running the Backend
227
+
228
+ ```bash
229
+ # From the project root:
230
+ python -m expert_backend.main
231
+ # Or:
232
+ uvicorn expert_backend.main:app --host 0.0.0.0 --port 8000
233
+ ```
234
+
235
+ The backend serves on `http://localhost:8000`. It expects `pypowsybl` and `expert_op4grid_recommender` to be available in the Python environment.
236
+
237
+ ### Running the Frontend
238
+
239
+ ```bash
240
+ cd frontend
241
+ npm install
242
+ npm run dev # Start Vite dev server with HMR
243
+ ```
244
+
245
+ The frontend dev server proxies API calls to `http://localhost:8000` (hardcoded in `frontend/src/api.ts`).
246
+
247
+ ### Build & Lint
248
+
249
+ ```bash
250
+ cd frontend
251
+ npm run build # TypeScript compilation (tsc -b) + Vite production build
252
+ npm run lint # ESLint
253
+ npm run preview # Preview production build
254
+ ```
255
+
256
+ ### Running Tests
257
+
258
+ Backend unit tests use `pytest` and run against the in-repo mock layer
259
+ (no live pypowsybl required):
260
+
261
+ ```bash
262
+ pytest # Full backend suite
263
+ pytest expert_backend/tests/test_foo.py # Single file
264
+ ```
265
+
266
+ Ad-hoc integration scripts live in `scripts/` (and `scripts/pypsa_eur/`
267
+ for the PyPSA-EUR → XIIDM pipeline). The pipeline scripts carry their
268
+ own pytest coverage (`scripts/pypsa_eur/test_*.py`) alongside the
269
+ backend suite; the rest require a running backend with real data:
270
+
271
+ ```bash
272
+ pytest scripts/pypsa_eur # Pipeline unit tests
273
+ python scripts/pypsa_eur/test_pipeline.py # End-to-end smoke test
274
+ python scripts/pypsa_eur/test_n1_calibration.py # N-1 flow calibration check
275
+ python scripts/pypsa_eur/test_grid_layout.py # Layout loading sanity check
276
+ python scripts/profile_diagram_perf.py # NAD rendering profiler
277
+ ```
278
+
279
+ Frontend unit tests use Vitest:
280
+
281
+ ```bash
282
+ cd frontend
283
+ npm run test # Run Vitest test suite
284
+ ```
285
+
286
+ ### Code-Quality Checks (continuous reporting)
287
+
288
+ ```bash
289
+ # Generate a full JSON + Markdown report (backend + frontend metrics)
290
+ python scripts/code_quality_report.py --output reports/code-quality.json \
291
+ --markdown reports/code-quality.md
292
+
293
+ # Gate a pull request: non-zero exit on threshold violation
294
+ python scripts/check_code_quality.py
295
+ ```
296
+
297
+ Both scripts run in CI (`.github/workflows/code-quality.yml`). The gate
298
+ guards the reductions documented in
299
+ [`docs/architecture/code-quality-analysis.md`](docs/architecture/code-quality-analysis.md)
300
+ (no new `print()` / bare except, module-size ceilings, no `any` /
301
+ `@ts-ignore` in frontend source).
302
+
303
+ ## API Endpoints
304
+
305
+ | Method | Path | Description |
306
+ |--------|------|-------------|
307
+ | GET | `/api/user-config` | Read persisted user configuration (paths, recommender params) |
308
+ | POST | `/api/user-config` | Persist user configuration |
309
+ | GET | `/api/config-file-path` | Get the current user-config file path |
310
+ | POST | `/api/config-file-path` | Set a custom user-config file path |
311
+ | POST | `/api/config` | Set network path, action file path, and all recommender parameters (incl. `model` and `compute_overflow_graph`) |
312
+ | GET | `/api/models` | List registered recommendation models with their `params_spec()` and capability flags |
313
+ | POST | `/api/recommender-model` | Lightweight swap of the active recommender model (no network reload) — fired by the model dropdowns in Settings and above Analyze & Suggest |
314
+ | GET | `/api/branches` | List disconnectable elements (lines + 2-winding transformers) |
315
+ | GET | `/api/voltage-levels` | List voltage levels in the network |
316
+ | GET | `/api/nominal-voltages` | Map voltage level IDs to nominal voltages (kV) |
317
+ | GET | `/api/element-voltage-levels` | Resolve equipment ID to its voltage level IDs |
318
+ | GET | `/api/voltage-level-substations` | Map voltage level IDs to their parent substation IDs (used by the SLD overlay and overflow pin pipeline) |
319
+ | POST | `/api/run-analysis` | Run full N-1 contingency analysis (streaming NDJSON, legacy) |
320
+ | POST | `/api/run-analysis-step1` | Two-step analysis Part 1: detect overloads |
321
+ | POST | `/api/run-analysis-step2` | Two-step analysis Part 2: resolve with actions (streaming NDJSON) |
322
+ | GET | `/api/network-diagram` | Get N-state network SVG diagram (NAD) |
323
+ | POST | `/api/contingency-diagram` | Get post-contingency N-1 diagram with flow deltas |
324
+ | POST | `/api/contingency-diagram-patch` | SVG-less per-branch delta for DOM-recycling fast path (PR #108) |
325
+ | POST | `/api/action-variant-diagram` | Get network state after applying a remedial action |
326
+ | POST | `/api/action-variant-diagram-patch` | Per-branch delta + VL-subtree splice for action DOM recycling |
327
+ | POST | `/api/focused-diagram` | Generate NAD sub-diagram focused on a specific element |
328
+ | POST | `/api/action-variant-focused-diagram` | Focused NAD for specific VL in post-action state |
329
+ | POST | `/api/n-sld` | Single Line Diagram for voltage level in N state. Response includes `switch_states` (per-switch open/closed map), `injections` (per-load/generator active-power baseline) used by the interactive SLD-edit feature, **and** `feeder_labels` (per-branch `{name, other_vl, label}` — `label` = far-end VL name + parallel index, used to relabel feeders and bridge friendly-named overloads to the SLD cell). |
330
+ | POST | `/api/contingency-sld` | Single Line Diagram in N-1 state (with flow deltas + `switch_states` + `injections` + `feeder_labels`). |
331
+ | POST | `/api/action-variant-sld` | SLD in post-action state (with flow deltas, `changed_switches`, `switch_states`, `injections`, `feeder_labels`). |
332
+ | POST | `/api/sld-topology-preview` | Target-topology preview SLD for the interactive SLD-edit feature: applies staged switch overrides on a throwaway variant and re-renders with topological colouring (no load flow; `stale_flows: true`). |
333
+ | GET | `/api/actions` | Return all available action IDs and descriptions |
334
+ | POST | `/api/regenerate-overflow-graph` | Regenerate (or serve from cache) the overflow graph in hierarchical / geo layout — drives the toggle on the Overflow Analysis tab |
335
+ | POST | `/api/simulate-manual-action` | Simulate a specific action against a contingency. Accepts an optional `voltage_level_id` field used to auto-name switch-only user actions (interactive SLD-edit feature). |
336
+ | POST | `/api/simulate-and-variant-diagram` | NDJSON stream: `{type:"metrics"}` then `{type:"diagram"}` so sidebar updates ahead of the SVG |
337
+ | POST | `/api/compute-superposition` | Compute combined effect of two actions (superposition theorem) |
338
+ | POST | `/api/game/log-solution` | Capitalise a Game Mode retained proposition into the shared solution base; returns the novelty verdict (+bonus points) and per-action usage frequencies |
339
+ | GET | `/api/game/lever-stats` | Most-used unitary levers of a (network, contingency) context in the shared solution base — the Game Mode beginner-assistance hints (top-N, tagged voltage_level / branch / generation / load) |
340
+ | POST | `/api/save-session` | Save session folder with JSON snapshot + PDF copy |
341
+ | GET | `/api/list-sessions` | List available session folders in a directory |
342
+ | POST | `/api/load-session` | Load session JSON and restore PDFs |
343
+ | POST | `/api/restore-analysis-context` | Restore analysis context from saved session |
344
+ | GET | `/api/pick-path` | Open native OS file/directory picker (tkinter subprocess) |
345
+ | GET | `/results/pdf/{filename}` | Serve generated overflow-graph files from `Overflow_Graph/` — HTML (interactive viewer, current default via `config.VISUALIZATION_FORMAT="html"`) or PDF (legacy sessions). URL path kept for backward compatibility. |
346
+
347
+ ## Key Patterns & Conventions
348
+
349
+ ### Backend
350
+ - **Singleton services**: `network_service` and `recommender_service` are module-level singleton instances
351
+ - **Streaming responses**: Analysis uses `StreamingResponse` with NDJSON (`application/x-ndjson`), yielding `{"type": "pdf", ...}` then `{"type": "result", ...}` events
352
+ - **AC/DC fallback**: Analysis first tries AC load flow; falls back to DC if AC does not converge
353
+ - **Threaded analysis**: `run_analysis` runs the computation in a background thread and polls for PDF generation
354
+ - **JSON sanitization**: NumPy types are recursively converted to native Python types via `sanitize_for_json()`
355
+ - **Unified error contract (D2, 2026-07)**: every error is `{"detail", "code"}` produced in one place (`services/api_errors.py`, `install_error_handlers(app)`). Raise a plain `HTTPException` (code derived from status: 400/404/409/422/500) or `AppHTTPException(status, detail, code)` when the frontend branches on the failure (e.g. `ACTION_RESULT_UNAVAILABLE`, `STUDY_BUSY`). Uncaught exceptions → generic logged 500 (never `str(e)` — it leaks paths). The frontend reads it via `frontend/src/utils/apiError.ts`.
356
+ - **Machine-checked API contract (D2, 2026-07)**: `app.openapi()` is snapshotted to `expert_backend/openapi.snapshot.json` and diffed in CI (`scripts/check_openapi_contract.py`, `test_openapi_contract.py`). Regenerate on any deliberate endpoint / model / status change: `python scripts/check_openapi_contract.py --write`.
357
+ - **Concurrency ownership (D3, 2026-07)**: a service-level re-entrant lock (`services/service_lock.py`, `@with_network_lock[_stream]`) serializes the variant-switching entry points on the shared `Network`; overlapping study mutations (config load / analysis) get **HTTP 409**. See [`docs/architecture/shared-network-concurrency.md`](docs/architecture/shared-network-concurrency.md).
358
+ - **Mixin → helper-package decomposition (PR #104 / #106)**: `DiagramMixin`, `AnalysisMixin` and `SimulationMixin` are thin orchestrators. Pure numerics live in `services/diagram/`, `services/analysis/` and `services/simulation_helpers.py` respectively — dependency-injected so existing `@patch` tests keep working.
359
+ - **SVG DOM recycling (PR #108)**: patch endpoints (`/api/contingency-diagram-patch`, `/api/action-variant-diagram-patch`) return per-branch deltas + optional VL-subtree splices so the frontend can clone the already-mounted N-state SVG instead of re-downloading the full NAD (~80 % faster tab switches on large grids).
360
+ - **Interactive overflow viewer (PR #116, 0.7.0)**: `services/overflow_overlay.py` injects a Co-Study4Grid pin / filter overlay (`<style>` + `<script>` block) into the upstream `expert_op4grid_recommender` HTML viewer before serving it from `/results/pdf/{filename}`. `services/analysis/overflow_geo_transform.py` is a pure lxml transform that rewrites the hierarchical-layout SVG to geographic coordinates for the `/api/regenerate-overflow-graph` toggle; the geo cache is per-study and cleared on `reset()`.
361
+ - **Shared diagram helpers**: `RecommenderService` uses `_load_network()`, `_load_layout()`, `_default_nad_parameters()`, and `_generate_diagram()` to deduplicate diagram generation logic across endpoints
362
+ - **Focused diagrams**: The `/api/focused-diagram` endpoint resolves an element to its voltage levels and generates a sub-diagram with configurable depth, useful for inspecting specific parts of large grids
363
+ - **Ruff-gated**: `pyproject.toml` configures a narrow `E9` + `F` ruleset (real bugs only); stylistic rules deliberately off
364
+
365
+ ### Frontend
366
+ - **Strict TypeScript**: `strict: true`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`
367
+ - **Functional components** with React hooks; no external state management library
368
+ - **Inline styles**: Components use inline `style` objects rather than CSS modules or utility classes
369
+ - **Design tokens (PR #120, 0.7.0)**: every colour / spacing / typography / radius value lives in `frontend/src/styles/tokens.{css,ts}`. Inline `style` objects import the typed `colors` / `space` / `text` / `radius` constants from `tokens.ts`; stylesheet rules use `var(--…)` from `tokens.css`. Raw SVG attribute setters (`element.setAttribute('fill', …)`) import the hex-valued `pinColors` / `pinChrome` constants because browsers don't reliably resolve `var(--…)` inside SVG presentation attributes. **The code-quality gate enforces zero hex literals outside the two token files.**
370
+ - **Light / dark theme (0.8.0)**: theming is a token swap, not per-component overrides — the `useTheme` hook flips a theme attribute that re-points the `tokens.css` custom properties, with a tiny pre-mount script to avoid the first-paint flash. A legibility pass covers the pypowsybl NAD / SLD chrome and the injected overflow-viewer overlay. See [`docs/features/dark-mode.md`](docs/features/dark-mode.md).
371
+ - **Component architecture (Phase 2 hook extraction, PR #109)**:
372
+ - `App.tsx` (~1400 lines) is the **state orchestration hub** — it wires all hooks together and handles cross-hook logic (e.g., `handleApplySettings`). It should NOT contain large JSX blocks.
373
+ - **Presentational components** live in `components/` and `components/modals/`. They receive data and callbacks via typed props; all business logic stays in `App.tsx` or in hooks.
374
+ - `hooks/useContingencyFetch.ts` owns the N-1 diagram fetch pipeline (svgPatch fast-path + `/api/contingency-diagram` fallback + contingency-change confirm routing).
375
+ - `hooks/useDiagramHighlights.ts` owns the per-tab SVG highlight pipeline (overload halos, contingency highlight, action targets, delta visuals) + per-tab Flow/Impacts view-mode state.
376
+ - `hooks/useOverflowIframe.ts` (PR #116, 0.7.0) owns the interactive overflow viewer — iframe lifecycle, layer-toggle state, hierarchical ↔ geo layout switch, postMessage bridge to the host, and the action-pin overlay payload computation.
377
+ - `hooks/useSldTopologyEdit.ts` (0.8.0) owns the interactive SLD edit flow — `editMode` (implicit while the SLD is open — no toggle button; read-only on close), staged `pendingStates` (switch toggles) + `pendingInjections` (load / generator active-power retunes), `toggle` / `removeSwitch(es)` / `setInjection` / `removeInjection` / `focusedSwitchId` — that turns clicked breakers AND injection retunes into one manual action card (`SldInjectionPopover` is the active-power editor bubble). See [`docs/features/sld-topology-edit.md`](docs/features/sld-topology-edit.md).
378
+ - `useSettings.ts` exposes `SettingsState` (all settings values + setters), which is passed wholesale to `SettingsModal` to avoid 30+ prop-drilling.
379
+ - **SVG DOM recycling (PR #108)**: `utils/svgPatch.ts` clones the already-mounted N-state `SVGSVGElement` and patches only per-branch deltas on N-1 / action tab switches, saving a 12–28 MB SVG re-download and re-parse.
380
+ - **Props-based data flow**: State lifted to `App.tsx`, passed down via props
381
+ - **ESLint**: Flat config (v9+) with `typescript-eslint`, `react-hooks`, and `react-refresh` plugins
382
+ - **Unit tests** use Vitest + React Testing Library. Isolated component tests (no backend mocking needed) live alongside their components as `*.test.tsx` files.
383
+
384
+ ### Data Flow
385
+ 1. User sets network path + action file path -> `POST /api/config` loads the network
386
+ 2. Frontend fetches disconnectable branches -> `GET /api/branches`
387
+ 3. User selects a contingency branch -> N-1 diagram fetched with overload highlighting
388
+ 4. User runs analysis (two-step flow):
389
+ - Step 1: `POST /api/run-analysis-step1` detects overloads in N-1 state
390
+ - User selects which overloads to resolve
391
+ - Step 2: `POST /api/run-analysis-step2` streams PDF event + action results
392
+ 5. Frontend displays overflow PDF and action cards in ActionFeed panel
393
+ 6. User can star/reject actions, manually simulate others, compute combined pairs
394
+ 7. Action selection triggers `POST /api/action-variant-diagram` -> post-action diagram
395
+ 8. Session save captures full snapshot to `session.json` + overflow PDF copy
396
+
397
+ ### Session Save/Load
398
+ - **Save**: `buildSessionResult()` in `sessionUtils.ts` serializes all state (config, contingency, actions with status tags, combined pairs) -> `POST /api/save-session` writes to disk
399
+ - **Load**: `POST /api/load-session` reads `session.json` -> frontend restores all state without re-simulating actions
400
+ - **Output folder**: `<output_folder>/costudy4grid_session_<contingency>_<timestamp>/` contains `session.json` + overflow PDF
401
+ - **Interaction logging**: Every user interaction is logged as a timestamped, replay-ready event via `interactionLogger`. Saved as `interaction_log.json` alongside `session.json`.
402
+ - See `docs/features/save-results.md` for session save/load, `docs/features/interaction-logging.md` for the replay contract, and `docs/features/action-overview-diagram.md` for the Remedial Action overview (pin overlay on N-1 network)
403
+
404
+ ### SVG Visualization
405
+ Both the React frontend and the auto-generated
406
+ `frontend/dist-standalone/standalone.html` render pypowsybl
407
+ NAD/SLD payloads:
408
+ - **Dynamic text scaling** (`utils/svgUtils.ts:boostSvgForLargeGrid` /
409
+ standalone `boostSvgForLargeGrid`): font sizes for node labels, edge
410
+ info, and legends scale proportionally to diagram size via
411
+ `sqrt(diagramSize / referenceSize)`, so text is readable when zoomed
412
+ in and naturally invisible at full zoom-out. Engaged for grids
413
+ ≥ 500 voltage levels.
414
+ - **Bus / transformer scaling**: circle radii for bus nodes and
415
+ transformer windings are boosted proportionally.
416
+ - **Edge-info scaling**: flow values and arrow glyphs are scaled via
417
+ transform groups so they remain proportional to the line on which
418
+ they sit.
419
+ - **ViewBox zoom**: auto-centers on selected contingency targets with
420
+ adjustable padding.
421
+ - **Pan/zoom**: the `usePanZoom` hook writes the SVG `viewBox`
422
+ directly (rAF-batched, cached CTM) — no pan/zoom library — in both
423
+ the React dev build and the auto-generated standalone (they share
424
+ the same source tree).
425
+
426
+ ## Dependencies
427
+
428
+ ### Backend (`pyproject.toml` + `overrides.txt`)
429
+ - `fastapi`, `uvicorn`, `python-multipart`
430
+ - `pypowsybl`, `expert_op4grid_recommender` (expected in venv)
431
+ - `pandas>=2.2.2`, `numpy>=2.0.0`, `grid2op>=1.12.2`, `pandapower>=2.14.0`
432
+ - `lightsim2grid>=0.12.0`, `matplotlib>=3.10.6`, `scipy>=1.16.0`
433
+ - `lxml>=6.0.0`, `contourpy>=1.2.0`, `tqdm>=4.65.0`
434
+
435
+ ### Frontend (`frontend/package.json`)
436
+ - See `dependencies` and `devDependencies` in `frontend/package.json`
437
+
438
+ ## File Conventions
439
+
440
+ - Network data files: `.xiidm` format (loaded by pypowsybl)
441
+ - Action definitions: `.json` files with action IDs mapping to descriptions
442
+ - Generated outputs: PDF files in `Overflow_Graph/` directory
443
+ - Network layouts: `grid_layout.json` (node ID -> [x, y] coordinates)
444
+
445
+ ## Notes for AI Assistants
446
+
447
+ - The backend API base URL defaults to `http://127.0.0.1:8000` in `frontend/src/api.ts` (`API_BASE_URL`), overridable at build time via `VITE_API_BASE_URL` — set it to `""` for **same-origin** hosting where the backend serves the SPA (the HuggingFace Docker Space), so requests become relative `/api/...`
448
+ - CORS defaults to the local Vite dev/preview origins on loopback (`localhost`/`127.0.0.1` on `:5173` / `:4173`); a wildcard is explicit opt-in and any other set is configurable via the `CORS_ALLOWED_ORIGINS` env var (see `.env.example`)
449
+ - **Frontend architecture (Phase 2 hook extraction, PR #109)**: `App.tsx` is the state orchestration hub; it must NOT contain large inline JSX blocks. Extracted presentational components live in `components/` and `components/modals/`; cross-cutting state pipelines live in `hooks/` (notably `useContingencyFetch` and `useDiagramHighlights`). When adding new UI sections, create a new component file (or hook for stateful pipelines) and wire it in `App.tsx`.
450
+ - **`useSettings` hook**: Exposes a `SettingsState` object with all settings fields + setters. This is passed wholesale to `SettingsModal` to avoid excessive prop drilling. Adding a new setting means: (1) add to `useSettings.ts`, (2) add to `SettingsModal.tsx`. No manual standalone mirror is required — the legacy hand-maintained file has been decommissioned and the auto-generated bundle inherits from the React source automatically.
451
+ - **Standalone bundle (auto-generated)**: `npm run build:standalone` in `frontend/` produces `frontend/dist-standalone/standalone.html` — a single-file HTML with React + CSS inlined via `vite-plugin-singlefile`. This is the canonical distribution artifact replacing the former `standalone_interface.html`. The legacy file remains on disk as `standalone_interface_legacy.html` (tracked as a frozen snapshot — do NOT edit).
452
+ - **Online deployment (HuggingFace Docker Space, 0.8.0)**: `Dockerfile` builds the SPA with `VITE_API_BASE_URL=""` + `VITE_GAME_MODE=1` and serves it **same-origin** with the FastAPI backend on port 7860. `main.py` optionally mounts the built SPA via `COSTUDY4GRID_FRONTEND_DIST` (mounted LAST, after every `/api/*` and `/results/*` route; inert when the dist is absent, so local dev is unaffected). One Space instance serves one player (module-level singletons). See `deploy/huggingface/`.
453
+ - **Deployment lockdown (D7, 2026-07)**: the `Dockerfile` sets `COSTUDY4GRID_LOCKDOWN=1`, which disables the desktop-era filesystem RPCs (custom config-file path, session save/list/load, native file picker) with a `403 {code: LOCKED_DOWN}` — they assume a local operator and would otherwise expose the container filesystem to an anonymous Space visitor. The read-only app config stays available so the SPA boots; unset locally so dev is unaffected. The HF deploy is **test-gated** (`workflow_run` on a green Tests run) and **tags each deploy** on origin (`space-deploy-*`) as the rollback pointer. See [`docs/architecture/deployment-trust.md`](docs/architecture/deployment-trust.md).
454
+ - **Game Mode (0.8.0)**: a timed, scored session shell in `frontend/src/game/`, **additive and inert unless `?game=1`** — `main.tsx` mounts `GameShell` instead of `App`; App integration is three `gameBridge.isGameMode()`-guarded touch points. See `docs/features/game-mode-codabench.md`.
455
+ - **Binary assets via Git LFS + transparent network decompression (0.8.0)**: `.gitattributes` tracks `*.zip` / `*.png` / `*.jpg` via Git LFS (the HuggingFace Space git endpoint rejects non-LFS binaries); the large France 225/400 kV grid ships as `network.xiidm.zip` and `network_service._resolve_network_file` / `_extract_network_zip` decompress it transparently on load.
456
+ - **CI pipelines**: GitHub Actions only (`.github/workflows/`: `code-quality.yml`, `parity.yml`, `test.yml`, `canary.yml`, `deploy-huggingface.yml`) run the code-quality gate, ruff, the pytest + Vitest suites, the data-pipeline + scorer-parity slice, and the parity scripts. CircleCI was removed (QW23 — GitHub Actions is a strict superset). The backend test + the `Dockerfile` install `expert_op4grid_recommender` from **`recommender-pin.txt`** — one pinned, exact version (QW8) so an upstream release can't silently break a PR or a rebuild; the weekly `canary.yml` floats to the latest release and flags regressions, so upgrades are a deliberate bump of that file.
457
+ - Root `.gitignore` excludes `__pycache__/`, `*.pyc`, `*.pyo`; `frontend/.gitignore` handles frontend build artifacts
458
+ - Integration helpers and parity scripts live under `scripts/`. They are NOT part of the pytest suite — invoke them directly. The PyPSA-EUR pipeline scripts under `scripts/pypsa_eur/` DO carry pytest coverage (`test_build_pipeline.py`, `test_calibrate_thermal_limits.py`, `test_generate_n1_overloads.py`, `test_regenerate_grid_layout.py`).
459
+ - `overrides.txt` contains pinned versions for transitive Python dependencies that need to be forced to specific versions
460
+ - **Frontend unit tests** use Vitest + React Testing Library. Isolated component tests live as `*.test.tsx` files next to their component. Run with `cd frontend && npm run test`. No backend mocking is needed for component tests since they only use mocked props.
461
+ - The two-step analysis flow (step1: detect overloads, step2: resolve) is the primary user workflow; the single-step `/api/run-analysis` is a legacy alternative
462
+ - Session save/load is documented in `docs/features/save-results.md`
463
+ - **`grid_layout.json` coordinate scale (2026-05-08)**: the on-disk layout MUST be in raw Mercator metres (span ≈ 1.4–1.6 M for the French grid). pypowsybl emits VL outer circles at a *fixed* `r = 27.5` user-space units, so any layout squashed below ~500 000 units forces overlap on dense regions (Paris/Lyon). `scripts/pypsa_eur/regenerate_grid_layout.py` defaults to raw metres; the legacy `--target-width 8000` flag is preserved but warns. Full rationale + operator-vs-PyPSA comparison in [`docs/data/grid-layout-coordinate-scale.md`](docs/data/grid-layout-coordinate-scale.md).
464
+
465
+ ---
466
+
467
+ ## Contributing & Pull Requests
468
+
469
+ - **Upstream is `ainetus`; `marota` is the working fork.** Development branches
470
+ are pushed to `marota/Co-Study4Grid`, but **pull requests are opened directly
471
+ against the upstream `ainetus/Co-Study4Grid`** (base = its default branch,
472
+ head = `marota:<branch>`) — *not* against `marota`. The sibling library
473
+ `Expert_op4grid_recommender` follows the same rule against
474
+ `ainetus/Expert_op4grid_recommender`.
475
+ - **Load `ainetus` as an initial source.** A cross-fork PR into `ainetus` can
476
+ only be created from a session/tool context that has the `ainetus` repo in
477
+ scope, so a new working session should be started with
478
+ **`ainetus/Co-Study4Grid` and `ainetus/Expert_op4grid_recommender` as the
479
+ initial sources** (they should always be auto-loaded). A session rooted only
480
+ at `marota` cannot target `ainetus` (cross-tier adds are blocked) and the PR
481
+ step fails with an access-denied error.
482
+ - **Sync `marota` with `ainetus` before starting new work.** PRs merge into
483
+ `ainetus/main`, but development happens on `marota`, so `marota/main` drifts
484
+ behind `ainetus/main` after every merged PR (this applies to **both** repos —
485
+ Co-Study4Grid and Expert_op4grid_recommender). **At the start of a dev session,
486
+ bring `marota/main` up to date with `ainetus/main`** — GitHub "Sync fork", or
487
+ locally `git fetch ainetus main && git merge --ff-only ainetus/main` then push
488
+ `marota/main` — and branch from there. Skipping this makes a new branch collide
489
+ with the already-merged revisions when it is PR'd into `ainetus`. If the sync
490
+ was missed and the PR already shows conflicts, merge `ainetus/main` into the
491
+ branch (or rebase onto it) and resolve, then force-with-lease push.
492
+ - **DCO sign-off is required on every commit.** The `ainetus` repos enforce the
493
+ [Developer Certificate of Origin](https://developercertificate.org/): every
494
+ commit must carry a `Signed-off-by: <Name> <amarot91@gmail.com>` trailer, and
495
+ because the DCO check matches the sign-off against the commit **author**, the
496
+ commit must also be *authored* under that same identity (author email =
497
+ `amarot91@gmail.com`):
498
+
499
+ ```bash
500
+ git config user.name "<Name>"
501
+ git config user.email "amarot91@gmail.com"
502
+ git commit -s -m "..." # -s appends the Signed-off-by trailer
503
+ ```
504
+
505
+ To sign off commits already made under a different identity, re-author and add
506
+ the trailer (`git rebase --exec 'git commit --amend --no-edit --reset-author \
507
+ -s' <base>`), then force-with-lease push.
508
+
509
+ ---
510
+
511
+ ## Standalone Interface Parity Audit
512
+
513
+ The detailed audit — feature inventory, mirror-status table, Layer
514
+ 1–4 conformity findings, regression-guard matrix, gap-priority list
515
+ and delta-vs-previous commits — lives in
516
+ [`frontend/PARITY_AUDIT.md`](frontend/PARITY_AUDIT.md). That
517
+ document is the working record of the parity project and is
518
+ updated as fixes land.
519
+
520
+ Quick status summary (2026-05-05):
521
+
522
+ - Canonical distribution is now the auto-generated
523
+ `frontend/dist-standalone/standalone.html`
524
+ (`npm run build:standalone`). The hand-maintained
525
+ `standalone_interface.html` has been decommissioned and
526
+ renamed to `standalone_interface_legacy.html` — committed as
527
+ a frozen snapshot of its last version (commit `5d2b9d1` content),
528
+ do NOT edit further. Regenerate UI from `frontend/src/` via
529
+ `npm run build:standalone` instead. The standalone versioned
530
+ snapshot was bumped to v0.7 on `adae7ac` to include references
531
+ to the new `/api/*-diagram-patch` endpoints.
532
+ - Four parity layers run against the React source + the
533
+ standalone of choice:
534
+ - **Layer 1 — static parity** (`scripts/check_standalone_parity.py`)
535
+ - **Layer 2 — session-reload fidelity** (`scripts/check_session_fidelity.py`)
536
+ - **Layer 3a — gesture-sequence static proxy** (`scripts/check_gesture_sequence.py`)
537
+ - **Layer 3b — behavioural E2E** (`scripts/parity_e2e/e2e_parity.spec.ts`)
538
+ - **Layer 4 — user-observable invariants** (`scripts/check_invariants.py`)
539
+ - All parity scripts accept `COSTUDY4GRID_STANDALONE_PATH` to
540
+ re-target any artifact; they default to the auto-gen bundle
541
+ and fall back to the legacy file when the auto-gen is not
542
+ built.
543
+
544
+ See [`frontend/PARITY_AUDIT.md`](frontend/PARITY_AUDIT.md) for
545
+ the full gap list, the session-fidelity regression record, the
546
+ honest-gap report of what each layer catches and misses, and the
547
+ 2026-04-20 delta that documents the `/api/restore-analysis-context`
548
+ one-way API drift (now resolved) and the auto-generated-standalone
549
+ viability confirmation.
CONTRIBUTING.md ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to Co-Study4Grid
2
+
3
+ Thanks for taking the time to contribute. This document captures the
4
+ conventions that keep the codebase easy to work in. For the project
5
+ overview and architecture see [`CLAUDE.md`](./CLAUDE.md).
6
+
7
+ ## Development setup
8
+
9
+ ### Backend
10
+
11
+ ```bash
12
+ python -m pip install --upgrade pip
13
+ pip install ".[test]"
14
+ pip install --no-deps expert_op4grid_recommender
15
+ uvicorn expert_backend.main:app --host 0.0.0.0 --port 8000
16
+ ```
17
+
18
+ #### System prerequisite — Graphviz (`dot`)
19
+
20
+ The overflow-graph rendering pipeline shells out to Graphviz's
21
+ `dot` binary. `pip install` attempts a best-effort auto-install via
22
+ the platform's package manager (apt / dnf / pacman / apk on Linux,
23
+ Homebrew or MacPorts on macOS, Chocolatey / winget / Scoop on
24
+ Windows); set `COSTUDY4GRID_SKIP_GRAPHVIZ_INSTALL=1` to opt out.
25
+ Modern wheel-based installs may skip the `setup.py` post-install
26
+ hook — re-run it manually with the bundled console script if
27
+ `dot -V` fails:
28
+
29
+ ```bash
30
+ costudy4grid-install-graphviz
31
+ ```
32
+
33
+ If the auto-install can't elevate (no `sudo`, locked package DB,
34
+ unsupported package manager), install Graphviz by hand:
35
+
36
+ | Platform | Command |
37
+ |-------------------------|---------------------------------------------------|
38
+ | Debian / Ubuntu | `sudo apt-get install graphviz` |
39
+ | RHEL / Fedora | `sudo dnf install graphviz` (or `yum`) |
40
+ | Arch | `sudo pacman -S graphviz` |
41
+ | Alpine | `sudo apk add graphviz` |
42
+ | macOS (Homebrew) | `brew install graphviz` |
43
+ | macOS (MacPorts) | `sudo port install graphviz` |
44
+ | Windows (Chocolatey) | `choco install graphviz` |
45
+ | Windows (winget) | `winget install Graphviz.Graphviz` |
46
+ | Windows (Scoop) | `scoop install graphviz` |
47
+
48
+ ### Frontend
49
+
50
+ ```bash
51
+ cd frontend
52
+ npm install
53
+ npm run dev # Vite dev server with HMR (default port 5173)
54
+ ```
55
+
56
+ ## Running tests
57
+
58
+ ```bash
59
+ # Backend
60
+ pytest
61
+
62
+ # Frontend
63
+ cd frontend
64
+ npm run test
65
+ npm run lint
66
+ ```
67
+
68
+ ## Code-quality checks
69
+
70
+ Continuous quality metrics are generated by
71
+ [`scripts/code_quality_report.py`](./scripts/code_quality_report.py) and
72
+ enforced by [`scripts/check_code_quality.py`](./scripts/check_code_quality.py).
73
+ Both run locally and in CI (see `.github/workflows/code-quality.yml`).
74
+
75
+ ```bash
76
+ # Generate a full JSON + Markdown report
77
+ python scripts/code_quality_report.py --output reports/code-quality.json \
78
+ --markdown reports/code-quality.md
79
+
80
+ # Gate a pull request (non-zero exit on regression)
81
+ python scripts/check_code_quality.py
82
+ ```
83
+
84
+ The gate enforces (full table in
85
+ [`scripts/check_code_quality.py`](./scripts/check_code_quality.py)):
86
+
87
+ - No new `print()` or `traceback.print_exc()` calls in backend sources
88
+ - No new bare `except Exception: pass` patterns
89
+ - Backend modules stay under **1150 lines** (the "god-object" ceiling);
90
+ functions under **240**. The scan covers all of `expert_backend/`
91
+ except the test suite and the setup-time / ad-hoc scripts.
92
+ - Backend functions also stay under **cyclomatic complexity 38** and
93
+ **nesting depth 8** (computed from the AST — no external tool).
94
+ - Frontend components stay under **1450 lines** (`utils/**` under
95
+ **1000**); `App.tsx`, the orchestration hub, has a bounded **2100**
96
+ ceiling rather than a blanket exemption.
97
+ - No `any` / `as any` annotations, and no `@ts-ignore` /
98
+ `@ts-expect-error` / `@ts-nocheck` in frontend sources
99
+ - **Ratcheted** (frozen at today's count, may only go down): backend
100
+ `# noqa` / `# type: ignore` (**3**), `as unknown as` casts (**12**),
101
+ `Record<string, unknown>` usages (**45**)
102
+ - **No hex color literals** in frontend source. The ceiling is zero —
103
+ every colour must come from a named token. Define new colours in
104
+ [`frontend/src/styles/tokens.css`](./frontend/src/styles/tokens.css)
105
+ (the canonical CSS variables) and re-export them from
106
+ [`frontend/src/styles/tokens.ts`](./frontend/src/styles/tokens.ts)
107
+ for inline-style consumers (`colors` / `space` / `text` / `radius`,
108
+ plus `pinColors` / `pinChrome` for SVG-attribute use cases). Both
109
+ token files are exempt from the gate; nothing else is.
110
+
111
+ Lower the thresholds — don't raise them. Tightening the gate
112
+ is how we protect the hard-won reductions documented in
113
+ [`docs/architecture/code-quality-analysis.md`](./docs/architecture/code-quality-analysis.md).
114
+
115
+ **mypy gates the build.** The shared-state base
116
+ ([`expert_backend/services/_recommender_state.py`](./expert_backend/services/_recommender_state.py))
117
+ makes the mixin composition type-check cleanly, so mypy sits at 0 and any
118
+ new type error fails CI. **Test coverage gates** on both ends: frontend
119
+ via `frontend/vite.config.ts` (`coverage.thresholds`, enforced by
120
+ `npm run test:coverage`) and backend via `pyproject.toml`
121
+ (`[tool.coverage.report] fail_under = 72`, enforced by `pytest --cov`).
122
+ Both floors sit a few points below the measured baseline — raise them as
123
+ coverage climbs, don't lower them. All are wired into the GitHub Actions
124
+ pipelines; see §§19–20 of the analysis doc.
125
+
126
+ ## Commit & PR conventions
127
+
128
+ - **Conventional-commit prefixes**: `feat:`, `fix:`, `perf:`, `docs:`,
129
+ `test:`, `refactor:`, `build:`, `chore:`. Match the surrounding git log.
130
+ - Keep PRs focused. One logical change per PR.
131
+ - Run `pytest`, `npm run test`, `npm run lint`, and
132
+ `python scripts/check_code_quality.py` before opening a PR.
133
+ - Update [`docs/architecture/code-quality-analysis.md`](./docs/architecture/code-quality-analysis.md)
134
+ when a fix resolves a documented issue.
135
+
136
+ ## Style
137
+
138
+ - **Python**: PEP 8 manually (4-space indent, type hints where helpful,
139
+ snake_case). Use `logging`, not `print`. Ruff runs in CI with a light
140
+ ruleset — see `pyproject.toml`.
141
+ - **TypeScript**: strict mode (`strict: true`, `noUnusedLocals`,
142
+ `noUnusedParameters`). No `any`. Functional components + hooks.
143
+ ESLint flat config enforces the rest.
144
+ - **Editor defaults** live in `.editorconfig`.
145
+
146
+ ## Reporting bugs
147
+
148
+ Open a GitHub issue with:
149
+
150
+ 1. Steps to reproduce (network path, action file, settings, contingency).
151
+ 2. Expected vs. actual behaviour.
152
+ 3. Browser console + backend stderr when UI-related.
153
+ 4. The attached session folder when reproducible via save/reload.
Dockerfile ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Co-Study4Grid — single-container image for a HuggingFace Docker Space.
2
+ #
3
+ # The frontend (built same-origin) and the FastAPI backend are served by one
4
+ # uvicorn process on port 7860. Bundled sample grids (data/) let the Game Mode
5
+ # presets resolve out of the box. See deploy/huggingface/ for the Space README
6
+ # and step-by-step setup.
7
+ #
8
+ # Build context = repo root: docker build -t costudy4grid .
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Stage 1 — build the React SPA (same-origin API, game mode on by default).
12
+ # ---------------------------------------------------------------------------
13
+ FROM node:20-bookworm-slim AS frontend
14
+
15
+ WORKDIR /build
16
+ COPY frontend/package.json frontend/package-lock.json ./
17
+ RUN npm ci
18
+
19
+ COPY frontend/ ./
20
+ # Empty base URL → relative `/api/...` requests, served by the backend below.
21
+ # VITE_GAME_MODE=1 → the Space boots straight into the timed game shell.
22
+ ENV VITE_API_BASE_URL="" \
23
+ VITE_GAME_MODE="1"
24
+ RUN npm run build
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Stage 2 — Python runtime serving API + built SPA + bundled grids.
28
+ # ---------------------------------------------------------------------------
29
+ FROM python:3.11-slim-bookworm AS runtime
30
+
31
+ # graphviz `dot`: overflow-graph rendering. libgomp1: OpenMP runtime that the
32
+ # scientific wheels (numpy / scipy / lightsim2grid) link against.
33
+ RUN apt-get update && apt-get install -y --no-install-recommends \
34
+ graphviz \
35
+ libgomp1 \
36
+ && rm -rf /var/lib/apt/lists/*
37
+
38
+ # HuggingFace Spaces run the container as uid 1000 ("user"). Keep the app under
39
+ # its home so runtime writes (Overflow_Graph/, config.json, session folders)
40
+ # land on a writable path.
41
+ RUN useradd -m -u 1000 user
42
+ USER user
43
+ ENV HOME=/home/user \
44
+ PATH=/home/user/.local/bin:$PATH \
45
+ PYTHONUNBUFFERED=1
46
+ WORKDIR /home/user/app
47
+
48
+ # --- Python dependencies (own layer for caching) ---------------------------
49
+ # `pip install .` pulls the declared runtime deps (ExpertOp4Grid, pypowsybl,
50
+ # fastapi, …). The recommender ships with `--no-deps` — mirroring CI — because
51
+ # its own dependency tree is self-conflicting (it wants numpy>=2 while its
52
+ # transitive `pypowsybl2grid` pins numpy==1.26.4); the working runtime deps
53
+ # come from `pip install .`. It is pinned via recommender-pin.txt (QW8) — the
54
+ # SAME file CI installs — so a zero-change rebuild can't drift to a new
55
+ # release. overrides.txt forces the pinned transitive versions last.
56
+ COPY --chown=user pyproject.toml README.md overrides.txt recommender-pin.txt ./
57
+ COPY --chown=user expert_backend/ ./expert_backend/
58
+ RUN pip install --no-cache-dir --upgrade pip \
59
+ && pip install --no-cache-dir . \
60
+ && pip install --no-cache-dir --no-deps -r recommender-pin.txt \
61
+ && pip install --no-cache-dir -r overrides.txt
62
+
63
+ # --- Application code, bundled grids, built SPA ----------------------------
64
+ # Seed the default user config (recommender params + the fr225_400 paths);
65
+ # the backend copies it to config.json on first boot.
66
+ COPY --chown=user config.default.json ./
67
+ COPY --chown=user data/ ./data/
68
+ COPY --chown=user scripts/ ./scripts/
69
+ # The European grid ships compressed (its raw .xiidm exceeds HuggingFace's
70
+ # 10 MiB git file limit, so it travels as a Git-LFS .zip). Validate + decompress
71
+ # it so pypowsybl can load network.xiidm directly — the "Medium" game difficulty.
72
+ # The helper FAILS LOUDLY on an un-smudged LFS pointer or a corrupt archive
73
+ # (QW25) instead of silently baking a broken grid into the image.
74
+ RUN python scripts/extract_network_zip.py data/pypsa_eur_eur220_225_380_400/network.xiidm.zip
75
+ COPY --chown=user --from=frontend /build/dist ./frontend/dist
76
+ # The overflow-viewer overlay (services/overflow_overlay.py) inlines this
77
+ # shared pin-glyph source module at request time, so it must exist at the
78
+ # path it expects even though the rest of frontend/src is not shipped.
79
+ COPY --chown=user frontend/src/utils/svg/pinGlyph.js ./frontend/src/utils/svg/pinGlyph.js
80
+
81
+ # The backend serves this directory at "/" (same origin as the API).
82
+ #
83
+ # EXPERT_OP4GRID_REASSESSMENT_PARALLEL=0 forces the per-action reassessment to
84
+ # run SERIALLY. The Space runs on 2 vCPUs; the recommender's container-aware
85
+ # detection already picks serial there, but pinning it makes the guarantee
86
+ # explicit and independent of the host's cgroup exposure — parallel worker
87
+ # threads each clone a full pypowsybl network, so on 2 vCPUs they over-subscribe
88
+ # the CPU and are far SLOWER than serial (the 47 s → ~15 s assessment win).
89
+ ENV COSTUDY4GRID_FRONTEND_DIST=/home/user/app/frontend/dist \
90
+ EXPERT_OP4GRID_REASSESSMENT_PARALLEL=0 \
91
+ PORT=7860 \
92
+ COSTUDY4GRID_LOCKDOWN=1
93
+ # COSTUDY4GRID_LOCKDOWN=1 (D7): this is a public, anonymous-visitor
94
+ # deployment, so the desktop-era filesystem RPCs (custom config path,
95
+ # session save/list/load, native file picker) are disabled — see the
96
+ # lockdown profile in expert_backend/main.py.
97
+
98
+ EXPOSE 7860
99
+
100
+ # Liveness probe (QW25): the read-only app-config endpoint stays available even
101
+ # under lockdown (D7), so it's a safe heartbeat. The long start-period covers
102
+ # the slow first network load. Uses $PORT so it tracks a non-default port.
103
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
104
+ CMD python -c "import os,sys,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/api/user-config' % os.environ.get('PORT','7860')).read(); sys.exit(0)"
105
+
106
+ # Shell form so ${PORT} expands (QW25: PORT was decorative before); `exec`
107
+ # keeps uvicorn as PID 1 so it receives SIGTERM directly for clean shutdown.
108
+ CMD ["sh", "-c", "exec uvicorn expert_backend.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
LICENSE.md ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Mozilla Public License Version 2.0
2
+ ==================================
3
+
4
+ 1. Definitions
5
+ --------------
6
+
7
+ 1.1. "Contributor"
8
+ means each individual or legal entity that creates, contributes to
9
+ the creation of, or owns Covered Software.
10
+
11
+ 1.2. "Contributor Version"
12
+ means the combination of the Contributions of others (if any) used
13
+ by a Contributor and that particular Contributor's Contribution.
14
+
15
+ 1.3. "Contribution"
16
+ means Covered Software of a particular Contributor.
17
+
18
+ 1.4. "Covered Software"
19
+ means Source Code Form to which the initial Contributor has attached
20
+ the notice in Exhibit A, the Executable Form of such Source Code
21
+ Form, and Modifications of such Source Code Form, in each case
22
+ including portions thereof.
23
+
24
+ 1.5. "Incompatible With Secondary Licenses"
25
+ means
26
+
27
+ (a) that the initial Contributor has attached the notice described
28
+ in Exhibit B to the Covered Software; or
29
+
30
+ (b) that the Covered Software was made available under the terms of
31
+ version 1.1 or earlier of the License, but not also under the
32
+ terms of a Secondary License.
33
+
34
+ 1.6. "Executable Form"
35
+ means any form of the work other than Source Code Form.
36
+
37
+ 1.7. "Larger Work"
38
+ means a work that combines Covered Software with other material, in
39
+ a separate file or files, that is not Covered Software.
40
+
41
+ 1.8. "License"
42
+ means this document.
43
+
44
+ 1.9. "Licensable"
45
+ means having the right to grant, to the maximum extent possible,
46
+ whether at the time of the initial grant or subsequently, any and
47
+ all of the rights conveyed by this License.
48
+
49
+ 1.10. "Modifications"
50
+ means any of the following:
51
+
52
+ (a) any file in Source Code Form that results from an addition to,
53
+ deletion from, or modification of the contents of Covered
54
+ Software; or
55
+
56
+ (b) any new file in Source Code Form that contains any Covered
57
+ Software.
58
+
59
+ 1.11. "Patent Claims" of a Contributor
60
+ means any patent claim(s), including without limitation, method,
61
+ process, and apparatus claims, in any patent Licensable by such
62
+ Contributor that would be infringed, but for the grant of the
63
+ License, by the making, using, selling, offering for sale, having
64
+ made, import, or transfer of either its Contributions or its
65
+ Contributor Version.
66
+
67
+ 1.12. "Secondary License"
68
+ means either the GNU General Public License, Version 2.0, the GNU
69
+ Lesser General Public License, Version 2.1, the GNU Affero General
70
+ Public License, Version 3.0, or any later versions of those
71
+ licenses.
72
+
73
+ 1.13. "Source Code Form"
74
+ means the form of the work preferred for making modifications.
75
+
76
+ 1.14. "You" (or "Your")
77
+ means an individual or a legal entity exercising rights under this
78
+ License. For legal entities, "You" includes any entity that
79
+ controls, is controlled by, or is under common control with You. For
80
+ purposes of this definition, "control" means (a) the power, direct
81
+ or indirect, to cause the direction or management of such entity,
82
+ whether by contract or otherwise, or (b) ownership of more than
83
+ fifty percent (50%) of the outstanding shares or beneficial
84
+ ownership of such entity.
85
+
86
+ 2. License Grants and Conditions
87
+ --------------------------------
88
+
89
+ 2.1. Grants
90
+
91
+ Each Contributor hereby grants You a world-wide, royalty-free,
92
+ non-exclusive license:
93
+
94
+ (a) under intellectual property rights (other than patent or trademark)
95
+ Licensable by such Contributor to use, reproduce, make available,
96
+ modify, display, perform, distribute, and otherwise exploit its
97
+ Contributions, either on an unmodified basis, with Modifications, or
98
+ as part of a Larger Work; and
99
+
100
+ (b) under Patent Claims of such Contributor to make, use, sell, offer
101
+ for sale, have made, import, and otherwise transfer either its
102
+ Contributions or its Contributor Version.
103
+
104
+ 2.2. Effective Date
105
+
106
+ The licenses granted in Section 2.1 with respect to any Contribution
107
+ become effective for each Contribution on the date the Contributor first
108
+ distributes such Contribution.
109
+
110
+ 2.3. Limitations on Grant Scope
111
+
112
+ The licenses granted in this Section 2 are the only rights granted under
113
+ this License. No additional rights or licenses will be implied from the
114
+ distribution or licensing of Covered Software under this License.
115
+ Notwithstanding Section 2.1(b) above, no patent license is granted by a
116
+ Contributor:
117
+
118
+ (a) for any code that a Contributor has removed from Covered Software;
119
+ or
120
+
121
+ (b) for infringements caused by: (i) Your and any other third party's
122
+ modifications of Covered Software, or (ii) the combination of its
123
+ Contributions with other software (except as part of its Contributor
124
+ Version); or
125
+
126
+ (c) under Patent Claims infringed by Covered Software in the absence of
127
+ its Contributions.
128
+
129
+ This License does not grant any rights in the trademarks, service marks,
130
+ or logos of any Contributor (except as may be necessary to comply with
131
+ the notice requirements in Section 3.4).
132
+
133
+ 2.4. Subsequent Licenses
134
+
135
+ No Contributor makes additional grants as a result of Your choice to
136
+ distribute the Covered Software under a subsequent version of this
137
+ License (see Section 10.2) or under the terms of a Secondary License (if
138
+ permitted under the terms of Section 3.3).
139
+
140
+ 2.5. Representation
141
+
142
+ Each Contributor represents that the Contributor believes its
143
+ Contributions are its original creation(s) or it has sufficient rights
144
+ to grant the rights to its Contributions conveyed by this License.
145
+
146
+ 2.6. Fair Use
147
+
148
+ This License is not intended to limit any rights You have under
149
+ applicable copyright doctrines of fair use, fair dealing, or other
150
+ equivalents.
151
+
152
+ 2.7. Conditions
153
+
154
+ Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155
+ in Section 2.1.
156
+
157
+ 3. Responsibilities
158
+ -------------------
159
+
160
+ 3.1. Distribution of Source Form
161
+
162
+ All distribution of Covered Software in Source Code Form, including any
163
+ Modifications that You create or to which You contribute, must be under
164
+ the terms of this License. You must inform recipients that the Source
165
+ Code Form of the Covered Software is governed by the terms of this
166
+ License, and how they can obtain a copy of this License. You may not
167
+ attempt to alter or restrict the recipients' rights in the Source Code
168
+ Form.
169
+
170
+ 3.2. Distribution of Executable Form
171
+
172
+ If You distribute Covered Software in Executable Form then:
173
+
174
+ (a) such Covered Software must also be made available in Source Code
175
+ Form, as described in Section 3.1, and You must inform recipients of
176
+ the Executable Form how they can obtain a copy of such Source Code
177
+ Form by reasonable means in a timely manner, at a charge no more
178
+ than the cost of distribution to the recipient; and
179
+
180
+ (b) You may distribute such Executable Form under the terms of this
181
+ License, or sublicense it under different terms, provided that the
182
+ license for the Executable Form does not attempt to limit or alter
183
+ the recipients' rights in the Source Code Form under this License.
184
+
185
+ 3.3. Distribution of a Larger Work
186
+
187
+ You may create and distribute a Larger Work under terms of Your choice,
188
+ provided that You also comply with the requirements of this License for
189
+ the Covered Software. If the Larger Work is a combination of Covered
190
+ Software with a work governed by one or more Secondary Licenses, and the
191
+ Covered Software is not Incompatible With Secondary Licenses, this
192
+ License permits You to additionally distribute such Covered Software
193
+ under the terms of such Secondary License(s), so that the recipient of
194
+ the Larger Work may, at their option, further distribute the Covered
195
+ Software under the terms of either this License or such Secondary
196
+ License(s).
197
+
198
+ 3.4. Notices
199
+
200
+ You may not remove or alter the substance of any license notices
201
+ (including copyright notices, patent notices, disclaimers of warranty,
202
+ or limitations of liability) contained within the Source Code Form of
203
+ the Covered Software, except that You may alter any license notices to
204
+ the extent required to remedy known factual inaccuracies.
205
+
206
+ 3.5. Application of Additional Terms
207
+
208
+ You may choose to offer, and to charge a fee for, warranty, support,
209
+ indemnity or liability obligations to one or more recipients of Covered
210
+ Software. However, You may do so only on Your own behalf, and not on
211
+ behalf of any Contributor. You must make it absolutely clear that any
212
+ such warranty, support, indemnity, or liability obligation is offered by
213
+ You alone, and You hereby agree to indemnify every Contributor for any
214
+ liability incurred by such Contributor as a result of warranty, support,
215
+ indemnity or liability terms You offer. You may include additional
216
+ disclaimers of warranty and limitations of liability specific to any
217
+ jurisdiction.
218
+
219
+ 4. Inability to Comply Due to Statute or Regulation
220
+ ---------------------------------------------------
221
+
222
+ If it is impossible for You to comply with any of the terms of this
223
+ License with respect to some or all of the Covered Software due to
224
+ statute, judicial order, or regulation then You must: (a) comply with
225
+ the terms of this License to the maximum extent possible; and (b)
226
+ describe the limitations and the code they affect. Such description must
227
+ be placed in a text file included with all distributions of the Covered
228
+ Software under this License. Except to the extent prohibited by statute
229
+ or regulation, such description must be sufficiently detailed for a
230
+ recipient of ordinary skill to be able to understand it.
231
+
232
+ 5. Termination
233
+ --------------
234
+
235
+ 5.1. The rights granted under this License will terminate automatically
236
+ if You fail to comply with any of its terms. However, if You become
237
+ compliant, then the rights granted under this License from a particular
238
+ Contributor are reinstated (a) provisionally, unless and until such
239
+ Contributor explicitly and finally terminates Your grants, and (b) on an
240
+ ongoing basis, if such Contributor fails to notify You of the
241
+ non-compliance by some reasonable means prior to 60 days after You have
242
+ come back into compliance. Moreover, Your grants from a particular
243
+ Contributor are reinstated on an ongoing basis if such Contributor
244
+ notifies You of the non-compliance by some reasonable means, this is the
245
+ first time You have received notice of non-compliance with this License
246
+ from such Contributor, and You become compliant prior to 30 days after
247
+ Your receipt of the notice.
248
+
249
+ 5.2. If You initiate litigation against any entity by asserting a patent
250
+ infringement claim (excluding declaratory judgment actions,
251
+ counter-claims, and cross-claims) alleging that a Contributor Version
252
+ directly or indirectly infringes any patent, then the rights granted to
253
+ You by any and all Contributors for the Covered Software under Section
254
+ 2.1 of this License shall terminate.
255
+
256
+ 5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257
+ end user license agreements (excluding distributors and resellers) which
258
+ have been validly granted by You or Your distributors under this License
259
+ prior to termination shall survive termination.
260
+
261
+ ************************************************************************
262
+ * *
263
+ * 6. Disclaimer of Warranty *
264
+ * ------------------------- *
265
+ * *
266
+ * Covered Software is provided under this License on an "as is" *
267
+ * basis, without warranty of any kind, either expressed, implied, or *
268
+ * statutory, including, without limitation, warranties that the *
269
+ * Covered Software is free of defects, merchantable, fit for a *
270
+ * particular purpose or non-infringing. The entire risk as to the *
271
+ * quality and performance of the Covered Software is with You. *
272
+ * Should any Covered Software prove defective in any respect, You *
273
+ * (not any Contributor) assume the cost of any necessary servicing, *
274
+ * repair, or correction. This disclaimer of warranty constitutes an *
275
+ * essential part of this License. No use of any Covered Software is *
276
+ * authorized under this License except under this disclaimer. *
277
+ * *
278
+ ************************************************************************
279
+
280
+ ************************************************************************
281
+ * *
282
+ * 7. Limitation of Liability *
283
+ * -------------------------- *
284
+ * *
285
+ * Under no circumstances and under no legal theory, whether tort *
286
+ * (including negligence), contract, or otherwise, shall any *
287
+ * Contributor, or anyone who distributes Covered Software as *
288
+ * permitted above, be liable to You for any direct, indirect, *
289
+ * special, incidental, or consequential damages of any character *
290
+ * including, without limitation, damages for lost profits, loss of *
291
+ * goodwill, work stoppage, computer failure or malfunction, or any *
292
+ * and all other commercial damages or losses, even if such party *
293
+ * shall have been informed of the possibility of such damages. This *
294
+ * limitation of liability shall not apply to liability for death or *
295
+ * personal injury resulting from such party's negligence to the *
296
+ * extent applicable law prohibits such limitation. Some *
297
+ * jurisdictions do not allow the exclusion or limitation of *
298
+ * incidental or consequential damages, so this exclusion and *
299
+ * limitation may not apply to You. *
300
+ * *
301
+ ************************************************************************
302
+
303
+ 8. Litigation
304
+ -------------
305
+
306
+ Any litigation relating to this License may be brought only in the
307
+ courts of a jurisdiction where the defendant maintains its principal
308
+ place of business and such litigation shall be governed by laws of that
309
+ jurisdiction, without reference to its conflict-of-law provisions.
310
+ Nothing in this Section shall prevent a party's ability to bring
311
+ cross-claims or counter-claims.
312
+
313
+ 9. Miscellaneous
314
+ ----------------
315
+
316
+ This License represents the complete agreement concerning the subject
317
+ matter hereof. If any provision of this License is held to be
318
+ unenforceable, such provision shall be reformed only to the extent
319
+ necessary to make it enforceable. Any law or regulation which provides
320
+ that the language of a contract shall be construed against the drafter
321
+ shall not be used to construe this License against a Contributor.
322
+
323
+ 10. Versions of the License
324
+ ---------------------------
325
+
326
+ 10.1. New Versions
327
+
328
+ Mozilla Foundation is the license steward. Except as provided in Section
329
+ 10.3, no one other than the license steward has the right to modify or
330
+ publish new versions of this License. Each version will be given a
331
+ distinguishing version number.
332
+
333
+ 10.2. Effect of New Versions
334
+
335
+ You may distribute the Covered Software under the terms of the version
336
+ of the License under which You originally received the Covered Software,
337
+ or under the terms of any subsequent version published by the license
338
+ steward.
339
+
340
+ 10.3. Modified Versions
341
+
342
+ If you create software not governed by this License, and you want to
343
+ create a new license for such software, you may create and use a
344
+ modified version of this License if you rename the license and remove
345
+ any references to the name of the license steward (except to note that
346
+ such modified license differs from this License).
347
+
348
+ 10.4. Distributing Source Code Form that is Incompatible With Secondary
349
+ Licenses
350
+
351
+ If You choose to distribute Source Code Form that is Incompatible With
352
+ Secondary Licenses under the terms of this version of the License, the
353
+ notice described in Exhibit B of this License must be attached.
354
+
355
+ Exhibit A - Source Code Form License Notice
356
+ -------------------------------------------
357
+
358
+ This Source Code Form is subject to the terms of the Mozilla Public
359
+ License, v. 2.0. If a copy of the MPL was not distributed with this
360
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
361
+
362
+ If it is not possible or desirable to put the notice in a particular
363
+ file, then You may include the notice in a location (such as a LICENSE
364
+ file in a relevant directory) where a recipient would be likely to look
365
+ for such a notice.
366
+
367
+ You may add additional accurate notices of copyright ownership.
368
+
369
+ Exhibit B - "Incompatible With Secondary Licenses" Notice
370
+ ---------------------------------------------------------
371
+
372
+ This Source Code Form is "Incompatible With Secondary Licenses", as
373
+ defined by the Mozilla Public License, v. 2.0.
Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html ADDED
The diff for this file is too large to render. See raw diff
 
README.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Co-Study4Grid Game
3
+ emoji: ⚡
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mpl-2.0
10
+ ---
11
+
12
+ # Co-Study4Grid — Game Mode
13
+
14
+ A timed, scored power-grid contingency game built on
15
+ [Co-Study4Grid](https://github.com/marota/Co-Study4Grid). Each **study** is a
16
+ grid state with an N-1 line outage that pushes a line past 100 % loading. Your
17
+ job: bring every monitored line back under 100 % with **at most 3 remedial
18
+ actions** before the per-study timer runs out, then move to the next study.
19
+
20
+ This Space boots straight into the game (the `VITE_GAME_MODE=1` build flag), so
21
+ there is nothing to configure — pick or tweak the study list and play.
22
+
23
+ ## How to play
24
+
25
+ 1. **Configure** — name the session, set the per-study timer and the action cap
26
+ (≤ 3), and review the ordered study list. It is pre-filled with a warm-up
27
+ tour of the bundled PyPSA-EUR France 225/400 kV grid; add more presets or
28
+ custom studies as you like.
29
+ 2. **Play** — a HUD shows the current study, a live countdown, your action
30
+ counter (`X/3`) and the best resulting line loading. Explore the network,
31
+ simulate actions, and **star** the ones you commit to. Click **Next study →**
32
+ (or let the timer expire) to advance.
33
+ 3. **Results** — a per-study table plus your final score, with **⬇ JSON
34
+ (Codabench)** and **⬇ CSV** exports. Submit the JSON to the matching
35
+ [Codabench](https://www.codabench.org/) competition to be ranked.
36
+
37
+ ## Scoring
38
+
39
+ Per study (0–100): `60·R + 25·R·A + 15·R·T` — **R** rewards remediation (worst
40
+ line back under 100 %), **A** rewards using fewer actions, **T** rewards speed.
41
+ Session score is the mean across studies. The in-browser scorer is a twin of the
42
+ Codabench Python scorer, locked by unit tests on both sides.
43
+
44
+ ## One player per instance
45
+
46
+ The backend keeps a **single active study** in memory (module-level
47
+ singletons), so one running Space serves **one player at a time**. For multiple
48
+ players, use the **Duplicate this Space** button (top-right) — each duplicate is
49
+ an isolated instance. A genuinely concurrent multi-player deployment would need
50
+ the backend refactored to be session-scoped; see the repo's deployment notes.
51
+
52
+ ## Resources
53
+
54
+ Heavy scientific stack (pypowsybl + a JVM-free native lib, grid2op, pandapower,
55
+ lightsim2grid). The free CPU tier (2 vCPU / 16 GB) handles the bundled small and
56
+ fr225_400 grids; first load after a cold start is slow while the container
57
+ boots. Storage is ephemeral — game results are downloaded client-side, so
58
+ nothing important lives on the Space disk.
59
+
60
+ One exception worth persisting: the **shared solution base** (every retained
61
+ proposition, signed with the player name, that feeds the novelty bonus and the
62
+ usage-frequency feedback). Enable **Settings → Persistent storage** and set the
63
+ `COSTUDY4GRID_DATA_DIR=/data` variable so it survives restarts — without it the
64
+ base still works but resets on every reboot. See `deploy/huggingface/SETUP.md`.
benchmarks/README.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Co-Study4Grid — Performance benchmarks
2
+
3
+ Consolidated micro-benchmarks that exercise the critical path of a
4
+ Load Study on the reference **PyPSA-EUR France 400 kV** grid
5
+ (~25 MB SVG, 6 835 VLs, 85 304 switches, 14 880 loads+generators,
6
+ 55 104 operational-limit entries).
7
+
8
+ These scripts drive the same code paths as the web UI but without the
9
+ HTTP stack, so they can be re-run after a patch to catch regressions
10
+ before pushing.
11
+
12
+ ## Prerequisites
13
+
14
+ ```bash
15
+ # Same venv as the backend (`expert_backend`) — needs:
16
+ # pypowsybl, expert_op4grid_recommender, pandas, numpy
17
+ export PATH="$HOME/.asdf/shims:$PATH"
18
+ python -c "import pypowsybl, expert_op4grid_recommender"
19
+ ```
20
+
21
+ Override the reference grid / action file via env vars:
22
+
23
+ ```bash
24
+ export BENCH_NETWORK_PATH=/path/to/grid_dir # contains grid.xiidm + grid_layout.json
25
+ export BENCH_ACTION_FILE=/path/to/reduced_actions.json
26
+ export BENCH_CONTINGENCY=DISCO_NAME # only for bench_n1_diagram.py
27
+ ```
28
+
29
+ ## Scripts
30
+
31
+ | Script | What it measures | Where the patch history lives |
32
+ |---|---|---|
33
+ | `bench_load_study.py` | Full `/api/config` + 4 parallel XHRs round-trip (reset + load_network + update_config + 4 response helpers). Cumulative target of every patch on the branch. | `docs/performance/history/loading-parallel.md` |
34
+ | `bench_topology_cache.py` | Per-helper + full `NetworkTopologyCache(net)` init. Validates upstream vectorisation series (0.2.0.post3 → post8). | `docs/performance/history/vectorize-topology-cache.md`, `docs/performance/history/topology-cache-iter2.md` |
35
+ | `bench_voltage_level_queries.py` | `/api/voltage-levels`, `/api/nominal-voltages`, `get_monitored_elements`, `_get_switches_with_topology` narrow-attr wins. | `docs/performance/history/narrow-voltage-level-queries.md` |
36
+ | `bench_n1_diagram.py` | Full `get_n1_diagram(contingency)` cold + warm, per-sub-step breakdown. Validates the 3 N-1 fast-path patches. | `docs/performance/history/n1-diagram-fast-path.md` |
37
+ | `bench_nad_n_state.py` | `get_network_diagram()` cold + warm on the N-state. Captures NAD / SVG / Meta sub-timings from the `[RECO]` log lines. | `docs/performance/nad-profile-bare-env.md` |
38
+ | `bench_nad_toggles.py` | Matrix of `NadParameters` toggle combinations — quantifies per-flag impact on NAD gen + SVG size, surfaces the cost of `injections_added=True`. | `docs/performance/nad-profile-bare-env.md` |
39
+ | `bench_analyze_suggest.py` | **Full "Analyze & Suggest" for a Game Mode study** — drives `/api/config` → `step1` → `step2` (streaming NDJSON) through the FastAPI `TestClient` and prints the UI's execution-time breakdown (step1 / overflow / prediction / **assessment** / enrichment / **Other**), with "Other" decomposed into discovery-overhead / result `sanitize_for_json` / transport. `--serial` forces serial reassessment; `--compare` runs parallel-vs-serial. This is the case the 30 s → 75 s regression was reported on. | `docs/performance/history/analyze-suggest-2vcpu.md` |
40
+ | `bench_load_flow_modes.py` | **Per-LF knob sweep** — times a single `run_ac` on the N-1 state under each load-flow parameter variant (tap-changer control mode, shunt / reactive / phase-shifter toggles), reporting time / Newton iters / constrained-line current. Isolates the ~6x reassessment cost to `transformer_voltage_control_on` and shows `AFTER_GENERATOR_VOLTAGE_CONTROL` recovers it. `--contingency` / `--overload` / `--reps`. | `docs/performance/history/reassessment-fast-mode-tap-control.md` |
41
+ | `run_all.py` | Drives every benchmark above sequentially. | — |
42
+
43
+ ### `bench_analyze_suggest.py`
44
+
45
+ ```bash
46
+ # The reported "this case, first scenario": Pyrenees LANNEL61PRAGN on the
47
+ # medium/European grid (its network.xiidm ships as a Git-LFS zip — run
48
+ # `git lfs pull` first, or use --tier high for the uncompressed French grid).
49
+ python benchmarks/bench_analyze_suggest.py # medium tier, first study
50
+ python benchmarks/bench_analyze_suggest.py --tier high # French grid, first study
51
+ python benchmarks/bench_analyze_suggest.py --compare # parallel vs serial, same case
52
+ ```
53
+
54
+ Two levers this benchmark validates:
55
+
56
+ - **Per-action reassessment goes serial on a CPU-limited host.** The tail line
57
+ reports `reassessment: serial|parallel — N worker(s) / M effective core(s)`.
58
+ On a 2-vCPU host the recommender's container-aware detection picks serial;
59
+ even on a 4-core dev box `--compare` shows parallel is no faster than serial
60
+ (each worker clones a full network), so over-subscribing 2 vCPUs with ~10
61
+ workers was the 47 s assessment in the regression.
62
+ - **The step-2 result payload no longer ships full-grid per-branch arrays.**
63
+ Each combined-action pair used to carry `p_or_combined` / `p_ex_combined`
64
+ (one float per line × ~100 pairs ≈ **29 MB** on the European grid); the
65
+ frontend reads neither, so they are emptied at the API boundary. Watch
66
+ `payload=… KiB` and the `result sanitize_for_json` sub-line drop
67
+ (29 269 KiB / 2.57 s → 267 KiB / 0.01 s).
68
+
69
+ ## Reference measurements
70
+
71
+ On a developer box with pypowsybl 1.14.0 + Python 3.12 + the full
72
+ PyPSA-EUR France 400 kV grid, current branch tip:
73
+
74
+ ### `bench_load_study.py`
75
+
76
+ | Segment | Measured |
77
+ |---|---|
78
+ | `reset()` | ~0 ms |
79
+ | `load_network` | ~2 200 ms |
80
+ | `update_config` | ~5 900 ms |
81
+ | 4 response XHRs | ~330 ms |
82
+ | **Total** | **~8.5 s** |
83
+
84
+ This maps to ~8.8 s end-to-end wall-clock on Chrome DevTools traces —
85
+ see v18 row of `docs/performance/history/loading-parallel.md` (-63 % vs v6 baseline).
86
+
87
+ ### `bench_voltage_level_queries.py`
88
+
89
+ | Endpoint | Before | After |
90
+ |---|---|---|
91
+ | `/api/voltage-levels` | 7.5 ms | 4.5 ms |
92
+ | `/api/nominal-voltages` | **144 ms** | **5.7 ms** (~25×) |
93
+ | `get_monitored_elements` | 265 ms | 175 ms |
94
+ | `_get_switches_with_topology` | 174 ms | 141 ms |
95
+
96
+ ### `bench_n1_diagram.py` (contingency `ARGIAL71CANTE`)
97
+
98
+ | Call | Before | After |
99
+ |---|---|---|
100
+ | COLD (first view) | 18 125 ms | **4 159 ms** (-77 %) |
101
+ | WARM (repeat view) | 11 906 ms | **3 200 ms** (-73 %) |
102
+
103
+ ## When to run them
104
+
105
+ - **Before pushing a perf patch** on the backend or on
106
+ `expert_op4grid_recommender`: run the benchmark closest to the
107
+ change and confirm no regression on the rest via `run_all.py`.
108
+ - **When a DevTools trace suggests slowdown**: map the hot span to
109
+ one of the four scripts to isolate it from web-layer variance.
110
+ - **When upstream bumps** `expert_op4grid_recommender`: re-run
111
+ `bench_topology_cache.py` + `bench_voltage_level_queries.py` to
112
+ catch behavioural changes in pypowsybl / numpy / pandas upgrades.
113
+
114
+ ## Notes
115
+
116
+ - These scripts import `expert_backend.services.*` directly, so they
117
+ need to run inside the Co-Study4Grid venv.
118
+ - Each benchmark is idempotent — running one does not alter global
119
+ state in a way that would affect the next (each call to
120
+ `setup_service` resets the recommender).
121
+ - The scripts intentionally use real data paths rather than mocks:
122
+ the goal is to measure the full pypowsybl + JNI + pandas stack.
123
+ Unit tests in `expert_backend/tests/` cover the mock path.
benchmarks/_bench_common.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
2
+ # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
3
+ # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
4
+ # you can obtain one at http://mozilla.org/MPL/2.0/.
5
+ # SPDX-License-Identifier: MPL-2.0
6
+
7
+ """Shared helpers for the Co-Study4Grid performance benchmarks.
8
+
9
+ Each script in this directory measures a distinct slice of the Load
10
+ Study critical path so that regressions can be caught on the real
11
+ PyPSA-EUR France 400 kV grid without standing up the full web stack.
12
+
13
+ Usage from each benchmark:
14
+
15
+ from _bench_common import bench, setup_service, NETWORK_PATH, ACTION_FILE
16
+
17
+ net = pn.load(NETWORK_PATH + "/grid.xiidm")
18
+ bench("my op", lambda: my_op(net))
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from types import SimpleNamespace
27
+ from typing import Callable, Iterable
28
+
29
+ # Make `expert_backend` importable when running a benchmark directly.
30
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
31
+ if str(_REPO_ROOT) not in sys.path:
32
+ sys.path.insert(0, str(_REPO_ROOT))
33
+
34
+ # Default reference grid — overridable via env var so benchmarks can
35
+ # also run on smaller test cases in CI.
36
+ NETWORK_PATH = os.environ.get(
37
+ "BENCH_NETWORK_PATH",
38
+ "/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_20240828T0100Z",
39
+ )
40
+ ACTION_FILE = os.environ.get(
41
+ "BENCH_ACTION_FILE",
42
+ "/home/marotant/dev/Expert_op4grid_recommender/data/action_space/"
43
+ "reduced_model_actions_20240828T0100Z_dijon.json",
44
+ )
45
+
46
+
47
+ def bench(label: str, fn: Callable, reps: int = 5, width: int = 60) -> object:
48
+ """Time `fn` over `reps` runs and print median/min.
49
+
50
+ Returns the last call's return value so the caller can assert
51
+ semantic equivalence across variants.
52
+ """
53
+ dts: list[float] = []
54
+ ret = None
55
+ for _ in range(reps):
56
+ t0 = time.perf_counter()
57
+ ret = fn()
58
+ dts.append((time.perf_counter() - t0) * 1000)
59
+ dts.sort()
60
+ med = dts[len(dts) // 2]
61
+ mn = dts[0]
62
+ print(f" {label:<{width}} median={med:>7.1f} ms min={mn:>7.1f}")
63
+ return ret
64
+
65
+
66
+ def setup_service(
67
+ network_path: str = NETWORK_PATH,
68
+ action_file: str = ACTION_FILE,
69
+ wait_for_nad_prefetch: bool = True,
70
+ ) -> tuple[object, object, float]:
71
+ """Prepare `network_service` + `recommender_service` with a
72
+ realistic config, mimicking `/api/config` from the UI.
73
+
74
+ Returns `(network_service, recommender_service, dt_setup_ms)`.
75
+ """
76
+ from expert_backend.services.network_service import network_service
77
+ from expert_backend.services.recommender_service import recommender_service
78
+
79
+ settings = SimpleNamespace(
80
+ network_path=network_path,
81
+ action_file_path=action_file,
82
+ layout_path=f"{network_path}/grid_layout.json",
83
+ min_line_reconnections=2.0,
84
+ min_close_coupling=3.0,
85
+ min_open_coupling=2.0,
86
+ min_line_disconnections=3.0,
87
+ n_prioritized_actions=10,
88
+ monitoring_factor=0.95,
89
+ pre_existing_overload_threshold=0.02,
90
+ ignore_reconnections=False,
91
+ pypowsybl_fast_mode=True,
92
+ min_pst=1.5,
93
+ min_load_shedding=2.5,
94
+ min_renewable_curtailment_actions=1,
95
+ lines_monitoring_path=None,
96
+ do_visualization=True,
97
+ )
98
+
99
+ t0 = time.perf_counter()
100
+ recommender_service.reset()
101
+ network_service.load_network(network_path)
102
+ recommender_service.update_config(settings)
103
+ if wait_for_nad_prefetch:
104
+ ev = getattr(recommender_service, "_prefetched_base_nad_event", None)
105
+ if ev is not None:
106
+ ev.wait(timeout=30)
107
+ dt_setup = (time.perf_counter() - t0) * 1000
108
+ return network_service, recommender_service, dt_setup
109
+
110
+
111
+ def timed(name: str, orig: Callable, store: dict) -> Callable:
112
+ """Wrap `orig` so every call appends its duration to `store[name]`.
113
+
114
+ Used to instrument a live service method without changing its code:
115
+
116
+ steps = {}
117
+ mixin._generate_diagram = timed("generate", mixin._generate_diagram, steps)
118
+ mixin.get_n1_diagram(...)
119
+ print(steps)
120
+ """
121
+ def wrapped(*a, **kw):
122
+ t0 = time.perf_counter()
123
+ try:
124
+ return orig(*a, **kw)
125
+ finally:
126
+ store.setdefault(name, []).append((time.perf_counter() - t0) * 1000)
127
+ return wrapped
128
+
129
+
130
+ def print_step_summary(steps: dict, header: str = "Step timings") -> None:
131
+ print(f"\n--- {header} ---")
132
+ for k, dts in steps.items():
133
+ if not dts:
134
+ continue
135
+ total = sum(dts)
136
+ last = dts[-1]
137
+ print(f" {k:<55} last={last:>8.1f} ms count={len(dts)} total={total:>8.1f} ms")
benchmarks/bench_analyze_suggest.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
4
+ # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
5
+ # you can obtain one at http://mozilla.org/MPL/2.0/.
6
+ # SPDX-License-Identifier: MPL-2.0
7
+
8
+ """Full "Analyze & Suggest" benchmark for a Game Mode study.
9
+
10
+ Drives the exact code path the Game Mode UI runs for one study —
11
+ ``POST /api/config`` → ``/api/run-analysis-step1`` → ``/api/run-analysis-step2``
12
+ (streaming NDJSON) — through the FastAPI ``TestClient`` (in-process, no port),
13
+ and reports the **same per-stage breakdown the UI shows** in its
14
+ "Execution time breakdown" tooltip:
15
+
16
+ Step 1 (contingency simulation) ... step1_time
17
+ Overflow analysis ................. overflow_graph_time
18
+ Action prediction ................. action_prediction_time
19
+ Action assessment ................. assessment_time (per-action reassessment)
20
+ Enrichment / post-process ......... enrichment_time
21
+ Other (network / streaming) ....... wall − sum(the five above)
22
+ Total (wall-clock, click → display) wall
23
+
24
+ Unlike the UI, it further **decomposes "Other"** into the server-side pieces
25
+ that fall outside the reported stages (discovery overhead = expert-rule filter
26
+ + input building; result-payload ``sanitize_for_json``; NDJSON size), so a
27
+ regression there can be attributed instead of hiding in a single bucket.
28
+
29
+ This is the case the perf work targets: the Game "first scenario" run that
30
+ regressed from ~30 s to ~75 s on the 2-vCPU HuggingFace Space. Use it to:
31
+
32
+ - confirm the per-action reassessment goes **serial** on a CPU-limited host
33
+ (``--serial`` forces it; the tail line reports workers / effective cores);
34
+ - compare parallel vs serial assessment (``--compare``);
35
+ - watch the "Other" residual after a serialization / filtering change.
36
+
37
+ Usage (from the repo root, backend venv active)::
38
+
39
+ python benchmarks/bench_analyze_suggest.py # medium tier, first study
40
+ python benchmarks/bench_analyze_suggest.py --tier high # French grid, first study
41
+ python benchmarks/bench_analyze_suggest.py --study s3 # a specific study id
42
+ python benchmarks/bench_analyze_suggest.py --serial # force serial reassessment
43
+ python benchmarks/bench_analyze_suggest.py --compare # parallel vs serial, same case
44
+
45
+ Path overrides (when the study's grid is not on disk — e.g. the medium/European
46
+ grid ships as a Git-LFS ``network.xiidm.zip`` that must be pulled first)::
47
+
48
+ BENCH_NETWORK_PATH=data/pypsa_eur_fr225_400/network.xiidm \\
49
+ BENCH_ACTION_FILE=data/pypsa_eur_fr225_400/actions.json \\
50
+ BENCH_LAYOUT=data/pypsa_eur_fr225_400/grid_layout.json \\
51
+ BENCH_CONTINGENCY=way_109818602-225 \\
52
+ python benchmarks/bench_analyze_suggest.py
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import os
59
+ import sys
60
+ import time
61
+ from pathlib import Path
62
+ from typing import Any
63
+
64
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
65
+ if str(_REPO_ROOT) not in sys.path:
66
+ sys.path.insert(0, str(_REPO_ROOT))
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Study presets — a Python mirror of the two tiers in
70
+ # `frontend/src/game/presets.ts` (kept in sync). The default is the medium
71
+ # tier's FIRST study, matching the Game Config screen's default and the case
72
+ # the perf regression was reported on. The medium/European grid ships as a
73
+ # Git-LFS zip; if it is not pulled, pass --tier high (the French grid ships
74
+ # uncompressed) or set the BENCH_* path overrides.
75
+ # ---------------------------------------------------------------------------
76
+ _EUR = {
77
+ "network": "data/pypsa_eur_eur220_225_380_400/network.xiidm",
78
+ "actions": "data/pypsa_eur_eur220_225_380_400/actions.json",
79
+ "layout": "data/pypsa_eur_eur220_225_380_400/grid_layout.json",
80
+ }
81
+ _FR = {
82
+ "network": "data/pypsa_eur_fr225_400/network.xiidm",
83
+ "actions": "data/pypsa_eur_fr225_400/actions.json",
84
+ "layout": "data/pypsa_eur_fr225_400/grid_layout.json",
85
+ }
86
+
87
+ TIERS: dict[str, dict[str, Any]] = {
88
+ "medium": {
89
+ "paths": _EUR,
90
+ "studies": [
91
+ {"id": "eu-pyrenees", "label": "Pyrenees 225 kV — LANNEL61PRAGN",
92
+ "contingency": "relation_8423570-225"},
93
+ {"id": "eu-italy", "label": "Campania 380 kV — Santa Sofia",
94
+ "contingency": "relation_13164355-380"},
95
+ {"id": "eu-spain", "label": "Hinojosa 400 kV double line",
96
+ "contingency": "way_170479605-400"},
97
+ ],
98
+ },
99
+ "high": {
100
+ "paths": _FR,
101
+ "studies": [
102
+ {"id": "s1", "label": "Toulouse 225 kV — Saint-Orens - Verfeil",
103
+ "contingency": "way_109818602-225"},
104
+ {"id": "s2", "label": "Biancon 225 kV — way/121500507",
105
+ "contingency": "way_121500507-225"},
106
+ {"id": "s3", "label": "Valence 225 kV — B.MONL61VALE8",
107
+ "contingency": "relation_6028666_c-225"},
108
+ {"id": "s4", "label": "Breuil 225 kV — BREUIL63CHAST",
109
+ "contingency": "relation_8307566_d-225"},
110
+ {"id": "s5", "label": "way/1463717755 225 kV",
111
+ "contingency": "way_1463717755-225"},
112
+ {"id": "s6", "label": "Échalas 225 kV — Échalas - Le Soleil",
113
+ "contingency": "way_130969307-225"},
114
+ {"id": "s7", "label": "Génissiat 400 kV — Cornier - Génissiat",
115
+ "contingency": "merged_way_100497456-400_1"},
116
+ {"id": "s8", "label": "Villejust 225 kV — Liers - Villejust",
117
+ "contingency": "way_204035714-225"},
118
+ ],
119
+ },
120
+ }
121
+
122
+ # Recommender config mirrors config.default.json (the bundled first-run
123
+ # settings the HuggingFace Space boots with).
124
+ CONFIG_MINIMA = {
125
+ "min_line_reconnections": 2.0,
126
+ "min_close_coupling": 3.0,
127
+ "min_open_coupling": 2.0,
128
+ "min_line_disconnections": 3.0,
129
+ "min_pst": 1.0,
130
+ "min_load_shedding": 2.0,
131
+ "min_renewable_curtailment_actions": 2,
132
+ "min_redispatch": 2,
133
+ "n_prioritized_actions": 15,
134
+ "monitoring_factor": 0.95,
135
+ "pre_existing_overload_threshold": 0.02,
136
+ "ignore_reconnections": False,
137
+ "pypowsybl_fast_mode": True,
138
+ }
139
+
140
+
141
+ def _resolve_case(args) -> tuple[dict, dict]:
142
+ """Return ``(paths, study)`` after applying tier/study selection + env
143
+ overrides."""
144
+ tier = TIERS[args.tier]
145
+ paths = dict(tier["paths"])
146
+ studies = tier["studies"]
147
+ if args.study:
148
+ match = [s for s in studies if s["id"] == args.study]
149
+ if not match:
150
+ raise SystemExit(
151
+ f"study '{args.study}' not in tier '{args.tier}'. "
152
+ f"Available: {', '.join(s['id'] for s in studies)}"
153
+ )
154
+ study = dict(match[0])
155
+ else:
156
+ study = dict(studies[0])
157
+
158
+ # Env overrides let the benchmark run when a study's grid is not on disk.
159
+ paths["network"] = os.environ.get("BENCH_NETWORK_PATH", paths["network"])
160
+ paths["actions"] = os.environ.get("BENCH_ACTION_FILE", paths["actions"])
161
+ paths["layout"] = os.environ.get("BENCH_LAYOUT", paths["layout"])
162
+ study["contingency"] = os.environ.get("BENCH_CONTINGENCY", study["contingency"])
163
+ return paths, study
164
+
165
+
166
+ class _Instrument:
167
+ """Monkeypatches that attribute the server-side share of "Other".
168
+
169
+ - ``sanitize_for_json`` (result-payload coercion at the NDJSON yield)
170
+ - ``run_analysis_step2_discovery`` wall time (so the discovery overhead —
171
+ expert-rule filter + recommender-input build, which is NOT part of the
172
+ reported prediction / assessment split — can be isolated).
173
+ """
174
+
175
+ def __init__(self):
176
+ self.sanitize_s = 0.0
177
+ self.discovery_wall_s = 0.0
178
+ self._patches = []
179
+
180
+ def __enter__(self):
181
+ # The model-aware step2 generator lives on AnalysisMixin (explicit
182
+ # composition since the 2026-07 D1 revision), so both patches
183
+ # target the analysis_mixin module namespace.
184
+ from expert_backend.services import analysis_mixin as am
185
+ si = am
186
+
187
+ orig_sanitize = si.sanitize_for_json
188
+
189
+ def timed_sanitize(obj):
190
+ t0 = time.perf_counter()
191
+ try:
192
+ return orig_sanitize(obj)
193
+ finally:
194
+ self.sanitize_s += time.perf_counter() - t0
195
+
196
+ orig_disc = am.run_analysis_step2_discovery
197
+
198
+ def timed_discovery(*a, **kw):
199
+ t0 = time.perf_counter()
200
+ try:
201
+ return orig_disc(*a, **kw)
202
+ finally:
203
+ self.discovery_wall_s += time.perf_counter() - t0
204
+
205
+ si.sanitize_for_json = timed_sanitize
206
+ am.run_analysis_step2_discovery = timed_discovery
207
+ self._patches = [
208
+ (si, "sanitize_for_json", orig_sanitize),
209
+ (am, "run_analysis_step2_discovery", orig_disc),
210
+ ]
211
+ return self
212
+
213
+ def __exit__(self, *exc):
214
+ for mod, name, orig in self._patches:
215
+ setattr(mod, name, orig)
216
+ return False
217
+
218
+
219
+ def run_once(client, paths, study, reset_between=True) -> dict:
220
+ """Drive config→step1→step2 for one study; return a metrics dict."""
221
+ cfg = {
222
+ "network_path": paths["network"],
223
+ "action_file_path": paths["actions"],
224
+ "layout_path": paths["layout"],
225
+ "model": "expert",
226
+ "compute_overflow_graph": True,
227
+ **CONFIG_MINIMA,
228
+ }
229
+ t_cfg = time.perf_counter()
230
+ r = client.post("/api/config", json=cfg)
231
+ r.raise_for_status()
232
+ t_cfg = time.perf_counter() - t_cfg
233
+
234
+ contingency = study["contingency"]
235
+ t_s1 = time.perf_counter()
236
+ r = client.post("/api/run-analysis-step1",
237
+ json={"disconnected_elements": [contingency]})
238
+ r.raise_for_status()
239
+ t_s1 = time.perf_counter() - t_s1
240
+ step1 = r.json()
241
+ overloads = step1.get("lines_overloaded", [])
242
+ if not (step1.get("can_proceed") and overloads):
243
+ return {"error": f"no actionable overload (can_proceed={step1.get('can_proceed')}, "
244
+ f"{len(overloads)} overload(s))"}
245
+
246
+ with _Instrument() as inst:
247
+ t_s2 = time.perf_counter()
248
+ r = client.post("/api/run-analysis-step2",
249
+ json={"selected_overloads": overloads, "all_overloads": overloads})
250
+ r.raise_for_status()
251
+ payload_text = r.text
252
+ t_s2 = time.perf_counter() - t_s2
253
+
254
+ result = {}
255
+ for line in payload_text.splitlines():
256
+ line = line.strip()
257
+ if not line:
258
+ continue
259
+ ev = json.loads(line)
260
+ if ev.get("type") == "result":
261
+ result = ev
262
+ elif ev.get("type") == "error":
263
+ return {"error": ev.get("message")}
264
+
265
+ # Per-stage timings reported by the backend (the UI tooltip buckets).
266
+ step1_t = result.get("step1_time") or 0.0
267
+ overflow_t = result.get("overflow_graph_time") or 0.0
268
+ prediction_t = result.get("action_prediction_time") or 0.0
269
+ assessment_t = result.get("assessment_time") or 0.0
270
+ enrichment_t = result.get("enrichment_time") or 0.0
271
+ backend_sum = step1_t + overflow_t + prediction_t + assessment_t + enrichment_t
272
+
273
+ # Wall-clock "click → display" proxy: step1 + step2 request wall time.
274
+ wall = t_s1 + t_s2
275
+ other = max(0.0, wall - backend_sum)
276
+ discovery_overhead = max(0.0, inst.discovery_wall_s - prediction_t - assessment_t)
277
+
278
+ reassess = result.get("reassessment_parallelism")
279
+ if reassess is None:
280
+ from expert_backend.services.recommender_service import recommender_service
281
+ reassess = (getattr(recommender_service, "_last_result", None) or {}).get(
282
+ "reassessment_parallelism")
283
+
284
+ return {
285
+ "n_actions": len(result.get("actions", {})),
286
+ "n_overloads": len(overloads),
287
+ "payload_bytes": len(payload_text),
288
+ "t_config": t_cfg,
289
+ "t_step1_http": t_s1,
290
+ "t_step2_http": t_s2,
291
+ "wall": wall,
292
+ "step1": step1_t,
293
+ "overflow": overflow_t,
294
+ "prediction": prediction_t,
295
+ "assessment": assessment_t,
296
+ "enrichment": enrichment_t,
297
+ "backend_sum": backend_sum,
298
+ "other": other,
299
+ "other_discovery_overhead": discovery_overhead,
300
+ "other_sanitize": inst.sanitize_s,
301
+ "other_residual": max(0.0, other - discovery_overhead - inst.sanitize_s),
302
+ "reassessment": reassess,
303
+ }
304
+
305
+
306
+ def _fmt(s: float) -> str:
307
+ return f"{s:6.2f}s"
308
+
309
+
310
+ def _print_breakdown(m: dict, title: str) -> None:
311
+ if "error" in m:
312
+ print(f"\n{title}: SKIPPED — {m['error']}")
313
+ return
314
+ total = m["wall"]
315
+
316
+ def pct(x):
317
+ return f"{100 * x / total:4.0f}%" if total else " -"
318
+ print(f"\n=== {title} ===")
319
+ print(f" actions={m['n_actions']} overloads={m['n_overloads']} "
320
+ f"payload={m['payload_bytes'] / 1024:.0f} KiB config-load={_fmt(m['t_config'])}")
321
+ print(f" {'Step 1 (contingency simulation)':<34} {_fmt(m['step1'])} {pct(m['step1'])}")
322
+ print(f" {'Overflow analysis':<34} {_fmt(m['overflow'])} {pct(m['overflow'])}")
323
+ print(f" {'Action prediction':<34} {_fmt(m['prediction'])} {pct(m['prediction'])}")
324
+ print(f" {'Action assessment (reassessment)':<34} {_fmt(m['assessment'])} {pct(m['assessment'])}")
325
+ print(f" {'Enrichment / post-process':<34} {_fmt(m['enrichment'])} {pct(m['enrichment'])}")
326
+ print(f" {'Other (network / streaming)':<34} {_fmt(m['other'])} {pct(m['other'])}")
327
+ print(f" ├─ {'discovery overhead (filter+inputs)':<29} {_fmt(m['other_discovery_overhead'])}")
328
+ print(f" ├─ {'result sanitize_for_json':<29} {_fmt(m['other_sanitize'])}")
329
+ print(f" └─ {'transport / frontend residual':<29} {_fmt(m['other_residual'])}")
330
+ print(f" {'─' * 46}")
331
+ print(f" {'Total (wall-clock, click → display)':<34} {_fmt(total)}")
332
+ if m.get("reassessment"):
333
+ ra = m["reassessment"]
334
+ mode = "parallel" if ra.get("parallel") else "serial"
335
+ print(f" reassessment: {mode} — {ra.get('workers')} worker(s) / "
336
+ f"{ra.get('cores_available')} effective core(s), "
337
+ f"{ra.get('n_actions')} action(s)")
338
+
339
+
340
+ def main() -> None:
341
+ ap = argparse.ArgumentParser(description=__doc__,
342
+ formatter_class=argparse.RawDescriptionHelpFormatter)
343
+ ap.add_argument("--tier", choices=list(TIERS), default="medium",
344
+ help="difficulty tier (default: medium = European grid)")
345
+ ap.add_argument("--study", default=None,
346
+ help="study id within the tier (default: first study)")
347
+ ap.add_argument("--reps", type=int, default=1,
348
+ help="repetitions (median reported); a fresh config load per rep")
349
+ ap.add_argument("--serial", action="store_true",
350
+ help="force serial per-action reassessment (EXPERT_OP4GRID_REASSESSMENT_PARALLEL=0)")
351
+ ap.add_argument("--compare", action="store_true",
352
+ help="run the same case twice — parallel then serial — and print both")
353
+ args = ap.parse_args()
354
+
355
+ os.chdir(_REPO_ROOT)
356
+ from fastapi.testclient import TestClient
357
+
358
+ from expert_backend.main import app
359
+ from expert_op4grid_recommender import config as eo_config
360
+
361
+ paths, study = _resolve_case(args)
362
+ net_ok = Path(paths["network"]).is_file() and Path(paths["network"]).stat().st_size > 1024
363
+ print(f"Case: tier={args.tier} study={study['id']} ({study['label']})")
364
+ print(f" network={paths['network']} contingency={study['contingency']}")
365
+ if not net_ok:
366
+ print(f"\nWARNING: {paths['network']} is missing or a Git-LFS pointer.\n"
367
+ f" Pull LFS (git lfs pull) or use --tier high / the BENCH_* path overrides.")
368
+
369
+ def _run(force_serial: bool, label: str) -> dict:
370
+ eo_config.REASSESSMENT_PARALLEL = False if force_serial else None
371
+ samples = []
372
+ with TestClient(app) as client:
373
+ for _ in range(max(1, args.reps)):
374
+ samples.append(run_once(client, paths, study))
375
+ ok = [s for s in samples if "error" not in s]
376
+ if not ok:
377
+ return samples[0]
378
+ ok.sort(key=lambda s: s["wall"])
379
+ return ok[len(ok) // 2] # median by wall-clock
380
+
381
+ if args.compare:
382
+ _print_breakdown(_run(False, "parallel"), "PARALLEL (auto)")
383
+ _print_breakdown(_run(True, "serial"), "SERIAL (forced)")
384
+ else:
385
+ _print_breakdown(_run(args.serial, "run"),
386
+ f"{'SERIAL (forced)' if args.serial else 'AUTO'}")
387
+
388
+
389
+ if __name__ == "__main__":
390
+ main()
benchmarks/bench_load_flow_modes.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
4
+ # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
5
+ # you can obtain one at http://mozilla.org/MPL/2.0/.
6
+ # SPDX-License-Identifier: MPL-2.0
7
+
8
+ """Per-load-flow micro-benchmark: what makes "slow mode" 6x slower than "fast".
9
+
10
+ Isolates a SINGLE ``run_ac`` on the post-contingency (N-1) state and times it
11
+ under each load-flow parameter variant, so the cost of the per-action
12
+ reassessment (`utils/reassessment.py` re-simulates every prioritized action
13
+ with one AC LF each) can be attributed to a specific knob rather than guessed.
14
+
15
+ Key result on the full France grid (`bare_env_20240828T0100Z`, contingency
16
+ ``P.SAOL31RONCI`` → overload ``BEON L31CPVAN``): the entire slow/fast gap is
17
+ the **transformer tap-changer voltage-control mode**. The provider default
18
+ (incremental outer loop) needs ~57 Newton iterations/LF; switching to
19
+ ``AFTER_GENERATOR_VOLTAGE_CONTROL`` converges in ~20 for the same branch
20
+ current (<0.5 % delta). Disabling tap control entirely (the *old* fast mode)
21
+ is only marginally faster still but changes the currents. See
22
+ ``docs/performance/history/reassessment-fast-mode-tap-control.md``.
23
+
24
+ Usage (from repo root, backend venv active)::
25
+
26
+ python benchmarks/bench_load_flow_modes.py
27
+ python benchmarks/bench_load_flow_modes.py --contingency P.SAOL31RONCI \
28
+ --overload "BEON L31CPVAN" --reps 3
29
+
30
+ Override the grid with ``BENCH_NETWORK_PATH`` (see ``_bench_common``).
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import time
36
+
37
+ import pypowsybl as pp
38
+ import pypowsybl.loadflow as lf
39
+
40
+ from _bench_common import NETWORK_PATH
41
+
42
+
43
+ def _base_params(**override):
44
+ """The recommender's slow-mode default (NetworkManager._create_default_lf_parameters).
45
+
46
+ ``override`` accepts any ``lf.Parameters`` attribute plus a special
47
+ ``provider`` dict that is merged into ``provider_parameters``.
48
+ """
49
+ provider = {
50
+ "useActiveLimits": "true",
51
+ "svcVoltageMonitoring": "false",
52
+ "voltageRemoteControl": "false",
53
+ "writeReferenceTerminals": "false",
54
+ "slackBusSelectionMode": "MOST_MESHED",
55
+ "maxOuterLoopIterations": "100",
56
+ }
57
+ provider.update(override.pop("provider", {}))
58
+ p = lf.Parameters(
59
+ read_slack_bus=False,
60
+ write_slack_bus=False,
61
+ voltage_init_mode=override.pop("voltage_init_mode", lf.VoltageInitMode.PREVIOUS_VALUES),
62
+ transformer_voltage_control_on=override.pop("transformer_voltage_control_on", True),
63
+ use_reactive_limits=override.pop("use_reactive_limits", True),
64
+ shunt_compensator_voltage_control_on=override.pop("shunt_compensator_voltage_control_on", True),
65
+ phase_shifter_regulation_on=override.pop("phase_shifter_regulation_on", True),
66
+ distributed_slack=True,
67
+ dc_use_transformer_ratio=False,
68
+ twt_split_shunt_admittance=True,
69
+ provider_parameters=provider,
70
+ )
71
+ for k, v in override.items():
72
+ setattr(p, k, v)
73
+ return p
74
+
75
+
76
+ def main() -> None:
77
+ ap = argparse.ArgumentParser(description=__doc__)
78
+ ap.add_argument("--contingency", default="P.SAOL31RONCI")
79
+ ap.add_argument("--overload", default="BEON L31CPVAN")
80
+ ap.add_argument("--reps", type=int, default=3)
81
+ args = ap.parse_args()
82
+
83
+ net = pp.network.load(f"{NETWORK_PATH}/grid.xiidm")
84
+
85
+ # Seed a valid state (DC_VALUES init — no previous voltages exist yet),
86
+ # then apply the contingency on a fresh variant and seed it too, so every
87
+ # timed config below starts from a converged PREVIOUS_VALUES baseline.
88
+ seed = _base_params(voltage_init_mode=lf.VoltageInitMode.DC_VALUES)
89
+ lf.run_ac(net, parameters=seed)
90
+ net.clone_variant(net.get_variant_ids()[0], "n1")
91
+ net.set_working_variant("n1")
92
+ try:
93
+ net.update_lines(id=args.contingency, connected1=False, connected2=False)
94
+ except Exception:
95
+ net.update_2_windings_transformers(id=args.contingency, connected1=False, connected2=False)
96
+ lf.run_ac(net, parameters=seed)
97
+
98
+ def run(label: str, params) -> None:
99
+ ts = []
100
+ res = None
101
+ for _ in range(args.reps):
102
+ t = time.perf_counter()
103
+ try:
104
+ res = lf.run_ac(net, parameters=params)
105
+ except Exception as e: # noqa: BLE001
106
+ print(f" {label:<44s} FAILED ({str(e)[:50]})")
107
+ return
108
+ ts.append(time.perf_counter() - t)
109
+ r0 = res[0]
110
+ try:
111
+ ln = net.get_lines(attributes=["i1", "i2"]).loc[args.overload]
112
+ imax = f"{max(abs(ln.i1), abs(ln.i2)):.1f}A"
113
+ except Exception:
114
+ imax = "n/a"
115
+ print(f" {label:<44s} {min(ts):6.3f}s {r0.status.name:<10s} "
116
+ f"nr={getattr(r0, 'iteration_count', '?'):>3} I={imax}")
117
+
118
+ print(f"Grid={NETWORK_PATH} contingency={args.contingency} overload={args.overload}")
119
+ print(f"Per-LF run_ac ({args.reps} reps, min):")
120
+ run("SLOW default (incremental tap control)", _base_params())
121
+ run("FAST-old (transformer+shunt ctrl OFF)", _base_params(
122
+ transformer_voltage_control_on=False, shunt_compensator_voltage_control_on=False))
123
+ run("FAST-new (tap ctrl AFTER_GENERATOR)", _base_params(
124
+ provider={"transformerVoltageControlMode": "AFTER_GENERATOR_VOLTAGE_CONTROL"}))
125
+ run("slow, shunt ctrl OFF only", _base_params(shunt_compensator_voltage_control_on=False))
126
+ run("slow, transformer ctrl OFF only", _base_params(transformer_voltage_control_on=False))
127
+ run("slow, reactive_limits OFF", _base_params(use_reactive_limits=False))
128
+ run("slow, phase_shifter_reg OFF", _base_params(phase_shifter_regulation_on=False))
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
benchmarks/bench_load_study.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """End-to-end profile of a Load Study backend round-trip.
6
+
7
+ Mimics what `/api/config` + the 4 subsequent parallel XHRs do on a
8
+ real UI click, without the HTTP stack. Used to track the cumulative
9
+ wall-clock gains documented in `docs/performance/history/loading-parallel.md`
10
+ (v6 → v18+ trace entries).
11
+
12
+ Usage:
13
+
14
+ BENCH_NETWORK_PATH=/path/to/grid_dir python benchmarks/bench_load_study.py
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from types import SimpleNamespace
20
+
21
+ from _bench_common import ACTION_FILE, NETWORK_PATH
22
+
23
+
24
+ def main() -> None:
25
+ print(f"Network: {NETWORK_PATH}")
26
+ print(f"Action file: {ACTION_FILE}")
27
+
28
+ from expert_backend.services.network_service import network_service
29
+ from expert_backend.services.recommender_service import recommender_service
30
+
31
+ settings = SimpleNamespace(
32
+ network_path=NETWORK_PATH,
33
+ action_file_path=ACTION_FILE,
34
+ layout_path=f"{NETWORK_PATH}/grid_layout.json",
35
+ min_line_reconnections=2.0,
36
+ min_close_coupling=3.0,
37
+ min_open_coupling=2.0,
38
+ min_line_disconnections=3.0,
39
+ n_prioritized_actions=10,
40
+ monitoring_factor=0.95,
41
+ pre_existing_overload_threshold=0.02,
42
+ ignore_reconnections=False,
43
+ pypowsybl_fast_mode=True,
44
+ min_pst=1.5,
45
+ min_load_shedding=2.5,
46
+ min_renewable_curtailment_actions=1,
47
+ lines_monitoring_path=None,
48
+ do_visualization=True,
49
+ )
50
+
51
+ # Step 1 — recommender_service.reset() (clears all caches; drains
52
+ # any stale NAD prefetch from the previous run).
53
+ t0 = time.perf_counter()
54
+ recommender_service.reset()
55
+ dt_reset = (time.perf_counter() - t0) * 1000
56
+
57
+ # Step 2 — pypowsybl network parse (~2 s on the PyPSA-EUR France
58
+ # 118 MB xiidm, dominated by JNI serialisation).
59
+ t0 = time.perf_counter()
60
+ network_service.load_network(NETWORK_PATH)
61
+ dt_load = (time.perf_counter() - t0) * 1000
62
+
63
+ # Step 3 — update_config: the big one. Spawns the base-NAD
64
+ # prefetch worker early (see docs/performance/history/nad-prefetch-earlier-spawn.md),
65
+ # runs enrich_actions_lazy (NetworkTopologyCache — now ~700 ms
66
+ # since 0.2.0.post5+post6), sets up SimulationEnvironment.
67
+ t0 = time.perf_counter()
68
+ recommender_service.update_config(settings)
69
+ dt_update = (time.perf_counter() - t0) * 1000
70
+
71
+ # Step 4 — the 4 post-config XHRs fired in parallel by the frontend.
72
+ t0 = time.perf_counter()
73
+ total_lines = len(network_service.get_disconnectable_elements())
74
+ monitored = len(network_service.get_monitored_elements())
75
+ vls = len(network_service.get_voltage_levels())
76
+ nominals = len(network_service.get_nominal_voltages())
77
+ dt_resp = (time.perf_counter() - t0) * 1000
78
+
79
+ total = dt_reset + dt_load + dt_update + dt_resp
80
+
81
+ print(f"\n{'reset()':<32} {dt_reset:>8.1f} ms")
82
+ print(f"{'load_network':<32} {dt_load:>8.1f} ms")
83
+ print(f"{'update_config':<32} {dt_update:>8.1f} ms")
84
+ print(f"{'response XHRs (4)':<32} {dt_resp:>8.1f} ms")
85
+ print(f"{'TOTAL':<32} {total:>8.1f} ms ({total / 1000:.1f} s)")
86
+ print(
87
+ f"\nCounts: lines={total_lines} monitored={monitored} "
88
+ f"vls={vls} nominals={nominals}"
89
+ )
90
+
91
+ # Step 5 — NAD prefetch overflow: time spent AFTER the 4 XHRs
92
+ # waiting for the background worker to finish. Should be ~0 ms
93
+ # if the prefetch was spawned early enough in update_config.
94
+ ev = getattr(recommender_service, "_prefetched_base_nad_event", None)
95
+ if ev is not None:
96
+ t0 = time.perf_counter()
97
+ ev.wait(timeout=30)
98
+ dt_wait = (time.perf_counter() - t0) * 1000
99
+ print(f"\nNAD worker overflow after endpoint: {dt_wait:.1f} ms")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
benchmarks/bench_n1_diagram.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Profile a full `get_n1_diagram(contingency)` call end-to-end.
6
+
7
+ Covers the three fast-path patches documented in
8
+ `docs/performance/history/n1-diagram-fast-path.md`:
9
+
10
+ 1. `_get_asset_flows` vectorised (1 168 ms → 75 ms)
11
+ 2. `_get_overloaded_lines` vect. (1 161 ms → 98 ms)
12
+ 3. LF-status cache per variant (600-1000 ms saved per view)
13
+
14
+ Measures both the COLD call (N-1 variant created + AC LF) and the
15
+ WARM call (variant cached + LF status cached), which is what the
16
+ user experiences when re-visiting the same contingency.
17
+
18
+ Usage:
19
+
20
+ python benchmarks/bench_n1_diagram.py # default ARGIAL71CANTE
21
+ BENCH_CONTINGENCY='DISCO_X' python bench_n1_diagram.py
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import time
27
+
28
+ from _bench_common import NETWORK_PATH, print_step_summary, setup_service, timed
29
+
30
+ CONTINGENCY = os.environ.get("BENCH_CONTINGENCY", "ARGIAL71CANTE")
31
+
32
+
33
+ def _instrument(recommender_service):
34
+ """Wrap each hot method with the `timed` helper.
35
+
36
+ Returns `(steps_dict, restore_fn)`. The caller should invoke
37
+ `restore_fn()` after benchmarking so the service goes back to
38
+ normal behaviour.
39
+ """
40
+ from expert_backend.services import diagram_mixin as _dm
41
+
42
+ steps: dict = {}
43
+
44
+ originals = {
45
+ "_generate_diagram": (_dm.DiagramMixin, "_generate_diagram"),
46
+ "_run_ac_with_fallback": (recommender_service, "_run_ac_with_fallback"),
47
+ "_get_n1_variant": (recommender_service, "_get_n1_variant"),
48
+ "_get_network_flows": (_dm.DiagramMixin, "_get_network_flows"),
49
+ "_get_asset_flows": (_dm.DiagramMixin, "_get_asset_flows"),
50
+ "_compute_deltas": (_dm.DiagramMixin, "_compute_deltas"),
51
+ "_compute_asset_deltas": (_dm.DiagramMixin, "_compute_asset_deltas"),
52
+ "_get_overloaded_lines": (_dm.DiagramMixin, "_get_overloaded_lines"),
53
+ }
54
+
55
+ backup = {}
56
+ for label, (obj, attr) in originals.items():
57
+ orig = getattr(obj, attr)
58
+ backup[label] = (obj, attr, orig)
59
+ setattr(obj, attr, timed(label, orig, steps))
60
+
61
+ def restore() -> None:
62
+ for _label, (obj, attr, orig) in backup.items():
63
+ setattr(obj, attr, orig)
64
+
65
+ return steps, restore
66
+
67
+
68
+ def main() -> None:
69
+ print(f"Network: {NETWORK_PATH}")
70
+ print(f"Contingency: {CONTINGENCY}")
71
+
72
+ _ns, recommender_service, dt_setup = setup_service()
73
+ print(f"Setup done in {dt_setup:.0f} ms\n")
74
+
75
+ steps, restore = _instrument(recommender_service)
76
+
77
+ try:
78
+ # --- COLD call ---
79
+ print(f"=== Call 1 (COLD: creates N-1 variant + LF status) ===")
80
+ t0 = time.perf_counter()
81
+ d1 = recommender_service.get_n1_diagram(CONTINGENCY)
82
+ dt_cold = (time.perf_counter() - t0) * 1000
83
+ print(f"Total: {dt_cold:.1f} ms")
84
+ print(f" SVG size: {len(d1['svg']):,} bytes")
85
+ print(f" overloaded lines: {len(d1['lines_overloaded'])}")
86
+ print(f" flow_deltas: {len(d1['flow_deltas'])}")
87
+ print(f" asset_deltas: {len(d1['asset_deltas'])}")
88
+ print_step_summary(steps, "COLD call step timings")
89
+
90
+ # Reset counters for warm call
91
+ for key in steps:
92
+ steps[key] = []
93
+
94
+ # --- WARM call (variant + LF status both cached) ---
95
+ print(f"\n=== Call 2 (WARM: variant cached + LF status cached) ===")
96
+ t0 = time.perf_counter()
97
+ d2 = recommender_service.get_n1_diagram(CONTINGENCY)
98
+ dt_warm = (time.perf_counter() - t0) * 1000
99
+ print(f"Total: {dt_warm:.1f} ms")
100
+ print_step_summary(steps, "WARM call step timings")
101
+
102
+ print(f"\n=== Summary ===")
103
+ print(f" COLD: {dt_cold/1000:.2f} s")
104
+ print(f" WARM: {dt_warm/1000:.2f} s (Δ = {(dt_cold-dt_warm)/1000:+.2f} s)")
105
+ finally:
106
+ restore()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()
benchmarks/bench_n1_diagram_patch.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Profile the N-1 patch endpoint (SVG-less payload) vs the full-SVG
6
+ endpoint, end-to-end on the reference bare_env_20240828T0100Z grid.
7
+
8
+ The patch endpoint skips pypowsybl's `get_network_area_diagram` call
9
+ and the ~12 MB SVG serialisation entirely. The frontend then clones
10
+ the already-loaded N-state SVG DOM and applies the patch in-place,
11
+ avoiding the 20-28 MB transfer and re-parse on each contingency
12
+ selection.
13
+
14
+ See `docs/performance/history/svg-dom-recycling.md` for the wider
15
+ context and payload schema; this script captures the backend cost
16
+ delta alone (wire + client parse are measured in the frontend).
17
+
18
+ Usage:
19
+ python benchmarks/bench_n1_diagram_patch.py # default contingency
20
+ BENCH_CONTINGENCY='DISCO_X' python bench_n1_diagram_patch.py
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import time
27
+
28
+ from _bench_common import NETWORK_PATH, setup_service
29
+
30
+ CONTINGENCY = os.environ.get("BENCH_CONTINGENCY", "ARGIAL71CANTE")
31
+ RESULTS_FILE = os.environ.get(
32
+ "BENCH_RESULTS_FILE",
33
+ os.path.join(
34
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
35
+ "profiling_patch_results.json",
36
+ ),
37
+ )
38
+
39
+
40
+ def _payload_bytes(payload: dict) -> int:
41
+ """Rough on-wire size of a JSON payload, pre-gzip."""
42
+ return len(json.dumps(payload, default=str).encode("utf-8"))
43
+
44
+
45
+ def _measure(fn, reps: int = 3) -> tuple[float, list[float]]:
46
+ """Run `fn` `reps` times, return (median_ms, all_dts_ms)."""
47
+ dts: list[float] = []
48
+ for _ in range(reps):
49
+ t0 = time.perf_counter()
50
+ fn()
51
+ dts.append((time.perf_counter() - t0) * 1000)
52
+ dts_sorted = sorted(dts)
53
+ return dts_sorted[len(dts_sorted) // 2], dts
54
+
55
+
56
+ def main() -> None:
57
+ print(f"Network: {NETWORK_PATH}")
58
+ print(f"Contingency: {CONTINGENCY}")
59
+
60
+ _ns, recommender_service, dt_setup = setup_service()
61
+ print(f"Setup done in {dt_setup:.0f} ms\n")
62
+
63
+ # --- COLD full fetch (sets up N-1 variant, AC LF, overload cache) ---
64
+ print("=== Full /api/n1-diagram (COLD) ===")
65
+ t0 = time.perf_counter()
66
+ full_cold = recommender_service.get_n1_diagram(CONTINGENCY)
67
+ full_cold_ms = (time.perf_counter() - t0) * 1000
68
+ full_cold_size = _payload_bytes(full_cold)
69
+ full_cold_svg_mb = len(full_cold["svg"]) / 1_000_000
70
+ print(f" total: {full_cold_ms:>8.1f} ms")
71
+ print(f" full payload size: {full_cold_size / 1_000_000:>8.2f} MB")
72
+ print(f" SVG size: {full_cold_svg_mb:>8.2f} MB")
73
+ print(f" flow_deltas: {len(full_cold.get('flow_deltas', {}))}")
74
+
75
+ # --- WARM full fetch (variant + LF cached) — median of 3 ---
76
+ print("\n=== Full /api/n1-diagram (WARM median of 3) ===")
77
+ full_warm_ms, full_warm_all = _measure(
78
+ lambda: recommender_service.get_n1_diagram(CONTINGENCY), reps=3
79
+ )
80
+ print(f" median: {full_warm_ms:>8.1f} ms")
81
+ print(f" all runs: {[f'{d:.0f}' for d in full_warm_all]}")
82
+
83
+ # --- COLD patch (expected to reuse LF cache already populated by
84
+ # the full-fetch call above; still avoids the ~2-4 s
85
+ # `_generate_diagram` NAD call + ~12 MB SVG serialisation) ---
86
+ print("\n=== Patch /api/n1-diagram-patch (COLD) ===")
87
+ t0 = time.perf_counter()
88
+ patch_cold = recommender_service.get_n1_diagram_patch(CONTINGENCY)
89
+ patch_cold_ms = (time.perf_counter() - t0) * 1000
90
+ patch_cold_size = _payload_bytes(patch_cold)
91
+ print(f" total: {patch_cold_ms:>8.1f} ms")
92
+ print(f" patch payload: {patch_cold_size / 1_000_000:>8.3f} MB")
93
+ print(f" patchable: {patch_cold['patchable']}")
94
+ print(f" flow_deltas: {len(patch_cold.get('flow_deltas', {}))}")
95
+ print(f" absolute_flows: {sum(len(patch_cold.get('absolute_flows', {}).get(k, {})) for k in ('p1','p2','q1','q2'))}")
96
+
97
+ # --- WARM patch — median of 3 ---
98
+ print("\n=== Patch /api/n1-diagram-patch (WARM median of 3) ===")
99
+ patch_warm_ms, patch_warm_all = _measure(
100
+ lambda: recommender_service.get_n1_diagram_patch(CONTINGENCY), reps=3
101
+ )
102
+ print(f" median: {patch_warm_ms:>8.1f} ms")
103
+ print(f" all runs: {[f'{d:.0f}' for d in patch_warm_all]}")
104
+
105
+ # --- Summary ---
106
+ print(f"\n=== Summary (lower is better) ===")
107
+ print(f" FULL cold: {full_cold_ms/1000:>5.2f} s warm: {full_warm_ms/1000:>5.2f} s")
108
+ print(f" PATCH cold: {patch_cold_ms/1000:>5.2f} s warm: {patch_warm_ms/1000:>5.2f} s")
109
+ warm_savings = full_warm_ms - patch_warm_ms
110
+ cold_savings = full_cold_ms - patch_cold_ms
111
+ print(f" Δ cold: -{cold_savings/1000:>5.2f} s "
112
+ f"({-100 * cold_savings / full_cold_ms:>5.1f}%)")
113
+ print(f" Δ warm: -{warm_savings/1000:>5.2f} s "
114
+ f"({-100 * warm_savings / full_warm_ms:>5.1f}%)")
115
+ print(f" Payload reduction: "
116
+ f"{(full_warm_all[0] and full_cold_size) and full_cold_size / 1_000_000:>5.2f} MB → "
117
+ f"{patch_cold_size / 1_000_000:>5.3f} MB "
118
+ f"({100 * patch_cold_size / max(full_cold_size, 1):.1f}% of full)")
119
+
120
+ # --- Persist to JSON for the perf retrospective doc ---
121
+ out = {
122
+ "network_path": NETWORK_PATH,
123
+ "contingency": CONTINGENCY,
124
+ "full": {
125
+ "cold_ms": full_cold_ms,
126
+ "warm_ms_median": full_warm_ms,
127
+ "warm_ms_all": full_warm_all,
128
+ "payload_bytes": full_cold_size,
129
+ "svg_mb": full_cold_svg_mb,
130
+ },
131
+ "patch": {
132
+ "cold_ms": patch_cold_ms,
133
+ "warm_ms_median": patch_warm_ms,
134
+ "warm_ms_all": patch_warm_all,
135
+ "payload_bytes": patch_cold_size,
136
+ "patchable": patch_cold["patchable"],
137
+ },
138
+ "savings": {
139
+ "cold_ms": cold_savings,
140
+ "cold_pct": 100 * cold_savings / max(full_cold_ms, 1),
141
+ "warm_ms": warm_savings,
142
+ "warm_pct": 100 * warm_savings / max(full_warm_ms, 1),
143
+ "payload_ratio_pct": 100 * patch_cold_size / max(full_cold_size, 1),
144
+ },
145
+ }
146
+ with open(RESULTS_FILE, "w", encoding="utf-8") as f:
147
+ json.dump(out, f, indent=2)
148
+ print(f"\n JSON: {RESULTS_FILE}")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
benchmarks/bench_nad_n_state.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Profile N-state NAD generation times (Load Study base diagram).
6
+
7
+ Runs 3 iterations in a single Python process against the reference
8
+ grid defined by ``BENCH_NETWORK_PATH``. Iteration 1 is cold (fresh
9
+ JVM / pypowsybl caches), iterations 2-3 are warm.
10
+
11
+ Wraps ``recommender_service.get_network_diagram()`` with
12
+ ``time.perf_counter()`` and captures the three sub-timings already
13
+ emitted by ``diagram_mixin._generate_diagram`` via a logging.Handler
14
+ parsing ``[RECO] Diagram generated: NAD Xs, SVG Ys, Meta Zs …``.
15
+
16
+ Outputs ``profiling_results.json`` at the project root + a stdout
17
+ summary. See ``docs/performance/nad-profile-bare-env.md``.
18
+
19
+ Usage:
20
+
21
+ BENCH_NETWORK_PATH=/path/to/grid_dir \
22
+ python benchmarks/bench_nad_n_state.py
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import re
29
+ import time
30
+ from pathlib import Path
31
+ from statistics import median
32
+
33
+ from _bench_common import NETWORK_PATH, setup_service
34
+
35
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
36
+
37
+ _DIAG_LINE_RE = re.compile(
38
+ r"\[RECO\] Diagram generated: NAD ([\d.]+)s, SVG ([\d.]+)s, Meta ([\d.]+)s "
39
+ r"\(SVG length=(\d+)\)"
40
+ )
41
+
42
+
43
+ class _DiagramTimingCapture(logging.Handler):
44
+ """Capture the latest ``[RECO] Diagram generated: …`` log line."""
45
+
46
+ def __init__(self) -> None:
47
+ super().__init__(level=logging.INFO)
48
+ self.last_match: dict | None = None
49
+
50
+ def emit(self, record: logging.LogRecord) -> None:
51
+ m = _DIAG_LINE_RE.search(record.getMessage())
52
+ if m:
53
+ self.last_match = {
54
+ "nad_s": float(m.group(1)),
55
+ "svg_s": float(m.group(2)),
56
+ "meta_s": float(m.group(3)),
57
+ "svg_len": int(m.group(4)),
58
+ }
59
+
60
+
61
+ def _run(iteration: int, cold: bool, capture: _DiagramTimingCapture) -> dict:
62
+ capture.last_match = None
63
+ _, recommender_service, _ = setup_service(wait_for_nad_prefetch=False)
64
+
65
+ t0 = time.perf_counter()
66
+ res = recommender_service.get_network_diagram()
67
+ total = time.perf_counter() - t0
68
+
69
+ if capture.last_match is None:
70
+ raise RuntimeError(
71
+ "Did not capture `[RECO] Diagram generated` log line. "
72
+ "Is diagram_mixin.py still emitting it at INFO level?"
73
+ )
74
+
75
+ svg_bytes = len(res["svg"])
76
+ row = {
77
+ "run": iteration,
78
+ "cold": cold,
79
+ "total_s": round(total, 3),
80
+ "nad_s": capture.last_match["nad_s"],
81
+ "svg_s": capture.last_match["svg_s"],
82
+ "meta_s": capture.last_match["meta_s"],
83
+ "svg_bytes": svg_bytes,
84
+ "svg_mb": round(svg_bytes / (1024 * 1024), 2),
85
+ }
86
+ print(
87
+ f" run {iteration} ({'cold' if cold else 'warm'}): "
88
+ f"total={row['total_s']}s NAD={row['nad_s']}s "
89
+ f"SVG={row['svg_s']}s Meta={row['meta_s']}s "
90
+ f"size={row['svg_mb']} MB"
91
+ )
92
+ return row
93
+
94
+
95
+ def main(iterations: int = 3) -> None:
96
+ logging.basicConfig(
97
+ level=logging.INFO,
98
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
99
+ )
100
+ capture = _DiagramTimingCapture()
101
+ logging.getLogger().addHandler(capture)
102
+
103
+ print(f"Network: {NETWORK_PATH}")
104
+ print(f"Iterations: {iterations}\n")
105
+
106
+ rows = [
107
+ _run(i, cold=(i == 1), capture=capture)
108
+ for i in range(1, iterations + 1)
109
+ ]
110
+
111
+ warm = [r for r in rows if not r["cold"]]
112
+ warm_median = (
113
+ {
114
+ "nad_s": round(median(r["nad_s"] for r in warm), 3),
115
+ "svg_s": round(median(r["svg_s"] for r in warm), 3),
116
+ "meta_s": round(median(r["meta_s"] for r in warm), 3),
117
+ "total_s": round(median(r["total_s"] for r in warm), 3),
118
+ }
119
+ if warm
120
+ else None
121
+ )
122
+
123
+ out_file = _REPO_ROOT / "profiling_results.json"
124
+ out_file.write_text(
125
+ json.dumps(
126
+ {"network_path": NETWORK_PATH, "runs": rows, "warm_median": warm_median},
127
+ indent=2,
128
+ )
129
+ )
130
+ print(f"\nSaved: {out_file}")
131
+ if warm_median:
132
+ print(
133
+ f"Warm median: NAD={warm_median['nad_s']}s "
134
+ f"SVG={warm_median['svg_s']}s Meta={warm_median['meta_s']}s "
135
+ f"total={warm_median['total_s']}s"
136
+ )
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
benchmarks/bench_nad_toggles.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Benchmark a matrix of ``NadParameters`` toggle combinations.
6
+
7
+ Drives ``recommender_service.get_network_diagram()`` with several
8
+ ``NadParameters`` overrides (applied via monkey-patching
9
+ ``DiagramMixin._default_nad_parameters``) to quantify the per-toggle
10
+ impact on NAD generation time and SVG size.
11
+
12
+ The matrix below validates the "minimal-render" choices documented in
13
+ ``docs/performance/nad-profile-bare-env.md`` (section "Results — after #6")
14
+ and surfaces the cost of ``injections_added=True`` so the trade-off
15
+ can be revisited later.
16
+
17
+ Outputs ``profiling_toggles_results.json`` at the project root +
18
+ a comparison table on stdout.
19
+
20
+ Usage:
21
+
22
+ BENCH_NETWORK_PATH=/path/to/grid_dir \
23
+ python benchmarks/bench_nad_toggles.py
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import re
30
+ import time
31
+ from pathlib import Path
32
+ from statistics import median
33
+
34
+ from _bench_common import NETWORK_PATH, setup_service
35
+
36
+ from expert_backend.services.diagram_mixin import DiagramMixin
37
+ from pypowsybl.network import NadLayoutType, NadParameters
38
+
39
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
40
+
41
+ _DIAG_LINE_RE = re.compile(
42
+ r"\[RECO\] Diagram generated: NAD ([\d.]+)s, SVG ([\d.]+)s, Meta ([\d.]+)s "
43
+ r"\(SVG length=(\d+)\)"
44
+ )
45
+
46
+ # Mirror the actual production defaults in
47
+ # `DiagramMixin._default_nad_parameters` so any override below is a
48
+ # pure diff. Keep this dict in sync if that method changes.
49
+ _PROD_KWARGS = dict(
50
+ edge_name_displayed=False,
51
+ id_displayed=False,
52
+ edge_info_along_edge=True,
53
+ power_value_precision=0,
54
+ angle_value_precision=0,
55
+ current_value_precision=1,
56
+ voltage_value_precision=0,
57
+ bus_legend=False,
58
+ substation_description_displayed=False,
59
+ voltage_level_details=False,
60
+ injections_added=False,
61
+ layout_type=NadLayoutType.GEOGRAPHICAL,
62
+ )
63
+
64
+
65
+ class _DiagramTimingCapture(logging.Handler):
66
+ def __init__(self) -> None:
67
+ super().__init__(level=logging.INFO)
68
+ self.last_match: dict | None = None
69
+
70
+ def emit(self, record: logging.LogRecord) -> None:
71
+ m = _DIAG_LINE_RE.search(record.getMessage())
72
+ if m:
73
+ self.last_match = {
74
+ "nad_s": float(m.group(1)),
75
+ "svg_s": float(m.group(2)),
76
+ "meta_s": float(m.group(3)),
77
+ "svg_len": int(m.group(4)),
78
+ }
79
+
80
+
81
+ def _make_factory(overrides: dict):
82
+ """Return a bound-method replacement for `_default_nad_parameters`."""
83
+ kwargs = dict(_PROD_KWARGS)
84
+ kwargs.update(overrides)
85
+
86
+ def _override(_self):
87
+ return NadParameters(**kwargs)
88
+
89
+ return _override
90
+
91
+
92
+ def _run_config(name, overrides, capture, iterations=3):
93
+ DiagramMixin._default_nad_parameters = _make_factory(overrides)
94
+
95
+ print(f"\n>>> Config: {name}")
96
+ print(f" overrides: {overrides or '(prod defaults)'}")
97
+
98
+ rows = []
99
+ for i in range(1, iterations + 1):
100
+ capture.last_match = None
101
+ _, recommender_service, _ = setup_service(wait_for_nad_prefetch=False)
102
+
103
+ t0 = time.perf_counter()
104
+ res = recommender_service.get_network_diagram()
105
+ total = time.perf_counter() - t0
106
+
107
+ if capture.last_match is None:
108
+ raise RuntimeError("Did not capture [RECO] Diagram generated line")
109
+
110
+ svg_bytes = len(res["svg"])
111
+ rows.append({
112
+ "run": i,
113
+ "total_s": round(total, 3),
114
+ "nad_s": capture.last_match["nad_s"],
115
+ "svg_bytes": svg_bytes,
116
+ "svg_mb": round(svg_bytes / (1024 * 1024), 2),
117
+ })
118
+ print(
119
+ f" run {i}: total={rows[-1]['total_s']}s "
120
+ f"NAD={rows[-1]['nad_s']}s size={rows[-1]['svg_mb']} MB"
121
+ )
122
+
123
+ # Warm median excludes run 1 (JVM/pypowsybl warm-up absorbed there).
124
+ warm = rows[1:]
125
+ return {
126
+ "config": name,
127
+ "overrides": overrides,
128
+ "runs": rows,
129
+ "warm_median": {
130
+ "nad_s": round(median(r["nad_s"] for r in warm), 3),
131
+ "total_s": round(median(r["total_s"] for r in warm), 3),
132
+ "svg_mb": round(median(r["svg_mb"] for r in warm), 2),
133
+ },
134
+ }
135
+
136
+
137
+ def main(iterations: int = 3) -> None:
138
+ logging.basicConfig(
139
+ level=logging.INFO,
140
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
141
+ )
142
+ capture = _DiagramTimingCapture()
143
+ logging.getLogger().addHandler(capture)
144
+
145
+ # Revert each toggle individually from the production minimal-render
146
+ # defaults to quantify its contribution. Plus two "regress" rows for
147
+ # `injections_added=True` which was measured and *rejected*.
148
+ configs = [
149
+ ("prod defaults (minimal-render)", {}),
150
+ ("+ bus_legend=True (pre-#6 default)", {"bus_legend": True}),
151
+ (
152
+ "+ substation_description_displayed=True (pre-#6 default)",
153
+ {"substation_description_displayed": True},
154
+ ),
155
+ ("+ voltage_level_details=True (pypowsybl default)", {"voltage_level_details": True}),
156
+ ("+ injections_added=True (NOT APPLIED, cost check)", {"injections_added": True}),
157
+ ]
158
+
159
+ print(f"Network: {NETWORK_PATH}")
160
+ results = [_run_config(n, o, capture, iterations) for n, o in configs]
161
+
162
+ out_file = _REPO_ROOT / "profiling_toggles_results.json"
163
+ out_file.write_text(
164
+ json.dumps({"network_path": NETWORK_PATH, "configs": results}, indent=2)
165
+ )
166
+
167
+ print("\n" + "=" * 100)
168
+ print(f"{'Config':<65} | {'NAD(s)':>8} | {'Total(s)':>9} | {'SVG(MB)':>8}")
169
+ print("-" * 100)
170
+ baseline = results[0]["warm_median"]
171
+ for res in results:
172
+ wm = res["warm_median"]
173
+ suffix = ""
174
+ if res["config"] != "prod defaults (minimal-render)":
175
+ suffix = (
176
+ f" Δ nad={wm['nad_s'] - baseline['nad_s']:+.2f} "
177
+ f"total={wm['total_s'] - baseline['total_s']:+.2f} "
178
+ f"mb={wm['svg_mb'] - baseline['svg_mb']:+.1f}"
179
+ )
180
+ print(
181
+ f"{res['config']:<65} | {wm['nad_s']:>8.3f} | "
182
+ f"{wm['total_s']:>9.3f} | {wm['svg_mb']:>8.2f}{suffix}"
183
+ )
184
+ print("=" * 100)
185
+ print(f"\nSaved: {out_file}")
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
benchmarks/bench_topology_cache.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Breakdown of `NetworkTopologyCache(n)` initialisation.
6
+
7
+ Validates the cumulative gains of the upstream vectorisation series
8
+ (`expert_op4grid_recommender` 0.2.0.post3 → post8) documented in
9
+ `docs/performance/history/vectorize-topology-cache.md` and
10
+ `docs/performance/history/topology-cache-iter2.md`. Also exercises the narrow
11
+ `_get_switches_with_topology` / `_get_branch_with_bus_breaker_info`
12
+ variants (post6 / post8).
13
+
14
+ Usage:
15
+
16
+ BENCH_NETWORK_PATH=/path/to/grid_dir python benchmarks/bench_topology_cache.py
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import pypowsybl as pp
21
+ import pypowsybl.network as pn
22
+
23
+ from _bench_common import NETWORK_PATH, bench
24
+
25
+
26
+ def main() -> None:
27
+ grid = f"{NETWORK_PATH}/grid.xiidm"
28
+ print(f"pypowsybl {pp.__version__}")
29
+ print(f"Grid: {grid}")
30
+ net = pn.load(grid)
31
+
32
+ from expert_op4grid_recommender.utils.conversion_actions_repas import (
33
+ NetworkTopologyCache,
34
+ _is_node_breaker_network,
35
+ _get_switches_with_topology,
36
+ _get_injection_with_bus_breaker_info,
37
+ _get_branch_with_bus_breaker_info,
38
+ )
39
+
40
+ is_nb = _is_node_breaker_network(net)
41
+ print(f"node_breaker: {is_nb}")
42
+
43
+ print("\n=== Individual helpers ===")
44
+ bench(
45
+ "_get_switches_with_topology",
46
+ lambda: _get_switches_with_topology(net, node_breaker=is_nb),
47
+ )
48
+ bench(
49
+ "_get_injection_with_bus_breaker_info (loads)",
50
+ lambda: _get_injection_with_bus_breaker_info(net, "get_loads", node_breaker=is_nb),
51
+ )
52
+ bench(
53
+ "_get_injection_with_bus_breaker_info (gens)",
54
+ lambda: _get_injection_with_bus_breaker_info(net, "get_generators", node_breaker=is_nb),
55
+ )
56
+ bench(
57
+ "_get_branch_with_bus_breaker_info (branches)",
58
+ lambda: _get_branch_with_bus_breaker_info(net, "get_branches", node_breaker=is_nb),
59
+ )
60
+ bench(
61
+ "_get_branch_with_bus_breaker_info (lines)",
62
+ lambda: _get_branch_with_bus_breaker_info(net, "get_lines", node_breaker=is_nb),
63
+ )
64
+
65
+ print("\n=== Full NetworkTopologyCache(net) init ===")
66
+ bench("NetworkTopologyCache(net)", lambda: NetworkTopologyCache(net), reps=3)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
benchmarks/bench_voltage_level_queries.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Benchmark `/api/voltage-levels`, `/api/nominal-voltages` and the
6
+ operational-limits query used by `/api/branches::get_monitored_elements`.
7
+
8
+ Documents the narrow-query + vectorisation gains from
9
+ `docs/performance/history/narrow-voltage-level-queries.md`:
10
+
11
+ - `/api/voltage-levels` 7.5 ms → 4.5 ms (attributes=[])
12
+ - `/api/nominal-voltages` 144 ms → 5.7 ms (narrow + no iterrows, ~25×)
13
+ - get_monitored_elements 265 ms → 175 ms (attributes=[])
14
+ - _get_switches_with_topology 174 ms → 141 ms (drop `kind` attr)
15
+
16
+ Usage:
17
+
18
+ BENCH_NETWORK_PATH=/path/to/grid_dir python benchmarks/bench_voltage_level_queries.py
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import pypowsybl.network as pn
23
+
24
+ from _bench_common import NETWORK_PATH, bench
25
+
26
+
27
+ def main() -> None:
28
+ from expert_backend.services.network_service import NetworkService
29
+
30
+ grid = f"{NETWORK_PATH}/grid.xiidm"
31
+ net = pn.load(grid)
32
+ svc = NetworkService()
33
+ svc.network = net
34
+
35
+ print(f"Grid: {grid}")
36
+
37
+ print("\n=== /api/voltage-levels ===")
38
+ bench("default get_voltage_levels()", lambda: net.get_voltage_levels())
39
+ bench("narrow get_voltage_levels(attributes=[])", lambda: net.get_voltage_levels(attributes=[]))
40
+ vl_list = bench("NetworkService.get_voltage_levels", svc.get_voltage_levels)
41
+ print(f" returned {len(vl_list)} VLs")
42
+
43
+ print("\n=== /api/nominal-voltages ===")
44
+ nv = bench("NetworkService.get_nominal_voltages", svc.get_nominal_voltages)
45
+ print(f" returned {len(nv)} mappings, unique kV: {sorted(set(nv.values()))}")
46
+
47
+ print("\n=== /api/branches::get_monitored_elements ===")
48
+ bench(
49
+ "default get_operational_limits()",
50
+ lambda: net.get_operational_limits(),
51
+ )
52
+ bench(
53
+ "narrow get_operational_limits(attributes=[])",
54
+ lambda: net.get_operational_limits(attributes=[]),
55
+ )
56
+ mon = bench("NetworkService.get_monitored_elements", svc.get_monitored_elements)
57
+ print(f" returned {len(mon)} monitored elements")
58
+
59
+ print("\n=== _get_switches_with_topology ===")
60
+ from expert_op4grid_recommender.utils.conversion_actions_repas import (
61
+ _get_switches_with_topology,
62
+ _is_node_breaker_network,
63
+ )
64
+ is_nb = _is_node_breaker_network(net)
65
+ sw = bench(
66
+ "_get_switches_with_topology (post8 narrow, no `kind`)",
67
+ lambda: _get_switches_with_topology(net, node_breaker=is_nb),
68
+ )
69
+ print(f" shape={sw.shape} cols={list(sw.columns)}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
benchmarks/interaction_paint/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Generated benchmark artifacts
2
+ nad.svg
3
+ nad.stats.json
4
+ result.json
5
+ # Real pypowsybl NAD fetched from the backend (9 MB; regenerate via
6
+ # GET /api/network-diagram?format=text) — don't track the heavy artifact.
7
+ real_nad.svg
8
+ real_nad.html
benchmarks/interaction_paint/FLUIDITY_FINDINGS.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pan/zoom fluidity — empirical evaluation of the "Smooth pan/zoom (GPU)" toggle
2
+
3
+ Grid: **pypsa_eur_eur220_225_380_400** (5247 VL / 8205 branches; real pypowsybl NAD
4
+ 9.1 MB, ~99k–104k DOM nodes). Hardware: **Apple M4 Pro (Metal), Chrome 149, 120 Hz, dpr=1**.
5
+ Metric: **median rAF frame interval during a sustained 2.5 s gesture** (lower = smoother;
6
+ 8.3 ms = 120 fps = idle ceiling). 100% "dropped" = never reaches the 120 Hz budget.
7
+
8
+ Harness: `bench_fluidity.html` driven over CDP (`cdp_driver.mjs`) in a Chrome launched
9
+ with anti-throttle flags so an occluded window still renders at full speed. The real-app
10
+ end-to-end numbers come from driving the actual `usePanZoom` handlers + the real Settings
11
+ toggle (`app_toggle_driver.mjs`, `app_n1_driver.mjs`).
12
+
13
+ ## Results
14
+
15
+ | Measurement | gesture | plain (no cull) | **cull = toggle OFF** | **gpu transform = toggle ON** | toggle gain |
16
+ |---|---|---|---|---|---|
17
+ | Synthetic NAD (isolated) | pan | 91.7 | 58.3 | 49.8 | 1.17× |
18
+ | Synthetic NAD (isolated) | zoom | 108.6 | 65.0 | 49.6 | 1.31× |
19
+ | **Real pypowsybl NAD (isolated)** | pan | 100.0 | 58.0 | 41.9 | 1.38× |
20
+ | **Real pypowsybl NAD (isolated)** | zoom | 126.7 (p95 1008!) | 65.1 | 43.3 | 1.50× |
21
+ | **Real app, N tab (live usePanZoom)** | pan | — | 60.1 | 50.0 | 1.20× |
22
+ | **Real app, N tab (live usePanZoom)** | zoom | — | 75.0 | 50.1 | 1.50× |
23
+ | **Real app, N-1/Contingency tab** | pan | — | 82.6 | 58.4 | 1.41× |
24
+ | **Real app, N-1/Contingency tab** | zoom | — | 83.9 | 66.7 | 1.26× |
25
+
26
+ (N-1/action share the identical render path — `MemoizedSvgContainer` + `usePanZoom` +
27
+ `.svg-interacting`; the action tab differs only by a few highlight elements, so its
28
+ fluidity equals N/N-1.)
29
+
30
+ ### Verdict on the toggle
31
+ - The toggle delivers a **real but modest ~1.2–1.5×** over the default. All four arms still
32
+ drop **100% of frames** — even ON, pan/zoom sit at **~15–24 fps**, never near 60/120.
33
+ - **Why it's small (proven):** a pure ±2 px CSS translate of the `will-change`'d `<svg>` layer
34
+ is *still* ~42–48 ms/frame (`micro_translate`). Chrome **re-rasterizes the ~100k-node vector
35
+ SVG on every transform** → `will-change:transform` never yields a reusable GPU texture, so
36
+ the gesture only saves the viewBox-attribute recompute + non-scaling-stroke re-eval.
37
+
38
+ ## What WOULD make it fluid (proven)
39
+ **Rasterize the NAD to a `<canvas>` once at gesture start, transform the bitmap per frame,
40
+ bake back to viewBox on settle:** **8.3 ms = 120 fps, 0 dropped frames** — on both the
41
+ synthetic and the real NAD. That is **~6× over the toggle** and **~7–8× over the default**,
42
+ and it composites cheaply even on software/VDI (the case the GPU toggle deliberately skips).
43
+
44
+ ### The catch (verified in code, Phase C)
45
+ The bitmap is rasterized via `new Image()`, which renders the SVG in isolation → **App.css
46
+ class-based styling is dropped**. On the **N-1 / action** tabs that means overload halos,
47
+ the contingency glow, and flow-delta colors **vanish** (they come from
48
+ `.nad-overloaded`/`.nad-contingency-highlight`/`.nad-action-target`/`.nad-delta-*` rules,
49
+ not inline attributes — `highlights.ts`). The N tab is bare, which is exactly why the
50
+ 8.3 ms held there. → A production bitmap mode must **inline the highlight/delta computed
51
+ styles into the clone** before serializing, and fix cursor-anchored wheel-zoom (the
52
+ `getScreenCTM()` source moves from the live SVG to the canvas).
53
+
54
+ ## Recommendations (ranked)
55
+ 1. **(big bet) Bitmap-snapshot mode** as a 3rd `usePanZoom` mode (`'off' | 'gpu' | 'bitmap'`),
56
+ opt-in/default-OFF. Prereqs: inline halo/delta styles into the clone (fixes N-1/action
57
+ fidelity), strip `<foreignObject>` (canvas taint — bench already does), dpr-scale the
58
+ canvas, fix wheel-zoom CTM. Effort L–XL. Reference impl: `bench_fluidity.html`
59
+ `snapshotCanvas`/`runBitmap`.
60
+ 2. **(quick win — REJECTED after measurement) Drop `vector-effect:non-scaling-stroke` during
61
+ `.svg-interacting`.** Phase C measured ~1.15× on the *synthetic* NAD, but a direct A/B on the
62
+ **real** pypowsybl NAD (`__runNss` arm, vector-effect verified flipping `none`↔`non-scaling-stroke`)
63
+ gave **1.00× — zero gain — on both GPU and software rendering** (`--disable-gpu`): pan 50/50 ms,
64
+ zoom 58.3/58.3 ms. The real bottleneck is pure SVG raster; the stroke recompute is negligible.
65
+ **Not shipped** — ~60 lines of CSS + a halo/delta protection block for a measured no-op. The
66
+ synthetic 1.15× did not transfer. [IMPLEMENTED then reverted.]
67
+ 3. **(SHIPPED) `commitViewBox` equality guard** (`usePanZoom.ts`) — free, avoids a settle-frame
68
+ React re-render when the viewBox nets back unchanged (the ~100k nodes are outside React's vdom
69
+ via `React.memo`+`replaceChildren`, so it's a tiny App-level saving, not a fps mover).
70
+ 4. **(SHIPPED, hygiene) Clear `will-change` promptly** on settle in `endInteraction`
71
+ (smooth-mode only); does NOT shorten the 150 ms wheel debounce. Drops a promoted multi-MB
72
+ compositor layer between gestures. 0 fps, pure hygiene.
73
+ 5. **Reject:** geometry-count culling (≤1.15×, some regress; `content-visibility:auto` is
74
+ 0.89×; `.nad-edge` selectors don't even match the real grid) and a full canvas/WebGL
75
+ rewrite (same 8.3 ms ceiling at XL effort + breaks svgPatch/inspect/SLD/highlights).
benchmarks/interaction_paint/README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interaction paint benchmark (pan/zoom fluidity)
2
+
3
+ A browser-driven micro-benchmark for the **frontend** pan/zoom paint cost
4
+ on a large Network Area Diagram. Unlike the Python benchmarks in the parent
5
+ directory (which measure the backend critical path), this one measures what
6
+ the browser actually repaints per frame while the operator pans/zooms.
7
+
8
+ It needs **no pypowsybl backend**: `generate_nad.mjs` synthesises a
9
+ structurally-faithful NAD straight from a grid's real `grid_layout.json`
10
+ (voltage-level coordinates), reproducing the paint workload — polyline
11
+ strokes, bus circles, edge-info flow `<text>` + arrows, and the expensive
12
+ HTML `<foreignObject>` voltage-level labels — at the true scale of the grid.
13
+
14
+ ## What it shows
15
+
16
+ The cost (per painted frame) of a viewBox pan/zoom **with vs without** the
17
+ interaction-time culling rule (`.svg-interacting` in
18
+ `frontend/src/App.css`). See
19
+ [`docs/performance/history/interaction-paint-culling.md`](../../docs/performance/history/interaction-paint-culling.md)
20
+ for the analysis and headline numbers.
21
+
22
+ ## Requirements
23
+
24
+ - Node ≥ 18.
25
+ - A local Playwright install reachable from `frontend/`
26
+ (`cd frontend && npm i -D playwright`).
27
+ - A Chromium/Chrome binary. The benchmark defaults to the Playwright build
28
+ at `/opt/pw-browsers/chromium-1194/chrome-linux/chrome`; override with
29
+ `PW_CHROME=/path/to/chrome`.
30
+
31
+ > Not wired into CI: the Playwright browser-download host is outside the
32
+ > CI network egress allowlist, so the browser must already be present.
33
+
34
+ ## Run
35
+
36
+ ```bash
37
+ cd benchmarks/interaction_paint
38
+
39
+ # 1. Build the synthetic NAD from a committed grid layout
40
+ # (default grid: pypsa_eur_eur220_225_380_400 — 5247 voltage levels)
41
+ node generate_nad.mjs
42
+ # or pick another grid that has data/<grid>/grid_layout.json:
43
+ # node generate_nad.mjs pypsa_eur_fr225_400
44
+
45
+ # 2. Benchmark plain vs culled pan/zoom (interleaved, 6 reps)
46
+ PW_CHROME=/path/to/chrome node bench_pan_zoom.mjs
47
+ ```
48
+
49
+ Output (example, headless software-GL — relative numbers, not absolute fps):
50
+
51
+ ```
52
+ pan plain mean 290.1ms median 297.6ms p95 407.3ms
53
+ pan cull mean 188.2ms median 183.8ms p95 244ms
54
+ => culling speedup: mean 1.5x median 1.6x
55
+ ```
56
+
57
+ ## Caveats
58
+
59
+ - **Headless = software rendering** (SwiftShader, no GPU). Absolute frame
60
+ times are far higher than on a real GPU desktop; only the *relative*
61
+ plain-vs-cull comparison is meaningful here. On GPU the culling win is
62
+ expected to be larger (the CPU-side `<foreignObject>` raster dominates
63
+ the residual cost relatively more).
64
+ - Always interleave A/B (this script does): back-to-back passes over the
65
+ same viewBoxes reuse warm raster tiles and overstate the speedup.
benchmarks/interaction_paint/app_bitmap_driver.mjs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Verify the bitmap pan/zoom mode end-to-end in the real app: set each mode
2
+ // via the Settings select, measure sustained drag/wheel fps on the N tab, then
3
+ // load a contingency and capture a MID-gesture screenshot to confirm the bitmap
4
+ // keeps the overload/contingency halos (the N-1/action fidelity prerequisite).
5
+ import { writeFileSync } from 'fs';
6
+ const PORT = 9444;
7
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
8
+ async function findTarget(){for(let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);}throw new Error('no app target');}
9
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();
10
+ ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});
11
+ ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});
12
+ const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,400));return r.result.value;};res({send,evalJs});});});}
13
+ const { send, evalJs } = await connect((await findTarget()).webSocketDebuggerUrl);
14
+ await send('Runtime.enable'); await send('Page.enable');
15
+ const log=(...a)=>console.error(...a);
16
+
17
+ await evalJs(`
18
+ window.__setInput=(sel,val)=>{const el=document.querySelector(sel);if(!el)return 'no';const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(el,val);el.dispatchEvent(new Event('input',{bubbles:true}));return el.value;};
19
+ window.__btn=(txt)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===txt);if(b){b.click();return true;}return false;};
20
+ window.__summarize=(a)=>{if(!a.length)return null;const s=a.slice().sort((x,y)=>x-y);const n=s.length,mean=a.reduce((p,v)=>p+v,0)/n;const pct=p=>s[Math.min(n-1,Math.floor(p*n))];const drop=a.filter(d=>d>14).length;return{frames:n,median:+pct(0.5).toFixed(2),p95:+pct(0.95).toFixed(2),max:+s[n-1].toFixed(0),fps_median:+(1000/pct(0.5)).toFixed(1),dropPct:+(100*drop/n).toFixed(1)};};
21
+ window.__canvasMounted=()=>!!document.querySelector('.svg-container canvas');
22
+ // first-frame latency: time from mousedown to the first rAF frame (a sync
23
+ // serialize freeze would make this large).
24
+ window.__downToFirstFrame=()=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve(-1);const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;const t0=performance.now();c.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:cx,clientY:cy,button:0}));window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+5,clientY:cy,button:0}));requestAnimationFrame(()=>{const dt=performance.now()-t0;window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+10,clientY:cy,button:0}));setTimeout(()=>window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:cx+10,clientY:cy,button:0})),50);resolve(+dt.toFixed(1));});});
25
+ window.__dragPan=(d)=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve({error:'no-container'});const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;c.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:cx,clientY:cy,button:0}));const iv=[];let last=performance.now();const t0=last;function f(now){iv.push(now-last);last=now;const e=now-t0,t=e/d,dx=Math.sin(t*Math.PI*4)*250;window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));if(e<d)requestAnimationFrame(f);else{window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));iv.shift();resolve(window.__summarize(iv));}}requestAnimationFrame(f);});
26
+ window.__setMode=async(mode)=>{window.__btn('⚙');await new Promise(r=>setTimeout(r,400));window.__btn('Configurations');await new Promise(r=>setTimeout(r,300));const sel=document.querySelector('[data-testid=pan-zoom-mode-select]');if(!sel)return'NO-SELECT';const s=Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype,'value').set;s.call(sel,mode);sel.dispatchEvent(new Event('change',{bubbles:true}));const ls=localStorage.getItem('cs4g-smooth-pan-zoom');if(!window.__btn('✕')){const fb=[...document.querySelectorAll('button')].find(b=>/cancel|close/i.test(b.textContent));if(fb)fb.click();}await new Promise(r=>setTimeout(r,400));return ls;};
27
+ 'ok';`);
28
+
29
+ // Configure + load N if needed.
30
+ const mounted0 = await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`);
31
+ if (mounted0 < 5000) {
32
+ if (!(await evalJs(`!!document.querySelector('#networkPathInput')`))) { await evalJs(`window.__btn('⚙')`); await sleep(600); }
33
+ await evalJs(`window.__setInput('#networkPathInput','data/pypsa_eur_eur220_225_380_400/network.xiidm')`);
34
+ await evalJs(`window.__setInput('#actionPathInput','data/pypsa_eur_eur220_225_380_400/actions.json')`);
35
+ await evalJs(`(()=>{const l=[...document.querySelectorAll('label')].find(x=>/layout/i.test(x.textContent));const inp=l&&(l.parentElement.querySelector('input[type=text]')||l.closest('div').querySelector('input[type=text]'));if(inp){const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,'data/pypsa_eur_eur220_225_380_400/grid_layout.json');inp.dispatchEvent(new Event('input',{bubbles:true}));}})()`);
36
+ await evalJs(`window.__btn('Apply')`); log('loading NAD…');
37
+ for (let i=0;i<120;i++){ const n=await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`).catch(()=>0); if(n>5000)break; await sleep(1000); }
38
+ }
39
+ log('N nodes:', await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`));
40
+ await sleep(800);
41
+
42
+ // fps per mode on the N tab.
43
+ const out = {};
44
+ for (const mode of ['off', 'gpu', 'bitmap']) {
45
+ const ls = await evalJs(`window.__setMode('${mode}')`, true);
46
+ await sleep(400);
47
+ // mousedown→first-frame latency (a sync serialize freeze would spike this).
48
+ const firstFrameMs = await evalJs(`window.__downToFirstFrame()`, true);
49
+ await sleep(300);
50
+ // prime gesture (warms the bitmap serialisation cache on idle), then pause so
51
+ // the idle prewarm completes, then measure the steady state.
52
+ await evalJs(`window.__dragPan(1500)`, true);
53
+ await sleep(2500);
54
+ const pan = await evalJs(`window.__dragPan(2500)`, true);
55
+ const canvasMounted = await evalJs(`window.__canvasMounted()`);
56
+ out[mode] = { ls, firstFrameMs, canvasMounted, pan };
57
+ log(mode, 'ls=' + ls, 'firstFrameMs=' + firstFrameMs, 'canvas=' + canvasMounted, JSON.stringify(pan));
58
+ }
59
+ console.log(JSON.stringify(out));
60
+ process.exit(0);
benchmarks/interaction_paint/app_bitmap_n1_driver.mjs ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // On the N-1 (Contingency) tab in BITMAP mode, capture a MID-gesture screenshot
2
+ // (live SVG hidden, canvas bitmap showing) and a static one, to confirm the
3
+ // overload/contingency halos + flow-delta colours survive in the rasterised
4
+ // bitmap — the N-1/action fidelity prerequisite (App.css class paint inlined).
5
+ import { writeFileSync } from 'fs';
6
+ const PORT = 9444;
7
+ const BRANCH = process.env.BRANCH || 'T_relation_13260100-400-225';
8
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
9
+ async function findTarget(){for(let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);}throw new Error('no app target');}
10
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();
11
+ ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});
12
+ ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});
13
+ const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,400));return r.result.value;};res({send,evalJs});});});}
14
+ const { send, evalJs } = await connect((await findTarget()).webSocketDebuggerUrl);
15
+ await send('Runtime.enable'); await send('Page.enable');
16
+ const log=(...a)=>console.error(...a);
17
+ const shot = async (path) => { const { data } = await send('Page.captureScreenshot', { format: 'jpeg', quality: 82 }); writeFileSync(path, Buffer.from(data, 'base64')); log('saved', path); };
18
+
19
+ await evalJs(`
20
+ window.__btn=(txt)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===txt);if(b){b.click();return true;}return false;};
21
+ window.__setMode=async(mode)=>{window.__btn('⚙');await new Promise(r=>setTimeout(r,400));window.__btn('Configurations');await new Promise(r=>setTimeout(r,300));const sel=document.querySelector('[data-testid=pan-zoom-mode-select]');if(sel){const s=Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype,'value').set;s.call(sel,mode);sel.dispatchEvent(new Event('change',{bubbles:true}));}const ls=localStorage.getItem('cs4g-smooth-pan-zoom');if(!window.__btn('✕')){const fb=[...document.querySelectorAll('button')].find(b=>/cancel|close/i.test(b.textContent));if(fb)fb.click();}await new Promise(r=>setTimeout(r,300));return ls;};
22
+ // hold a drag for durationMs (mousedown + rAF mousemoves + mouseup); exposes state.
23
+ window.__holdDrag=(d)=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c){window.__drag={error:'no-container'};return;}const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;c.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:cx,clientY:cy,button:0}));window.__dragDone=false;const t0=performance.now();function f(now){const e=now-t0;const dx=Math.sin(e/400)*180;window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));if(e<d)requestAnimationFrame(f);else{window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));window.__dragDone=true;}}requestAnimationFrame(f);};
24
+ 'ok';`);
25
+
26
+ log('mode:', await evalJs(`window.__setMode('bitmap')`, true));
27
+
28
+ // Select contingency + trigger (react-select then Trigger button).
29
+ await evalJs(`(()=>{const inp=document.querySelector('#react-select-3-input');if(!inp)return;inp.focus();const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,${JSON.stringify(BRANCH)});inp.dispatchEvent(new Event('input',{bubbles:true}));})()`);
30
+ await sleep(1300);
31
+ await evalJs(`(()=>{const o=document.querySelector('[class*=cs4g-contingency__option]');if(o){o.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}));o.click();}})()`);
32
+ await sleep(600);
33
+ await evalJs(`(()=>{const b=[...document.querySelectorAll('button')].find(b=>/trigger/i.test(b.textContent));if(b)b.click();})()`);
34
+ await sleep(5000);
35
+ await evalJs(`window.__btn('Contingency')`);
36
+ let st=null;
37
+ for(let i=0;i<60;i++){st=await evalJs(`(()=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));return c?{nodes:c.querySelector('svg').querySelectorAll('*').length,halos:c.querySelectorAll('.nad-contingency-highlight,.nad-overloaded,.nad-delta-positive,.nad-delta-negative').length}:null;})()`).catch(()=>null);if(st&&st.nodes>5000)break;await sleep(1000);}
38
+ log('N-1 visible svg:', JSON.stringify(st));
39
+ await sleep(800);
40
+
41
+ // Zoom into the contingency area so halos are visible, then static screenshot.
42
+ await evalJs(`(()=>{const svg=document.querySelector('.svg-container svg');const hl=svg.querySelector('.nad-contingency-highlight, .nad-delta-positive');const v=svg.getAttribute('viewBox').split(/\\s+/).map(Number);let cx=v[0]+v[2]/2, cy=v[1]+v[3]/2;if(hl){try{const b=hl.getBBox();cx=b.x+b.width/2;cy=b.y+b.height/2;}catch{}}const W=v[2]*0.05,H=W*0.66;svg.setAttribute('viewBox',(cx-W/2)+' '+(cy-H/2)+' '+W+' '+H);const c=svg.closest('.svg-container');if(c)c.setAttribute('data-zoom-tier','detail');})()`);
43
+ await sleep(800);
44
+ await shot('/tmp/cs4g_bitmap_n1_static.jpg');
45
+
46
+ // Start a held drag; mid-gesture (canvas mounted) capture, then let it finish.
47
+ await evalJs(`window.__holdDrag(3000)`);
48
+ await sleep(1500); // raster + mount window
49
+ const midState = await evalJs(`(()=>{const c=document.querySelector('.svg-container canvas');const svg=document.querySelector('.svg-container svg');return {canvasMounted:!!c, svgHidden: svg? getComputedStyle(svg).visibility==='hidden':null};})()`);
50
+ log('mid-gesture:', JSON.stringify(midState));
51
+ await shot('/tmp/cs4g_bitmap_n1_mid.jpg');
52
+ // wait for the drag to finish + settle
53
+ for(let i=0;i<30;i++){ if(await evalJs(`window.__dragDone===true`)) break; await sleep(200); }
54
+ await sleep(500);
55
+ console.log(JSON.stringify({ n1: st, midState }));
56
+ process.exit(0);
benchmarks/interaction_paint/app_gesture_driver.mjs ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Drive the REAL Co-Study4Grid app (localhost:5173) end-to-end on the
2
+ // isolated port-9444 Chrome: open Settings, point at pypsa_eur_eur220_225_380_400,
3
+ // Apply, wait for the N NAD, then measure a REAL drag-pan + wheel-zoom gesture
4
+ // through the app's own usePanZoom handlers, with the GPU toggle OFF then ON.
5
+ const PORT = 9444;
6
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
7
+
8
+ async function findTarget() {
9
+ for (let i = 0; i < 40; i++) {
10
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
11
+ const t = list.find(x => x.type === 'page' && /^https?:/.test(x.url));
12
+ if (t && t.webSocketDebuggerUrl) return t;
13
+ await sleep(250);
14
+ }
15
+ throw new Error('no page target');
16
+ }
17
+ function connect(wsUrl) {
18
+ return new Promise((resolve) => {
19
+ const ws = new WebSocket(wsUrl); let id = 0; const pending = new Map();
20
+ ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { const { r, j } = pending.get(m.id); pending.delete(m.id); m.error ? j(new Error(JSON.stringify(m.error))) : r(m.result); } });
21
+ ws.addEventListener('open', () => {
22
+ const send = (method, params = {}) => new Promise((r, j) => { const mid = ++id; pending.set(mid, { r, j }); ws.send(JSON.stringify({ id: mid, method, params })); });
23
+ const evalJs = async (expr, awaitPromise = false) => { const res = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise }); if (res.exceptionDetails) throw new Error('eval: ' + JSON.stringify(res.exceptionDetails).slice(0, 300)); return res.result.value; };
24
+ resolve({ send, evalJs });
25
+ });
26
+ });
27
+ }
28
+
29
+ const { send, evalJs } = await connect((await findTarget()).webSocketDebuggerUrl);
30
+ await send('Runtime.enable');
31
+ const log = (...a) => console.error(...a);
32
+
33
+ // Helper expressions injected into the page.
34
+ const HELPERS = `
35
+ window.__setInput = (sel, val) => {
36
+ const el = document.querySelector(sel); if (!el) return 'no-el:' + sel;
37
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
38
+ setter.call(el, val);
39
+ el.dispatchEvent(new Event('input', { bubbles: true }));
40
+ return el.value;
41
+ };
42
+ window.__btn = (txt) => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === txt); if (b) { b.click(); return true; } return false; };
43
+ window.__summarize = (a) => { if(!a.length) return null; const s=a.slice().sort((x,y)=>x-y); const n=s.length, mean=a.reduce((p,v)=>p+v,0)/n; const pct=p=>s[Math.min(n-1,Math.floor(p*n))]; const drop=a.filter(d=>d>14).length; return {frames:n, mean:+mean.toFixed(2), median:+pct(0.5).toFixed(2), p95:+pct(0.95).toFixed(2), max:+s[n-1].toFixed(2), fps_median:+(1000/pct(0.5)).toFixed(1), dropPct:+(100*drop/n).toFixed(1)}; };
44
+ // Real drag-pan through the app's usePanZoom (mousedown on container, mousemove on window).
45
+ window.__dragPan = (durationMs) => new Promise(resolve => {
46
+ const c = [...document.querySelectorAll('.svg-container')].find(e => e.offsetParent !== null && e.querySelector('svg'));
47
+ if (!c) return resolve({error:'no visible svg-container'});
48
+ const r = c.getBoundingClientRect();
49
+ const cx = r.left + r.width/2, cy = r.top + r.height/2;
50
+ const md = new MouseEvent('mousedown', {bubbles:true, clientX:cx, clientY:cy, button:0});
51
+ c.dispatchEvent(md);
52
+ const intervals=[]; let last=performance.now(); const t0=last;
53
+ function frame(now){
54
+ intervals.push(now-last); last=now;
55
+ const e=now-t0; const t=(e/durationMs); const dx=Math.sin(t*Math.PI*4)*250;
56
+ window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true, clientX:cx+dx, clientY:cy, button:0}));
57
+ if(e<durationMs) requestAnimationFrame(frame);
58
+ else { window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true, clientX:cx+dx, clientY:cy, button:0})); intervals.shift(); resolve(window.__summarize(intervals)); }
59
+ }
60
+ requestAnimationFrame(frame);
61
+ });
62
+ // Real wheel-zoom through usePanZoom (wheel on container, alternating in/out).
63
+ window.__wheelZoom = (durationMs) => new Promise(resolve => {
64
+ const c = [...document.querySelectorAll('.svg-container')].find(e => e.offsetParent !== null && e.querySelector('svg'));
65
+ if (!c) return resolve({error:'no visible svg-container'});
66
+ const r = c.getBoundingClientRect(); const cx=r.left+r.width/2, cy=r.top+r.height/2;
67
+ const intervals=[]; let last=performance.now(); const t0=last;
68
+ function frame(now){
69
+ intervals.push(now-last); last=now;
70
+ const e=now-t0;
71
+ const dir = Math.sin(e/250) > 0 ? -1 : 1; // zoom in/out
72
+ c.dispatchEvent(new WheelEvent('wheel',{bubbles:true, cancelable:true, clientX:cx, clientY:cy, deltaY: dir*100}));
73
+ if(e<durationMs) requestAnimationFrame(frame);
74
+ else { intervals.shift(); resolve(window.__summarize(intervals)); }
75
+ }
76
+ requestAnimationFrame(frame);
77
+ });
78
+ 'ok';`;
79
+ await evalJs(HELPERS);
80
+
81
+ // 1. Open Settings if not open.
82
+ let settingsOpen = await evalJs(`!!document.querySelector('#networkPathInput')`);
83
+ if (!settingsOpen) { await evalJs(`window.__btn('��')`); await sleep(600); }
84
+ log('settings open:', await evalJs(`!!document.querySelector('#networkPathInput')`));
85
+
86
+ // 2. Set paths.
87
+ log('net:', await evalJs(`window.__setInput('#networkPathInput','data/pypsa_eur_eur220_225_380_400/network.xiidm')`));
88
+ log('act:', await evalJs(`window.__setInput('#actionPathInput','data/pypsa_eur_eur220_225_380_400/actions.json')`));
89
+ // layout input: 3rd path input (no id). Set by locating label "Layout".
90
+ await evalJs(`(()=>{const labs=[...document.querySelectorAll('label')];const l=labs.find(x=>/layout/i.test(x.textContent));if(l){const inp=l.parentElement.querySelector('input[type=text]')||l.closest('div').querySelector('input[type=text]');if(inp){const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,'data/pypsa_eur_eur220_225_380_400/grid_layout.json');inp.dispatchEvent(new Event('input',{bubbles:true}));return inp.value;}}return 'no-layout-input';})()`).then(v=>log('layout:',v));
91
+
92
+ // 3. Ensure GPU toggle is OFF for the first run.
93
+ await evalJs(`(()=>{const t=document.querySelector('[data-testid=smooth-pan-zoom-toggle]');if(t&&t.checked){t.click();} return t?('checked='+document.querySelector('[data-testid=smooth-pan-zoom-toggle]').checked):'no-toggle';})()`).then(v=>log('toggle pre:',v));
94
+
95
+ // 4. Apply.
96
+ await evalJs(`window.__btn('Apply')`); log('clicked Apply');
97
+
98
+ // 5. Wait for the N NAD to mount with many nodes.
99
+ let mounted = 0;
100
+ for (let i = 0; i < 120; i++) { mounted = await evalJs(`(()=>{const s=document.querySelector('.svg-container svg');return s?s.querySelectorAll('*').length:0;})()`).catch(()=>0); if (mounted > 5000) break; await sleep(1000); }
101
+ log('NAD nodes mounted:', mounted, 'tab:', await evalJs(`document.querySelector('.svg-container svg')?.getAttribute('viewBox')?.slice(0,30)`));
102
+ if (mounted < 5000) { console.log(JSON.stringify({error:'NAD did not mount', mounted})); process.exit(1); }
103
+ await sleep(800);
104
+
105
+ // 6. Measure OFF.
106
+ const off_pan = await evalJs(`window.__dragPan(2500)`, true);
107
+ await sleep(300);
108
+ const off_zoom = await evalJs(`window.__wheelZoom(2500)`, true);
109
+ log('OFF pan', JSON.stringify(off_pan), 'zoom', JSON.stringify(off_zoom));
110
+
111
+ // 7. Flip GPU toggle ON via Settings (open, click toggle, close WITHOUT Apply).
112
+ await evalJs(`window.__btn('⚙')`); await sleep(500);
113
+ const onState = await evalJs(`(()=>{const t=document.querySelector('[data-testid=smooth-pan-zoom-toggle]');if(t&&!t.checked){t.click();} return document.querySelector('[data-testid=smooth-pan-zoom-toggle]')?.checked;})()`);
114
+ log('toggle now ON:', onState, 'localStorage:', await evalJs(`localStorage.getItem('cs4g-smooth-pan-zoom')`));
115
+ // Close settings without Apply: click ✕ or Cancel.
116
+ await evalJs(`(()=>{if(!window.__btn('✕')){const fb=[...document.querySelectorAll('button')].find(b=>/cancel|close|annul/i.test(b.textContent));if(fb)fb.click();}})()`); await sleep(500);
117
+ log('settings closed:', !(await evalJs(`!!document.querySelector('#networkPathInput')`)));
118
+
119
+ // 8. Measure ON.
120
+ const on_pan = await evalJs(`window.__dragPan(2500)`, true);
121
+ await sleep(300);
122
+ const on_zoom = await evalJs(`window.__wheelZoom(2500)`, true);
123
+ log('ON pan', JSON.stringify(on_pan), 'zoom', JSON.stringify(on_zoom));
124
+
125
+ console.log(JSON.stringify({ off:{pan:off_pan,zoom:off_zoom}, on:{pan:on_pan,zoom:on_zoom} }));
126
+ process.exit(0);
benchmarks/interaction_paint/app_measure_snapshot.mjs ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Measure where the bitmap-mode snapshot cost goes (clone / serialize / decode
2
+ // / draw) on the live N NAD — to size the gesture-start blocking problem.
3
+ const PORT = 9444; const sleep = (ms) => new Promise(r => setTimeout(r, ms));
4
+ async function findTarget(){for(let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);}throw new Error('no app target');}
5
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,300));return r.result.value;};res({send,evalJs});});});}
6
+ const { evalJs, send } = await connect((await findTarget()).webSocketDebuggerUrl);
7
+ await send('Runtime.enable');
8
+ const log=(...a)=>console.error(...a);
9
+ await evalJs(`window.__setInput=(s,v)=>{const el=document.querySelector(s);if(!el)return;const x=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;x.call(el,v);el.dispatchEvent(new Event('input',{bubbles:true}));};window.__btn=(t)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===t);if(b){b.click();return true;}return false;};'ok';`);
10
+ const mounted0 = await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`);
11
+ if (mounted0 < 5000) {
12
+ if (!(await evalJs(`!!document.querySelector('#networkPathInput')`))) { await evalJs(`window.__btn('⚙')`); await sleep(600); }
13
+ await evalJs(`window.__setInput('#networkPathInput','data/pypsa_eur_eur220_225_380_400/network.xiidm')`);
14
+ await evalJs(`window.__setInput('#actionPathInput','data/pypsa_eur_eur220_225_380_400/actions.json')`);
15
+ await evalJs(`(()=>{const l=[...document.querySelectorAll('label')].find(x=>/layout/i.test(x.textContent));const inp=l&&(l.parentElement.querySelector('input[type=text]')||l.closest('div').querySelector('input[type=text]'));if(inp){const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,'data/pypsa_eur_eur220_225_380_400/grid_layout.json');inp.dispatchEvent(new Event('input',{bubbles:true}));}})()`);
16
+ await evalJs(`window.__btn('Apply')`); log('loading…');
17
+ for (let i=0;i<120;i++){ const n=await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`).catch(()=>0); if(n>5000)break; await sleep(1000); }
18
+ }
19
+ await sleep(800);
20
+ const m = await evalJs(`(async()=>{
21
+ const svg=document.querySelector('.svg-container svg');
22
+ const W=svg.clientWidth, H=svg.clientHeight, dpr=window.devicePixelRatio||1;
23
+ const t0=performance.now();
24
+ const clone=svg.cloneNode(true);
25
+ const t1=performance.now();
26
+ clone.querySelectorAll('foreignObject').forEach(n=>n.remove());
27
+ clone.setAttribute('width',W); clone.setAttribute('height',H);
28
+ const xml=new XMLSerializer().serializeToString(clone);
29
+ const t2=performance.now();
30
+ const url=URL.createObjectURL(new Blob([xml],{type:'image/svg+xml;charset=utf-8'}));
31
+ const img=new Image(); img.width=W; img.height=H;
32
+ const t3=performance.now();
33
+ try{ await new Promise((res,rej)=>{img.onload=res;img.onerror=()=>rej(new Error('decode fail'));img.src=url;}); }catch(e){ return {error:String(e)}; }
34
+ const t4=performance.now();
35
+ const cv=document.createElement('canvas'); cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr); const ctx=cv.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.drawImage(img,0,0,W,H);
36
+ const t5=performance.now();
37
+ URL.revokeObjectURL(url);
38
+ return {W,H,dpr, cloneMs:+(t1-t0).toFixed(0), stripSerializeMs:+(t2-t1).toFixed(0), decodeMs:+(t4-t3).toFixed(0), drawMs:+(t5-t4).toFixed(0), syncBlockMs:+(t2-t0).toFixed(0), totalMs:+(t5-t0).toFixed(0), xmlMB:+(xml.length/1e6).toFixed(1)};
39
+ })()`, true);
40
+ console.log(JSON.stringify(m));
41
+ process.exit(0);
benchmarks/interaction_paint/app_n1_driver.mjs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Select a contingency via the react-select picker, switch to the Contingency
2
+ // tab, and measure real pan/zoom OFF vs ON on the N-1 diagram.
3
+ const PORT = 9444;
4
+ const BRANCH = process.env.BRANCH || 'T_relation_13260100-400-225';
5
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
6
+ async function findTarget(){for(let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);}throw new Error('no app target');}
7
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,300));return r.result.value;};res({send,evalJs});});});}
8
+ const { send, evalJs } = await connect((await findTarget()).webSocketDebuggerUrl);
9
+ await send('Runtime.enable');
10
+ const log=(...a)=>console.error(...a);
11
+
12
+ const HELPERS=`
13
+ window.__btn=(txt)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===txt);if(b){b.click();return true;}return false;};
14
+ window.__summarize=(a)=>{if(!a.length)return null;const s=a.slice().sort((x,y)=>x-y);const n=s.length,mean=a.reduce((p,v)=>p+v,0)/n;const pct=p=>s[Math.min(n-1,Math.floor(p*n))];const drop=a.filter(d=>d>14).length;return{frames:n,mean:+mean.toFixed(2),median:+pct(0.5).toFixed(2),p95:+pct(0.95).toFixed(2),max:+s[n-1].toFixed(2),fps_median:+(1000/pct(0.5)).toFixed(1),dropPct:+(100*drop/n).toFixed(1)};};
15
+ window.__visSvg=()=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));return c?{nodes:c.querySelector('svg').querySelectorAll('*').length, halos:c.querySelectorAll('.nad-overloaded,.nad-contingency-highlight,.nad-delta-positive,.nad-delta-negative').length}:null;};
16
+ window.__dragPan=(d)=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve({error:'no-container'});const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;c.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:cx,clientY:cy,button:0}));const iv=[];let last=performance.now();const t0=last;function f(now){iv.push(now-last);last=now;const e=now-t0,t=e/d,dx=Math.sin(t*Math.PI*4)*250;window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));if(e<d)requestAnimationFrame(f);else{window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));iv.shift();resolve(window.__summarize(iv));}}requestAnimationFrame(f);});
17
+ window.__wheelZoom=(d)=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve({error:'no-container'});const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;const iv=[];let last=performance.now();const t0=last;function f(now){iv.push(now-last);last=now;const e=now-t0;const dir=Math.sin(e/250)>0?-1:1;c.dispatchEvent(new WheelEvent('wheel',{bubbles:true,cancelable:true,clientX:cx,clientY:cy,deltaY:dir*100}));if(e<d)requestAnimationFrame(f);else{iv.shift();resolve(window.__summarize(iv));}}requestAnimationFrame(f);});
18
+ window.__setToggle=async(desired)=>{window.__btn('⚙');await new Promise(r=>setTimeout(r,400));window.__btn('Configurations');await new Promise(r=>setTimeout(r,300));const t=document.querySelector('[data-testid=smooth-pan-zoom-toggle]');if(!t)return'NO-TOGGLE';if(t.checked!==desired)t.click();const st={checked:document.querySelector('[data-testid=smooth-pan-zoom-toggle]').checked,ls:localStorage.getItem('cs4g-smooth-pan-zoom')};if(!window.__btn('✕')){const fb=[...document.querySelectorAll('button')].find(b=>/cancel|close/i.test(b.textContent));if(fb)fb.click();}await new Promise(r=>setTimeout(r,400));return st;};
19
+ window.__pickContingency=(branch)=>{const inp=document.querySelector('#react-select-3-input');if(!inp)return'no-input';inp.focus();const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,branch);inp.dispatchEvent(new Event('input',{bubbles:true}));return 'typed';};
20
+ window.__enter=()=>{const inp=document.querySelector('#react-select-3-input');if(!inp)return'no-input';['keydown','keypress','keyup'].forEach(t=>inp.dispatchEvent(new KeyboardEvent(t,{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true})));return 'enter';};
21
+ 'ok';`;
22
+ await evalJs(HELPERS);
23
+
24
+ // Select contingency.
25
+ log('pick:', await evalJs(`window.__pickContingency(${JSON.stringify(BRANCH)})`));
26
+ await sleep(1200);
27
+ // dump menu options count to confirm filtering worked
28
+ log('menu opts:', await evalJs(`document.querySelectorAll('[class*=cs4g-contingency__option]').length`));
29
+ log('enter:', await evalJs(`window.__enter()`));
30
+ await sleep(1000);
31
+ // Confirm dialog? click confirm if present.
32
+ await evalJs(`(()=>{const b=[...document.querySelectorAll('button')].find(b=>/confirm|oui|yes|proceed|continuer/i.test(b.textContent));if(b){b.click();return 'confirmed';}return 'no-confirm';})()`).then(v=>log('confirm:',v));
33
+ // Switch to Contingency tab.
34
+ await sleep(500); await evalJs(`window.__btn('Contingency')`);
35
+ // Poll for the N-1 diagram to render.
36
+ let st=null;
37
+ for(let i=0;i<90;i++){ st=await evalJs(`window.__visSvg()`).catch(()=>null); if(st&&st.nodes>5000) break; await sleep(1000); }
38
+ log('N-1 svg:', JSON.stringify(st));
39
+ if(!st||st.nodes<5000){console.log(JSON.stringify({error:'no N-1 diagram',st}));process.exit(1);}
40
+ await sleep(800);
41
+
42
+ const out={contingency:BRANCH, svg:st};
43
+ for(const mode of ['off','on']){
44
+ const t=await evalJs(`window.__setToggle(${mode==='on'})`,true); log(mode,'toggle',JSON.stringify(t));
45
+ await sleep(400); await evalJs(`window.__btn('Contingency')`); await sleep(300);
46
+ const pan=await evalJs(`window.__dragPan(2500)`,true); await sleep(300);
47
+ const zoom=await evalJs(`window.__wheelZoom(2500)`,true);
48
+ out[mode]={pan,zoom}; log(mode,'pan',JSON.stringify(pan),'zoom',JSON.stringify(zoom));
49
+ }
50
+ console.log(JSON.stringify(out));
51
+ process.exit(0);
benchmarks/interaction_paint/app_render_driver.mjs ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Configure the app, load N, capture the de-clutter log, auto-zoom to the
2
+ // densest substation cluster, and screenshot it (visual check of flow labels).
3
+ import { writeFileSync } from 'fs';
4
+ const PORT = 9444;
5
+ const OUT = process.env.OUT || '/tmp/cs4g_flowlabels.jpg';
6
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
7
+ async function findTarget(){for(let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);}throw new Error('no app target');}
8
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();const logs=[];
9
+ ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);
10
+ if(m.method==='Runtime.consoleAPICalled'){try{logs.push(m.params.args.map(a=>a.value??a.description??'').join(' '));}catch{}}
11
+ if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});
12
+ ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});
13
+ const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,300));return r.result.value;};
14
+ res({send,evalJs,logs});});});}
15
+ const { send, evalJs, logs } = await connect((await findTarget()).webSocketDebuggerUrl);
16
+ await send('Runtime.enable'); await send('Page.enable');
17
+ const log=(...a)=>console.error(...a);
18
+
19
+ await evalJs(`
20
+ window.__setInput=(sel,val)=>{const el=document.querySelector(sel);if(!el)return 'no:'+sel;const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(el,val);el.dispatchEvent(new Event('input',{bubbles:true}));return el.value;};
21
+ window.__btn=(txt)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===txt);if(b){b.click();return true;}return false;};
22
+ 'ok';`);
23
+
24
+ // Configure if a fresh NAD is needed.
25
+ const mounted0 = await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`);
26
+ if (mounted0 < 5000) {
27
+ if (!(await evalJs(`!!document.querySelector('#networkPathInput')`))) { await evalJs(`window.__btn('⚙')`); await sleep(600); }
28
+ await evalJs(`window.__setInput('#networkPathInput','data/pypsa_eur_eur220_225_380_400/network.xiidm')`);
29
+ await evalJs(`window.__setInput('#actionPathInput','data/pypsa_eur_eur220_225_380_400/actions.json')`);
30
+ await evalJs(`(()=>{const l=[...document.querySelectorAll('label')].find(x=>/layout/i.test(x.textContent));const inp=l&&(l.parentElement.querySelector('input[type=text]')||l.closest('div').querySelector('input[type=text]'));if(inp){const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;s.call(inp,'data/pypsa_eur_eur220_225_380_400/grid_layout.json');inp.dispatchEvent(new Event('input',{bubbles:true}));}})()`);
31
+ await evalJs(`window.__btn('Apply')`); log('applied; loading NAD…');
32
+ for (let i=0;i<120;i++){ const n=await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`).catch(()=>0); if(n>5000)break; await sleep(1000); }
33
+ }
34
+ const nodes = await evalJs(`document.querySelector('.svg-container svg')?.querySelectorAll('*').length||0`);
35
+ log('NAD nodes:', nodes);
36
+ await sleep(1000);
37
+ log('declutter log:', logs.filter(l=>/De-cluttered|Boosted|declutter pass/.test(l)).slice(-3).join(' || ') || '(none captured)');
38
+
39
+ // Optionally hide VL-name labels (🏷 VL) to isolate the flow values.
40
+ if (process.env.HIDE_VL === '1') { await evalJs(`window.__btn('🏷 VL')`).catch(()=>{}); await sleep(400);
41
+ await evalJs(`(()=>{const c=document.querySelector('.svg-container');if(c)c.classList.add('nad-hide-vl-labels');})()`); await sleep(300); }
42
+
43
+ const FORCED_VB = process.env.VB || '';
44
+ // Find the densest substation cluster and zoom the SVG viewBox onto it.
45
+ const vb = await evalJs(`(()=>{
46
+ const FORCED=${JSON.stringify(FORCED_VB)};
47
+ const svg=document.querySelector('.svg-container svg');
48
+ const circles=[...svg.querySelectorAll('.nad-vl-nodes g[transform]')];
49
+ const pts=[];
50
+ for(const g of circles){const m=/translate\\(\\s*([-0-9.eE+]+)\\s*[, ]\\s*([-0-9.eE+]+)/.exec(g.getAttribute('transform')||'');if(m)pts.push([+m[1],+m[2]]);}
51
+ if(!pts.length) return null;
52
+ // grid density
53
+ let minx=1e18,miny=1e18,maxx=-1e18,maxy=-1e18;
54
+ for(const[x,y]of pts){if(x<minx)minx=x;if(x>maxx)maxx=x;if(y<miny)miny=y;if(y>maxy)maxy=y;}
55
+ const span=Math.max(maxx-minx,maxy-miny);
56
+ const cell=span/120;
57
+ const grid=new Map();let best=null;
58
+ for(const[x,y]of pts){const k=Math.floor(x/cell)+','+Math.floor(y/cell);const c=(grid.get(k)||0)+1;grid.set(k,c);if(!best||c>best.c){best={k,c,x,y};}}
59
+ // Tight window (~2.2% of full width) over the densest cell so individual
60
+ // flow values render. Force the detail zoom-tier on the container (we set
61
+ // the viewBox directly, bypassing usePanZoom which normally maintains it).
62
+ let nvb;
63
+ if(FORCED){const p=FORCED.split(/\\s+/).map(Number);nvb={x:p[0],y:p[1],w:p[2],h:p[3]};}
64
+ else{const W=(maxx-minx)*0.022, H=W*0.66;nvb={x:best.x-W/2,y:best.y-H/2,w:W,h:H};}
65
+ svg.setAttribute('viewBox',\`\${nvb.x} \${nvb.y} \${nvb.w} \${nvb.h}\`);
66
+ const cont=svg.closest('.svg-container'); if(cont){cont.setAttribute('data-zoom-tier','detail');cont.classList.remove('svg-interacting');}
67
+ return {densest:best.c, vb:nvb};
68
+ })()`);
69
+ log('zoomed to densest cluster:', JSON.stringify(vb));
70
+ await sleep(1200);
71
+
72
+ const { data } = await send('Page.captureScreenshot', { format: 'jpeg', quality: 80 });
73
+ writeFileSync(OUT, Buffer.from(data, 'base64'));
74
+ log('saved', OUT);
75
+ process.exit(0);
benchmarks/interaction_paint/app_toggle_driver.mjs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Assumes the N NAD is already loaded in the app. For each mode (off/on),
2
+ // set the real "Smooth pan/zoom (GPU)" toggle via Settings -> Configurations,
3
+ // then measure a real drag-pan + wheel-zoom through usePanZoom.
4
+ const PORT = 9444;
5
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
6
+ async function findTarget() { for (let i=0;i<40;i++){const l=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();const t=l.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));if(t&&t.webSocketDebuggerUrl)return t;await sleep(250);} throw new Error('no app target'); }
7
+ function connect(wsUrl){return new Promise(res=>{const ws=new WebSocket(wsUrl);let id=0;const p=new Map();ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r,j}=p.get(m.id);p.delete(m.id);m.error?j(new Error(JSON.stringify(m.error))):r(m.result);}});ws.addEventListener('open',()=>{const send=(method,params={})=>new Promise((r,j)=>{const mid=++id;p.set(mid,{r,j});ws.send(JSON.stringify({id:mid,method,params}));});const evalJs=async(e,a=false)=>{const r=await send('Runtime.evaluate',{expression:e,returnByValue:true,awaitPromise:a});if(r.exceptionDetails)throw new Error('eval:'+JSON.stringify(r.exceptionDetails).slice(0,300));return r.result.value;};res({send,evalJs});});});}
8
+ const { send, evalJs } = await connect((await findTarget()).webSocketDebuggerUrl);
9
+ await send('Runtime.enable');
10
+ const log=(...a)=>console.error(...a);
11
+
12
+ const HELPERS=`
13
+ window.__btn=(txt)=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===txt);if(b){b.click();return true;}return false;};
14
+ window.__summarize=(a)=>{if(!a.length)return null;const s=a.slice().sort((x,y)=>x-y);const n=s.length,mean=a.reduce((p,v)=>p+v,0)/n;const pct=p=>s[Math.min(n-1,Math.floor(p*n))];const drop=a.filter(d=>d>14).length;return{frames:n,mean:+mean.toFixed(2),median:+pct(0.5).toFixed(2),p95:+pct(0.95).toFixed(2),max:+s[n-1].toFixed(2),fps_median:+(1000/pct(0.5)).toFixed(1),dropPct:+(100*drop/n).toFixed(1)};};
15
+ window.__dragPan=(d)=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve({error:'no-container'});const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;c.dispatchEvent(new MouseEvent('mousedown',{bubbles:true,clientX:cx,clientY:cy,button:0}));const iv=[];let last=performance.now();const t0=last;function f(now){iv.push(now-last);last=now;const e=now-t0,t=e/d,dx=Math.sin(t*Math.PI*4)*250;window.dispatchEvent(new MouseEvent('mousemove',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));if(e<d)requestAnimationFrame(f);else{window.dispatchEvent(new MouseEvent('mouseup',{bubbles:true,clientX:cx+dx,clientY:cy,button:0}));iv.shift();resolve(window.__summarize(iv));}}requestAnimationFrame(f);});
16
+ window.__wheelZoom=(d)=>new Promise(resolve=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));if(!c)return resolve({error:'no-container'});const r=c.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;const iv=[];let last=performance.now();const t0=last;function f(now){iv.push(now-last);last=now;const e=now-t0;const dir=Math.sin(e/250)>0?-1:1;c.dispatchEvent(new WheelEvent('wheel',{bubbles:true,cancelable:true,clientX:cx,clientY:cy,deltaY:dir*100}));if(e<d)requestAnimationFrame(f);else{iv.shift();resolve(window.__summarize(iv));}}requestAnimationFrame(f);});
17
+ // open Settings -> Configurations tab, set toggle to desired, read state, close.
18
+ window.__setToggle=async(desired)=>{
19
+ window.__btn('⚙'); await new Promise(r=>setTimeout(r,400));
20
+ window.__btn('Configurations'); await new Promise(r=>setTimeout(r,300));
21
+ const t=document.querySelector('[data-testid=smooth-pan-zoom-toggle]');
22
+ if(!t) return 'NO-TOGGLE';
23
+ if(t.checked!==desired) t.click();
24
+ const state={checked:document.querySelector('[data-testid=smooth-pan-zoom-toggle]').checked, ls:localStorage.getItem('cs4g-smooth-pan-zoom')};
25
+ if(!window.__btn('✕')){const fb=[...document.querySelectorAll('button')].find(b=>/cancel|close/i.test(b.textContent));if(fb)fb.click();}
26
+ await new Promise(r=>setTimeout(r,400));
27
+ return state;
28
+ };
29
+ 'ok';`;
30
+ await evalJs(HELPERS);
31
+ const TAB = process.env.TAB || 'Network (N)';
32
+ await evalJs(`window.__btn(${JSON.stringify(TAB)})`); await sleep(600);
33
+ log('tab:', TAB, 'visible svg:', await evalJs(`(()=>{const c=[...document.querySelectorAll('.svg-container')].find(e=>e.offsetParent!==null&&e.querySelector('svg'));return c?{nodes:c.querySelector('svg').querySelectorAll('*').length, halos:c.querySelectorAll('.nad-overloaded,.nad-contingency-highlight,.nad-delta-positive,.nad-delta-negative,.nad-action-target').length}:null;})()`));
34
+
35
+ const out={};
36
+ for (const mode of ['off','on']) {
37
+ const st = await evalJs(`window.__setToggle(${mode==='on'})`, true);
38
+ log(mode,'toggle state:', JSON.stringify(st));
39
+ await sleep(400);
40
+ await evalJs(`window.__btn(${JSON.stringify(TAB)})`); await sleep(400);
41
+ const pan = await evalJs(`window.__dragPan(2500)`, true);
42
+ await sleep(300);
43
+ const zoom = await evalJs(`window.__wheelZoom(2500)`, true);
44
+ out[mode]={toggle:st, pan, zoom};
45
+ log(mode,'pan',JSON.stringify(pan),'zoom',JSON.stringify(zoom));
46
+ }
47
+ console.log(JSON.stringify(out));
48
+ process.exit(0);
benchmarks/interaction_paint/bench_candidates.html ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>pan/zoom candidate-optimization bench</title>
6
+ <style>
7
+ html,body{margin:0;padding:0;background:#fff;font:13px monospace}
8
+ #stage{width:1400px;height:900px;position:relative;overflow:hidden;border:1px solid #ccc}
9
+ #out{padding:8px;white-space:pre}
10
+ /* Candidate-only CSS rules toggled by class on #c. Mirrors what a
11
+ real-app change would do — additive to App.css culling. */
12
+ /* C1: drop the 2nd polyline of every edge during gesture */
13
+ #c.cand-half-poly.svg-interacting .nad-edge > polyline:nth-of-type(2) { display:none !important; }
14
+ /* C2: content-visibility:auto on edge groups (skip off-screen raster) */
15
+ #c.cand-cv .nad-edge { content-visibility:auto; contain-intrinsic-size:1px 1px; }
16
+ /* C3: hide ALL edges during gesture, keep only bus nodes */
17
+ #c.cand-nodes-only.svg-interacting .nad-branch-edges { display:none !important; }
18
+ /* C4: hide bus circles during gesture, keep only edges */
19
+ #c.cand-edges-only.svg-interacting .nad-vl-nodes { display:none !important; }
20
+ /* C5: hide BOTH 2nd polyline AND bus circles (combined skeleton) */
21
+ #c.cand-skeleton.svg-interacting .nad-edge > polyline:nth-of-type(2),
22
+ #c.cand-skeleton.svg-interacting .nad-vl-nodes { display:none !important; }
23
+ /* C6: drop non-scaling-stroke during gesture (scaled stroke = cheaper raster?) */
24
+ #c.cand-no-nss.svg-interacting svg path,
25
+ #c.cand-no-nss.svg-interacting svg line,
26
+ #c.cand-no-nss.svg-interacting svg polyline,
27
+ #c.cand-no-nss.svg-interacting svg rect { vector-effect: none !important; }
28
+ /* C7: optimizeSpeed shape/text rendering during gesture (no AA) */
29
+ #c.cand-optspeed.svg-interacting svg { shape-rendering: optimizeSpeed; text-rendering: optimizeSpeed; }
30
+ /* C8: skeleton + optimizeSpeed + no-nss combined */
31
+ #c.cand-max.svg-interacting .nad-edge > polyline:nth-of-type(2),
32
+ #c.cand-max.svg-interacting .nad-vl-nodes { display:none !important; }
33
+ #c.cand-max.svg-interacting svg { shape-rendering: optimizeSpeed; }
34
+ #c.cand-max.svg-interacting svg polyline { vector-effect: none !important; }
35
+ /* C9: containment hint on the svg root during gesture */
36
+ #c.cand-contain.svg-interacting svg { contain: strict; }
37
+ /* C10: no-nss + half-poly (does dropping 2nd poly stack on top of no-nss?) */
38
+ #c.cand-nss-half.svg-interacting svg polyline { vector-effect: none !important; }
39
+ #c.cand-nss-half.svg-interacting .nad-edge > polyline:nth-of-type(2) { display:none !important; }
40
+ </style>
41
+ <style id="appcss"></style>
42
+ </head>
43
+ <body>
44
+ <div id="stage"><div class="svg-container" id="c"></div></div>
45
+ <div id="out">loading…</div>
46
+ <script>
47
+ const meetLocal = (vb, W, H) => { const a = Math.min(W / vb.w, H / vb.h); return { a, cx: (W - a * vb.w) / 2 - a * vb.x, cy: (H - a * vb.h) / 2 - a * vb.y }; };
48
+ let svg, base, baseSize, paths;
49
+ const c = document.getElementById('c');
50
+ const out = document.getElementById('out');
51
+
52
+ async function setup() {
53
+ const css = await (await fetch('/frontend/src/App.css')).text();
54
+ document.getElementById('appcss').textContent = css;
55
+ const svgText = await (await fetch('/benchmarks/interaction_paint/nad.svg')).text();
56
+ c.innerHTML = svgText;
57
+ svg = c.querySelector('svg');
58
+ const v = svg.getAttribute('viewBox').split(/\s+/).map(Number);
59
+ base = { x: v[0], y: v[1], w: v[2], h: v[3] };
60
+ baseSize = { w: c.clientWidth, h: c.clientHeight };
61
+ const bCx = base.x + base.w / 2, bCy = base.y + base.h / 2;
62
+ const pX = base.x + base.w * 0.60, pY = base.y + base.h * 0.42;
63
+ const DETAIL = base.w * 0.12;
64
+ const lerp = (a, b, t) => a + (b - a) * t, geom = (a, b, t) => a * Math.pow(b / a, t);
65
+ const vbAt = (cx, cy, w) => { const h = w * (base.h / base.w); return { x: cx - w / 2, y: cy - h / 2, w, h }; };
66
+ paths = {
67
+ pan: (t) => { const tt = 0.5 - 0.5 * Math.cos(2 * Math.PI * t); return vbAt(lerp(base.x + base.w*0.30, base.x + base.w*0.72, tt), pY, base.w * 0.22); },
68
+ zoom: (t) => { const tt = 0.5 - 0.5 * Math.cos(2 * Math.PI * t); return vbAt(lerp(bCx, pX, tt), lerp(bCy, pY, tt), geom(base.w * 0.9, DETAIL, tt)); },
69
+ };
70
+ out.textContent = 'ready: ' + JSON.stringify(base);
71
+ }
72
+
73
+ const applyVb = (vb) => svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
74
+ const CAND_CLASSES = ['cand-half-poly','cand-cv','cand-nodes-only','cand-edges-only','cand-skeleton','cand-no-nss','cand-optspeed','cand-max','cand-contain','cand-nss-half'];
75
+ function reset() {
76
+ c.classList.remove('svg-interacting', ...CAND_CLASSES);
77
+ svg.style.transform = ''; svg.style.willChange = ''; svg.style.transformOrigin = '';
78
+ applyVb(base);
79
+ }
80
+
81
+ let DROP_THRESH = 12;
82
+ // mode: 'cull' (baseline) or a candidate class name. All apply .svg-interacting
83
+ // (so App.css culling is active) PLUS the candidate class.
84
+ function runArm(mode, gesture, durationMs) {
85
+ return new Promise((resolve) => {
86
+ reset();
87
+ c.classList.add('svg-interacting');
88
+ if (mode !== 'cull') c.classList.add(mode);
89
+ const path = paths[gesture];
90
+ requestAnimationFrame(() => {
91
+ const intervals = []; let last = performance.now(); const t0 = last;
92
+ function frame(now) {
93
+ const dt = now - last; last = now; intervals.push(dt);
94
+ const elapsed = now - t0; const t = (elapsed / durationMs) % 1;
95
+ applyVb(path(t));
96
+ if (elapsed < durationMs) requestAnimationFrame(frame);
97
+ else { reset(); intervals.shift(); resolve(summarize(intervals)); }
98
+ }
99
+ requestAnimationFrame(frame);
100
+ });
101
+ });
102
+ }
103
+ function summarize(a) {
104
+ if (!a.length) return null;
105
+ const s = a.slice().sort((x, y) => x - y);
106
+ const n = s.length, mean = a.reduce((p, v) => p + v, 0) / n;
107
+ const pct = (p) => s[Math.min(n - 1, Math.floor(p * n))];
108
+ const dropped = a.filter(d => d > DROP_THRESH).length;
109
+ return { frames: n, mean: +mean.toFixed(2), median: +pct(0.5).toFixed(2), p95: +pct(0.95).toFixed(2), max: +s[n-1].toFixed(2), fps_median: +(1000/pct(0.5)).toFixed(1), dropPct: +(100*dropped/n).toFixed(1) };
110
+ }
111
+ function idleBaseline(durationMs) {
112
+ return new Promise((resolve) => {
113
+ reset();
114
+ const intervals = []; let last = performance.now(); const t0 = last;
115
+ function frame(now) { intervals.push(now - last); last = now; if (now - t0 < durationMs) requestAnimationFrame(frame); else { intervals.shift(); resolve(summarize(intervals)); } }
116
+ requestAnimationFrame(frame);
117
+ });
118
+ }
119
+
120
+ window.__runCandidates = async function (durationMs = 2500, reps = 3) {
121
+ const idle = await idleBaseline(1000);
122
+ DROP_THRESH = idle.median * 1.5;
123
+ const modes = ['cull', 'cand-no-nss', 'cand-nss-half'];
124
+ const result = { idle, dropThresh: +DROP_THRESH.toFixed(2), gestures: {} };
125
+ for (const g of ['pan', 'zoom']) {
126
+ const acc = {}; for (const m of modes) acc[m] = [];
127
+ for (let r = 0; r < reps; r++) {
128
+ for (const m of modes) { acc[m].push(await runArm(m, g, durationMs)); await new Promise(r => setTimeout(r, 150)); }
129
+ }
130
+ const agg = {};
131
+ for (const m of modes) {
132
+ const reps_ = acc[m];
133
+ const med = (k) => { const v = reps_.map(x => x[k]).sort((a, b) => a - b); return +v[Math.floor(v.length / 2)].toFixed(2); };
134
+ agg[m] = { median: med('median'), p95: med('p95'), mean: med('mean'), max: med('max'), fps_median: +(1000/med('median')).toFixed(1), dropPct: med('dropPct') };
135
+ }
136
+ result.gestures[g] = agg;
137
+ }
138
+ window.__candResult = result;
139
+ out.textContent = JSON.stringify(result, null, 2);
140
+ return result;
141
+ };
142
+ setup();
143
+ </script>
144
+ </body>
145
+ </html>
benchmarks/interaction_paint/bench_fluidity.html ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>pan/zoom fluidity bench</title>
6
+ <style>
7
+ html,body{margin:0;padding:0;background:#fff;font:13px monospace}
8
+ #stage{width:1400px;height:900px;position:relative;overflow:hidden;border:1px solid #ccc}
9
+ #out{padding:8px;white-space:pre}
10
+ </style>
11
+ <!-- App.css is injected at runtime from the served copy so the .svg-interacting
12
+ culling rules are byte-identical to the app. -->
13
+ <style id="appcss"></style>
14
+ <!-- Simulates the PRE-#2 state: re-force non-scaling-stroke on equipment during
15
+ a gesture (higher specificity than App.css #2's override) so we can A/B the
16
+ #2 quick win. Active only when the container also carries .nss-force. -->
17
+ <style id="nssforce">
18
+ .svg-container.svg-interacting.nss-force .nad-branch-edges path,
19
+ .svg-container.svg-interacting.nss-force .nad-branch-edges line,
20
+ .svg-container.svg-interacting.nss-force .nad-branch-edges polyline,
21
+ .svg-container.svg-interacting.nss-force .nad-branch-edges rect,
22
+ .svg-container.svg-interacting.nss-force .nad-vl-nodes path,
23
+ .svg-container.svg-interacting.nss-force .nad-vl-nodes line,
24
+ .svg-container.svg-interacting.nss-force .nad-vl-nodes polyline,
25
+ .svg-container.svg-interacting.nss-force .nad-vl-nodes rect {
26
+ vector-effect: non-scaling-stroke !important;
27
+ }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div id="stage"><div class="svg-container" id="c"></div></div>
32
+ <div id="out">loading…</div>
33
+ <script>
34
+ // ---- exact port of usePanZoom's smooth-mode math (commit b451ee3) ----
35
+ const meetLocal = (vb, W, H) => {
36
+ const a = Math.min(W / vb.w, H / vb.h);
37
+ return { a, cx: (W - a * vb.w) / 2 - a * vb.x, cy: (H - a * vb.h) / 2 - a * vb.y };
38
+ };
39
+ const interactionTransform = (base, target, W, H) => {
40
+ if (!(W > 0 && H > 0 && base.w > 0 && base.h > 0 && target.w > 0 && target.h > 0)) return null;
41
+ const lb = meetLocal(base, W, H), lt = meetLocal(target, W, H);
42
+ const s = lt.a / lb.a, tx = lt.cx - s * lb.cx, ty = lt.cy - s * lb.cy;
43
+ if (!Number.isFinite(s) || !Number.isFinite(tx) || !Number.isFinite(ty)) return null;
44
+ return `translate(${tx}px, ${ty}px) scale(${s})`;
45
+ };
46
+
47
+ let svg, base, baseSize, paths;
48
+ const c = document.getElementById('c');
49
+ const out = document.getElementById('out');
50
+
51
+ async function setup() {
52
+ const css = await (await fetch('/frontend/src/App.css')).text();
53
+ document.getElementById('appcss').textContent = css;
54
+ const svgFile = new URLSearchParams(location.search).get('svg') || 'nad.svg';
55
+ const svgText = await (await fetch('/benchmarks/interaction_paint/' + svgFile)).text();
56
+ c.innerHTML = svgText;
57
+ svg = c.querySelector('svg');
58
+ const v = svg.getAttribute('viewBox').split(/\s+/).map(Number);
59
+ base = { x: v[0], y: v[1], w: v[2], h: v[3] };
60
+ baseSize = { w: c.clientWidth, h: c.clientHeight };
61
+ // Build pan & zoom parametric paths (viewBox as a function of t in [0,1]).
62
+ const bCx = base.x + base.w / 2, bCy = base.y + base.h / 2;
63
+ const pX = base.x + base.w * 0.60, pY = base.y + base.h * 0.42;
64
+ const DETAIL = base.w * 0.12;
65
+ const lerp = (a, b, t) => a + (b - a) * t, geom = (a, b, t) => a * Math.pow(b / a, t);
66
+ const vbAt = (cx, cy, w) => { const h = w * (base.h / base.w); return { x: cx - w / 2, y: cy - h / 2, w, h }; };
67
+ paths = {
68
+ // pan at a fixed "region" zoom level, sweeping across the grid and back
69
+ pan: (t) => { const tt = 0.5 - 0.5 * Math.cos(2 * Math.PI * t); return vbAt(lerp(base.x + base.w*0.30, base.x + base.w*0.72, tt), pY, base.w * 0.22); },
70
+ // zoom from overview into detail and back out (ping-pong)
71
+ zoom: (t) => { const tt = 0.5 - 0.5 * Math.cos(2 * Math.PI * t); return vbAt(lerp(bCx, pX, tt), lerp(bCy, pY, tt), geom(base.w * 0.9, DETAIL, tt)); },
72
+ };
73
+ out.textContent = 'ready: ' + JSON.stringify(base);
74
+ }
75
+
76
+ const applyVb = (vb) => svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
77
+ function reset() {
78
+ c.classList.remove('svg-interacting');
79
+ c.classList.remove('nss-force');
80
+ svg.style.transform = ''; svg.style.willChange = ''; svg.style.transformOrigin = '';
81
+ applyVb(base);
82
+ }
83
+
84
+ // Run one arm: mode in {plain, cull, gpu}; gesture in {pan, zoom}.
85
+ // Drives a time-parametrised animation for durationMs and records the
86
+ // real per-frame rAF interval distribution (the fluidity metric).
87
+ let DROP_THRESH = 24;
88
+ function runArm(mode, gesture, durationMs) {
89
+ return new Promise((resolve) => {
90
+ reset();
91
+ const cull = (mode === 'cull' || mode === 'gpu' || mode === 'cull_nss');
92
+ if (cull) c.classList.add('svg-interacting');
93
+ if (mode === 'cull_nss') c.classList.add('nss-force');
94
+ if (mode === 'gpu') { svg.style.transformOrigin = '0 0'; svg.style.willChange = 'transform'; }
95
+ const path = paths[gesture];
96
+ // settle on first frame, then measure
97
+ requestAnimationFrame(() => {
98
+ const intervals = [];
99
+ let last = performance.now();
100
+ const t0 = last;
101
+ function frame(now) {
102
+ const dt = now - last; last = now;
103
+ intervals.push(dt);
104
+ const elapsed = now - t0;
105
+ const t = (elapsed / durationMs) % 1;
106
+ const vb = path(t);
107
+ if (mode === 'gpu') {
108
+ const tr = interactionTransform(base, vb, baseSize.w, baseSize.h);
109
+ if (tr) svg.style.transform = tr; else applyVb(vb);
110
+ } else {
111
+ applyVb(vb);
112
+ }
113
+ if (elapsed < durationMs) requestAnimationFrame(frame);
114
+ else {
115
+ // settle (bake) like endInteraction
116
+ if (mode === 'gpu') { svg.style.transform = ''; svg.style.willChange = ''; applyVb(vb); }
117
+ c.classList.remove('svg-interacting');
118
+ applyVb(base);
119
+ intervals.shift(); // drop first (warm-up)
120
+ resolve(summarize(intervals));
121
+ }
122
+ }
123
+ requestAnimationFrame(frame);
124
+ });
125
+ });
126
+ }
127
+
128
+ function summarize(a) {
129
+ if (!a.length) return null;
130
+ const s = a.slice().sort((x, y) => x - y);
131
+ const n = s.length, mean = a.reduce((p, v) => p + v, 0) / n;
132
+ const pct = (p) => s[Math.min(n - 1, Math.floor(p * n))];
133
+ const dropped = a.filter(d => d > DROP_THRESH).length;
134
+ return {
135
+ frames: n,
136
+ mean: +mean.toFixed(2),
137
+ median: +pct(0.5).toFixed(2),
138
+ p95: +pct(0.95).toFixed(2),
139
+ max: +s[n - 1].toFixed(2),
140
+ fps_median: +(1000 / pct(0.5)).toFixed(1),
141
+ dropped, dropPct: +(100 * dropped / n).toFixed(1),
142
+ };
143
+ }
144
+
145
+ // Idle baseline: measure the display refresh period with no work.
146
+ function idleBaseline(durationMs) {
147
+ return new Promise((resolve) => {
148
+ reset();
149
+ const intervals = []; let last = performance.now(); const t0 = last;
150
+ function frame(now) { intervals.push(now - last); last = now; if (now - t0 < durationMs) requestAnimationFrame(frame); else { intervals.shift(); resolve(summarize(intervals)); } }
151
+ requestAnimationFrame(frame);
152
+ });
153
+ }
154
+
155
+ // Full interleaved suite. Returns a JSON-able result object.
156
+ window.__runSuite = async function (durationMs = 2500, reps = 4) {
157
+ const idle = await idleBaseline(1000);
158
+ DROP_THRESH = idle.median * 1.5;
159
+ const result = { idle, dropThresh: +DROP_THRESH.toFixed(2), gestures: {} };
160
+ for (const g of ['pan', 'zoom']) {
161
+ const acc = { plain: [], cull: [], gpu: [] };
162
+ for (let r = 0; r < reps; r++) {
163
+ for (const mode of ['plain', 'cull', 'gpu']) {
164
+ const s = await runArm(mode, g, durationMs);
165
+ acc[mode].push(s);
166
+ await new Promise(r => setTimeout(r, 150));
167
+ }
168
+ }
169
+ // aggregate medians across reps
170
+ const agg = {};
171
+ for (const mode of ['plain', 'cull', 'gpu']) {
172
+ const reps_ = acc[mode];
173
+ const med = (k) => { const v = reps_.map(x => x[k]).sort((a, b) => a - b); return +v[Math.floor(v.length / 2)].toFixed(2); };
174
+ // dropped-frame %: re-derive from intervals not stored; approximate via median/p95 vs thresh per rep
175
+ agg[mode] = {
176
+ median: med('median'), p95: med('p95'), mean: med('mean'), max: med('max'),
177
+ fps_median: +(1000 / med('median')).toFixed(1),
178
+ dropPct: med('dropPct'),
179
+ reps: reps_,
180
+ };
181
+ }
182
+ result.gestures[g] = agg;
183
+ }
184
+ window.__result = result;
185
+ out.textContent = JSON.stringify(result, null, 2);
186
+ return result;
187
+ };
188
+
189
+ // ---- Diagnostic arms for "why isn't GPU compositing free?" ----
190
+
191
+ // Pure ±2px translate of the will-change'd SVG layer: if the layer is truly
192
+ // composited and reused, this should run at display refresh; if Chrome
193
+ // re-rasters the vector layer anyway, it stays slow.
194
+ function runMicro(durationMs) {
195
+ return new Promise((resolve) => {
196
+ reset();
197
+ c.classList.add('svg-interacting');
198
+ svg.style.transformOrigin = '0 0'; svg.style.willChange = 'transform';
199
+ requestAnimationFrame(() => {
200
+ const intervals = []; let last = performance.now(); const t0 = last;
201
+ function frame(now) {
202
+ intervals.push(now - last); last = now;
203
+ const e = now - t0;
204
+ const dx = 2 * Math.sin(e / 30);
205
+ svg.style.transform = `translate(${dx}px, 0px) scale(1)`;
206
+ if (e < durationMs) requestAnimationFrame(frame);
207
+ else { svg.style.transform = ''; svg.style.willChange = ''; c.classList.remove('svg-interacting'); intervals.shift(); resolve(summarize(intervals)); }
208
+ }
209
+ requestAnimationFrame(frame);
210
+ });
211
+ });
212
+ }
213
+
214
+ // Bitmap-snapshot arm: rasterise the SVG to a canvas ONCE at gesture start,
215
+ // transform the (small) bitmap layer during the gesture, restore the live
216
+ // SVG on settle. This is the "snapshot during gesture" optimisation — if it
217
+ // hits refresh rate it proves the bottleneck is vector re-raster, not compositing.
218
+ async function snapshotCanvas() {
219
+ const clone = svg.cloneNode(true);
220
+ // Drop the HTML <foreignObject> labels: they taint the canvas (SecurityError
221
+ // on drawImage) and are culled mid-gesture anyway.
222
+ clone.querySelectorAll('foreignObject, .nad-label-nodes').forEach(n => n.remove());
223
+ clone.setAttribute('width', baseSize.w);
224
+ clone.setAttribute('height', baseSize.h);
225
+ clone.setAttribute('viewBox', `${base.x} ${base.y} ${base.w} ${base.h}`);
226
+ const xml = new XMLSerializer().serializeToString(clone);
227
+ const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
228
+ const img = new Image();
229
+ await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; });
230
+ const dpr = window.devicePixelRatio || 1;
231
+ let cv = document.getElementById('snap');
232
+ if (!cv) { cv = document.createElement('canvas'); cv.id = 'snap'; cv.style.position = 'absolute'; cv.style.left = '0'; cv.style.top = '0'; cv.style.width = baseSize.w + 'px'; cv.style.height = baseSize.h + 'px'; document.getElementById('stage').appendChild(cv); }
233
+ cv.width = Math.round(baseSize.w * dpr); cv.height = Math.round(baseSize.h * dpr);
234
+ const ctx = cv.getContext('2d');
235
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
236
+ ctx.clearRect(0, 0, baseSize.w, baseSize.h);
237
+ ctx.drawImage(img, 0, 0, baseSize.w, baseSize.h);
238
+ URL.revokeObjectURL(url);
239
+ return cv;
240
+ }
241
+
242
+ async function runBitmap(gesture, durationMs) {
243
+ reset();
244
+ const cv = await snapshotCanvas();
245
+ svg.style.visibility = 'hidden';
246
+ cv.style.display = 'block'; cv.style.transformOrigin = '0 0'; cv.style.willChange = 'transform';
247
+ const path = paths[gesture];
248
+ return await new Promise((resolve) => {
249
+ requestAnimationFrame(() => {
250
+ const intervals = []; let last = performance.now(); const t0 = last;
251
+ function frame(now) {
252
+ intervals.push(now - last); last = now;
253
+ const e = now - t0; const t = (e / durationMs) % 1; const vb = path(t);
254
+ const tr = interactionTransform(base, vb, baseSize.w, baseSize.h);
255
+ if (tr) cv.style.transform = tr;
256
+ if (e < durationMs) requestAnimationFrame(frame);
257
+ else { cv.style.display = 'none'; cv.style.transform = ''; cv.style.willChange = ''; svg.style.visibility = ''; applyVb(base); intervals.shift(); resolve(summarize(intervals)); }
258
+ }
259
+ requestAnimationFrame(frame);
260
+ });
261
+ });
262
+ }
263
+
264
+ window.__runExtra = async function (durationMs = 2500, reps = 3) {
265
+ const idle = await idleBaseline(1000);
266
+ DROP_THRESH = idle.median * 1.5;
267
+ const micro = []; for (let r = 0; r < reps; r++) { micro.push(await runMicro(durationMs)); await new Promise(r => setTimeout(r, 150)); }
268
+ const bmp = { pan: [], zoom: [] };
269
+ for (const g of ['pan', 'zoom']) for (let r = 0; r < reps; r++) { bmp[g].push(await runBitmap(g, durationMs)); await new Promise(r => setTimeout(r, 150)); }
270
+ const med = (arr, k) => { const v = arr.map(x => x[k]).sort((a, b) => a - b); return +v[Math.floor(v.length / 2)].toFixed(2); };
271
+ const result = {
272
+ idle, dropThresh: +DROP_THRESH.toFixed(2),
273
+ micro_translate: { median: med(micro, 'median'), p95: med(micro, 'p95'), fps_median: +(1000 / med(micro, 'median')).toFixed(1), dropPct: med(micro, 'dropPct'), reps: micro },
274
+ bitmap_pan: { median: med(bmp.pan, 'median'), p95: med(bmp.pan, 'p95'), fps_median: +(1000 / med(bmp.pan, 'median')).toFixed(1), dropPct: med(bmp.pan, 'dropPct'), reps: bmp.pan },
275
+ bitmap_zoom: { median: med(bmp.zoom, 'median'), p95: med(bmp.zoom, 'p95'), fps_median: +(1000 / med(bmp.zoom, 'median')).toFixed(1), dropPct: med(bmp.zoom, 'dropPct'), reps: bmp.zoom },
276
+ };
277
+ window.__extra = result; out.textContent = JSON.stringify(result, null, 2);
278
+ return result;
279
+ };
280
+
281
+ // A/B for the #2 quick win: cull_nss (pre-#2, non-scaling-stroke re-forced)
282
+ // vs cull (current App.css with #2 dropping it). Same cull window otherwise.
283
+ window.__runNss = async function (durationMs = 2500, reps = 4) {
284
+ const idle = await idleBaseline(1000);
285
+ DROP_THRESH = idle.median * 1.5;
286
+ const result = { idle, dropThresh: +DROP_THRESH.toFixed(2), gestures: {} };
287
+ for (const g of ['pan', 'zoom']) {
288
+ const acc = { cull_nss: [], cull: [] };
289
+ for (let r = 0; r < reps; r++) {
290
+ for (const mode of ['cull_nss', 'cull']) {
291
+ acc[mode].push(await runArm(mode, g, durationMs));
292
+ await new Promise(r => setTimeout(r, 150));
293
+ }
294
+ }
295
+ const med = (arr, k) => { const v = arr.map(x => x[k]).sort((a, b) => a - b); return +v[Math.floor(v.length / 2)].toFixed(2); };
296
+ const agg = {};
297
+ for (const mode of ['cull_nss', 'cull']) agg[mode] = { median: med(acc[mode], 'median'), p95: med(acc[mode], 'p95'), fps_median: +(1000 / med(acc[mode], 'median')).toFixed(1), reps: acc[mode] };
298
+ agg.speedup = +(agg.cull_nss.median / agg.cull.median).toFixed(2);
299
+ result.gestures[g] = agg;
300
+ }
301
+ window.__nss = result; out.textContent = JSON.stringify(result, null, 2);
302
+ return result;
303
+ };
304
+
305
+ setup();
306
+ </script>
307
+ </body>
308
+ </html>
benchmarks/interaction_paint/bench_pan_zoom.mjs ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
2
+ // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
3
+ // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
4
+ // you can obtain one at http://mozilla.org/MPL/2.0/.
5
+ // SPDX-License-Identifier: MPL-2.0
6
+ //
7
+ // Measure pan/zoom paint cost of the large-grid NAD with and without the
8
+ // interaction-time culling rule (`.svg-interacting` in App.css), driving a
9
+ // real (headless) Chromium. Primary metric: time-to-painted-frame via
10
+ // double-rAF, with `--run-all-compositor-stages-before-draw` so each
11
+ // frame's full raster+composite cost is captured synchronously.
12
+ //
13
+ // "plain" = no class (labels + flow infos painted every frame)
14
+ // "cull" = `.svg-interacting` (App.css culls the expensive paint
15
+ // elements for the gesture window)
16
+ //
17
+ // The two modes are interleaved across reps to cancel ordering / raster-
18
+ // cache effects. See README.md for setup + the software-GL caveat.
19
+ //
20
+ // node bench_pan_zoom.mjs # uses ./nad.svg + ../../frontend/src/App.css
21
+ import { readFileSync, writeFileSync } from 'fs';
22
+ import { createRequire } from 'module';
23
+ import { fileURLToPath } from 'url';
24
+ import { dirname, resolve } from 'path';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const require = createRequire(resolve(__dirname, '../../frontend/'));
28
+ const { chromium } = require('playwright');
29
+
30
+ // A pre-installed Chromium (Playwright build or system Chrome). Override
31
+ // with PW_CHROME=/path/to/chrome when the default is absent.
32
+ const CHROME = process.env.PW_CHROME || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
33
+ const ARGS = ['--no-sandbox', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
34
+ '--enable-gpu-rasterization', '--disable-frame-rate-limit',
35
+ '--run-all-compositor-stages-before-draw', '--disable-renderer-backgrounding'];
36
+
37
+ const W = 1400, H = 900, REPS = 6, APPLIES = 20;
38
+ const svg = readFileSync(resolve(__dirname, 'nad.svg'), 'utf8');
39
+ const css = readFileSync(resolve(__dirname, '../../frontend/src/App.css'), 'utf8');
40
+ const stats = JSON.parse(readFileSync(resolve(__dirname, 'nad.stats.json'), 'utf8'));
41
+
42
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>
43
+ html,body{margin:0;padding:0;background:#fff}#stage{width:${W}px;height:${H}px;position:relative}
44
+ ${css}
45
+ </style></head><body><div id="stage"><div class="svg-container" id="c">${svg}</div></div></body></html>`;
46
+
47
+ const summarize = (a) => {
48
+ if (!a || !a.length) return null;
49
+ a = a.slice().sort((x, y) => x - y);
50
+ const n = a.length, mean = a.reduce((s, v) => s + v, 0) / n;
51
+ const pct = (p) => a[Math.min(n - 1, Math.floor(p * n))];
52
+ return { n, mean: +mean.toFixed(1), median: +pct(0.5).toFixed(1), p95: +pct(0.95).toFixed(1) };
53
+ };
54
+ const withTimeout = (p, ms, t) => Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error('timeout ' + t)), ms))]);
55
+
56
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ARGS });
57
+ const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
58
+ page.on('pageerror', (e) => console.error('PAGEERR', e.message));
59
+ await page.setContent(html, { waitUntil: 'load' });
60
+ await page.waitForTimeout(600);
61
+
62
+ await page.evaluate(([APPLIES]) => {
63
+ const c = document.getElementById('c'), svg = c.querySelector('svg');
64
+ const W = c.clientWidth, H = c.clientHeight;
65
+ const v = svg.getAttribute('viewBox').split(/\s+/).map(Number);
66
+ const base = { x: v[0], y: v[1], w: v[2], h: v[3] };
67
+ const vbAt = (cx, cy, w) => { const h = w * (base.h / base.w); return { x: cx - w / 2, y: cy - h / 2, w, h }; };
68
+ const lerp = (a, b, t) => a + (b - a) * t, geom = (a, b, t) => a * Math.pow(b / a, t);
69
+ const bCx = base.x + base.w / 2, bCy = base.y + base.h / 2;
70
+ const pX = base.x + base.w * 0.62, pY = base.y + base.h * 0.45, DETAIL = base.w * 0.12;
71
+ const zin = [], pan = [];
72
+ for (let i = 0; i < APPLIES; i++) {
73
+ const t = i / (APPLIES - 1);
74
+ zin.push(vbAt(lerp(bCx, pX, t), lerp(bCy, pY, t), geom(base.w, DETAIL, t)));
75
+ pan.push(vbAt(pX + base.w * 0.5 * t, pY, DETAIL));
76
+ }
77
+ window.__g = { zoomIn: zin, pan };
78
+ const applyVb = (vb) => svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
79
+ const reset = () => { c.classList.remove('svg-interacting'); applyVb(base); };
80
+ const nextPaint = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
81
+ window.__one = async (interacting, key) => {
82
+ reset();
83
+ if (interacting) c.classList.add('svg-interacting');
84
+ await nextPaint();
85
+ const times = [];
86
+ for (const vb of window.__g[key]) { const t0 = performance.now(); applyVb(vb); await nextPaint(); times.push(performance.now() - t0); }
87
+ reset();
88
+ return times;
89
+ };
90
+ }, [APPLIES]);
91
+
92
+ console.log('grid ' + stats.voltageLevels + ' VL / ' + stats.branches + ' branches / ' + stats.bytesMB + ' MB SVG | viewport ' + W + 'x' + H);
93
+ console.log('time-to-painted-frame (double-rAF), interleaved plain vs cull, ' + REPS + ' reps, headless software-GL\n');
94
+ const result = { grid: stats, phases: {} };
95
+ for (const g of ['pan', 'zoomIn']) {
96
+ const plain = [], cull = [];
97
+ for (let r = 0; r < REPS; r++) {
98
+ try { plain.push(...await withTimeout(page.evaluate(([i, k]) => window.__one(i, k), [false, g]), 90000, 'plain')); } catch (e) { console.error('plain fail', e.message); }
99
+ await page.waitForTimeout(80);
100
+ try { cull.push(...await withTimeout(page.evaluate(([i, k]) => window.__one(i, k), [true, g]), 90000, 'cull')); } catch (e) { console.error('cull fail', e.message); }
101
+ await page.waitForTimeout(80);
102
+ }
103
+ const sp = summarize(plain), sc = summarize(cull);
104
+ result.phases[g] = { plain: sp, cull: sc };
105
+ console.log(g.padEnd(8) + ' plain mean ' + sp.mean + 'ms median ' + sp.median + 'ms p95 ' + sp.p95 + 'ms');
106
+ console.log(g.padEnd(8) + ' cull mean ' + sc.mean + 'ms median ' + sc.median + 'ms p95 ' + sc.p95 + 'ms');
107
+ console.log(' => culling speedup: mean ' + (sp.mean / sc.mean).toFixed(1) + 'x median ' + (sp.median / sc.median).toFixed(1) + 'x\n');
108
+ }
109
+ writeFileSync(resolve(__dirname, 'result.json'), JSON.stringify(result, null, 2));
110
+ await browser.close();
111
+ console.log('done');
benchmarks/interaction_paint/capture.mjs ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PORT=9444; const sleep=ms=>new Promise(r=>setTimeout(r,ms));
2
+ import { writeFileSync } from 'fs';
3
+ const list=await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
4
+ const t=list.find(x=>x.type==='page'&&/localhost:5173/.test(x.url));
5
+ const ws=new WebSocket(t.webSocketDebuggerUrl); let id=0; const p=new Map();
6
+ ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data);if(m.id&&p.has(m.id)){const{r}=p.get(m.id);p.delete(m.id);r(m.result);}});
7
+ await new Promise(r=>ws.addEventListener('open',r));
8
+ const send=(method,params={})=>new Promise(r=>{const mid=++id;p.set(mid,{r});ws.send(JSON.stringify({id:mid,method,params}));});
9
+ await send('Page.enable');
10
+ const {data}=await send('Page.captureScreenshot',{format:'jpeg',quality:72});
11
+ writeFileSync('/tmp/cs4g_app_n1.jpg', Buffer.from(data,'base64'));
12
+ console.log('saved /tmp/cs4g_app_n1.jpg');
13
+ process.exit(0);
benchmarks/interaction_paint/cdp_driver.mjs ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Drive the bench_fluidity.html harness in a headed Chrome via the DevTools
2
+ // Protocol (Node 22 global WebSocket + fetch — no npm deps). Chrome must be
3
+ // running with --remote-debugging-port=9333 and the no-throttle flags so the
4
+ // page keeps rendering at full GPU speed even while occluded.
5
+ const PORT = process.env.CDP_PORT || 9333;
6
+ const URLMATCH = 'bench_fluidity.html';
7
+ const DURATION = +(process.env.DUR || 2500);
8
+ const REPS = +(process.env.REPS || 3);
9
+
10
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
11
+
12
+ async function findTarget() {
13
+ const wantAny = !!process.env.NAV || process.env.ANYPAGE === '1';
14
+ for (let i = 0; i < 60; i++) {
15
+ try {
16
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
17
+ const t = wantAny
18
+ ? list.find(x => x.type === 'page' && /^https?:|^about:|chrome:\/\/newtab/.test(x.url))
19
+ : list.find(x => x.type === 'page' && x.url.includes(URLMATCH));
20
+ if (t && t.webSocketDebuggerUrl) return t;
21
+ } catch { /* port not up yet */ }
22
+ await sleep(500);
23
+ }
24
+ throw new Error('no page target found (wantAny=' + wantAny + ')');
25
+ }
26
+
27
+ function connect(wsUrl) {
28
+ return new Promise((resolve, reject) => {
29
+ const ws = new WebSocket(wsUrl);
30
+ let id = 0;
31
+ const pending = new Map();
32
+ ws.addEventListener('message', (ev) => {
33
+ const msg = JSON.parse(ev.data);
34
+ if (msg.id && pending.has(msg.id)) {
35
+ const { resolve: r, reject: j } = pending.get(msg.id);
36
+ pending.delete(msg.id);
37
+ if (msg.error) j(new Error(JSON.stringify(msg.error))); else r(msg.result);
38
+ }
39
+ });
40
+ ws.addEventListener('error', (e) => reject(new Error('ws error ' + (e.message || ''))));
41
+ ws.addEventListener('open', () => {
42
+ const send = (method, params = {}) => new Promise((r, j) => {
43
+ const mid = ++id; pending.set(mid, { resolve: r, reject: j });
44
+ ws.send(JSON.stringify({ id: mid, method, params }));
45
+ });
46
+ const evalJs = async (expr, awaitPromise = false) => {
47
+ const res = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise });
48
+ if (res.exceptionDetails) throw new Error('eval: ' + JSON.stringify(res.exceptionDetails));
49
+ return res.result.value;
50
+ };
51
+ resolve({ send, evalJs, ws });
52
+ });
53
+ });
54
+ }
55
+
56
+ const EXPR = process.env.EXPR || `window.__runSuite(${DURATION}, ${REPS})`;
57
+ const { evalJs, send } = await connect((await findTarget()).webSocketDebuggerUrl);
58
+ await send('Runtime.enable');
59
+ await send('Page.enable');
60
+ if (process.env.NAV) { await send('Page.navigate', { url: process.env.NAV }); await sleep(2500); }
61
+ else if (process.env.RELOAD === '1') { await send('Page.reload', { ignoreCache: true }); await sleep(1500); }
62
+
63
+ // Wait for harness readiness.
64
+ for (let i = 0; i < 60 && process.env.SKIPREADY !== '1'; i++) {
65
+ const ready = await evalJs(`(typeof window.__runSuite === 'function' && typeof window.__runExtra === 'function' && document.getElementById('out') && document.getElementById('out').textContent.startsWith('ready'))`);
66
+ if (ready) break;
67
+ await sleep(500);
68
+ }
69
+
70
+ const env = await evalJs(`(()=>{return {visibility:document.visibilityState, dpr:window.devicePixelRatio, url:location.href};})()`);
71
+ console.error('ENV', JSON.stringify(env));
72
+ if (env.visibility !== 'visible') console.error('WARNING: visibility=' + env.visibility + ' — rAF may be throttled. Anti-throttle flags should keep it running anyway.');
73
+
74
+ const result = await evalJs(EXPR, true);
75
+ console.log(JSON.stringify(result));
76
+ process.exit(0);
benchmarks/interaction_paint/cdp_pipe_driver.mjs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Launch a headed Chrome via --remote-debugging-pipe (CDP over stdio fds 3/4,
2
+ // NO TCP port — so the IDE's preview/live-reload can't discover and hijack it)
3
+ // and run the fluidity harness. Fully isolated + real GPU (headed on macOS).
4
+ import { spawn } from 'child_process';
5
+ import { openSync } from 'fs';
6
+
7
+ const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
8
+ const URL = process.env.URL || 'http://127.0.0.1:8901/real.html';
9
+ const EXPR = process.env.EXPR || `window.__runSuite(${+(process.env.DUR || 2500)}, ${+(process.env.REPS || 3)})`;
10
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
11
+
12
+ const log = openSync('/tmp/cs4g_pipe_chrome.log', 'a');
13
+ const child = spawn(CHROME, [
14
+ '--user-data-dir=/tmp/cs4g-bench-pipe',
15
+ '--remote-debugging-pipe',
16
+ '--no-first-run', '--no-default-browser-check', '--no-startup-window=false',
17
+ '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
18
+ '--disable-background-timer-throttling', '--disable-features=CalculateNativeWinOcclusion',
19
+ '--window-size=1500,1000', URL,
20
+ ], { stdio: ['ignore', log, log, 'pipe', 'pipe'] });
21
+
22
+ const wr = child.stdio[3], rd = child.stdio[4];
23
+ let id = 0; const pending = new Map();
24
+ const events = [];
25
+ let buf = Buffer.alloc(0);
26
+ rd.on('data', (chunk) => {
27
+ buf = Buffer.concat([buf, chunk]);
28
+ let i;
29
+ while ((i = buf.indexOf(0)) !== -1) {
30
+ const msg = JSON.parse(buf.slice(0, i).toString('utf8'));
31
+ buf = buf.slice(i + 1);
32
+ if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); }
33
+ else if (msg.method) events.push(msg);
34
+ }
35
+ });
36
+ const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
37
+ const mid = ++id; pending.set(mid, { res, rej });
38
+ const m = { id: mid, method, params }; if (sessionId) m.sessionId = sessionId;
39
+ wr.write(JSON.stringify(m) + '\0');
40
+ });
41
+
42
+ // Discover the page target and attach (flattened sessions).
43
+ await send('Target.setDiscoverTargets', { discover: true });
44
+ let targetId = null;
45
+ for (let i = 0; i < 80 && !targetId; i++) {
46
+ const { targetInfos } = await send('Target.getTargets');
47
+ const t = targetInfos.find(x => x.type === 'page' && /^https?:/.test(x.url));
48
+ if (t) targetId = t.targetId; else await sleep(250);
49
+ }
50
+ if (!targetId) { console.error('no page target'); child.kill(); process.exit(1); }
51
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
52
+ const evalJs = async (expr, awaitPromise = false) => {
53
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise }, sessionId);
54
+ if (r.exceptionDetails) throw new Error('eval: ' + JSON.stringify(r.exceptionDetails).slice(0, 400));
55
+ return r.result.value;
56
+ };
57
+ await send('Runtime.enable', {}, sessionId);
58
+
59
+ // If the page isn't on our URL yet (fresh profile may show a default), navigate once.
60
+ const cur = await evalJs('location.href');
61
+ if (!cur.includes(URL.split('/').pop())) { await send('Page.enable', {}, sessionId); await send('Page.navigate', { url: URL }, sessionId); await sleep(2500); }
62
+
63
+ for (let i = 0; i < 80; i++) {
64
+ const ready = await evalJs(`(typeof window.__runSuite==='function' && typeof window.__runExtra==='function' && document.getElementById('out') && document.getElementById('out').textContent.startsWith('ready'))`).catch(() => false);
65
+ if (ready) break; await sleep(500);
66
+ }
67
+ const env = await evalJs(`(()=>{const gl=document.createElement('canvas').getContext('webgl');const d=gl&&gl.getExtension('WEBGL_debug_renderer_info');return {visibility:document.visibilityState,dpr:window.devicePixelRatio,gpu:d?gl.getParameter(d.UNMASKED_RENDERER_WEBGL):'n/a',svgChildren:document.querySelectorAll('#c svg *').length, url:location.href};})()`);
68
+ console.error('ENV', JSON.stringify(env));
69
+
70
+ const result = await evalJs(EXPR, true);
71
+ console.log(JSON.stringify(result));
72
+ child.kill();
73
+ process.exit(0);
benchmarks/interaction_paint/fluidity_result.phaseA.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {"env":{"gpu":"Apple M4 Pro (ANGLE Metal)","dpr":1,"display_hz":120,"idle_median_ms":8.3,"svgChildren":132905,"grid":"pypsa_eur_eur220_225_380_400 synthetic NAD (5247 VL / 8205 br / 7.26MB)"},
2
+ "main":{"pan":{"plain":91.7,"cull":58.3,"gpu":49.8},"zoom":{"plain":108.6,"cull":65.0,"gpu":49.6}},
3
+ "diagnostic":{"micro_translate_median_ms":48.0,"bitmap_pan_median_ms":8.3,"bitmap_zoom_median_ms":8.3}}
benchmarks/interaction_paint/fluidity_result.realApp.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {"env":{"app":"localhost:5173 + backend:8000","gpu":"Apple M4 Pro Metal","dpr":1,"hz":120},
2
+ "N_tab":{"off":{"pan":60.1,"zoom":75.0},"on":{"pan":50.0,"zoom":50.1}},
3
+ "N1_tab":{"off":{"pan":82.6,"zoom":83.9},"on":{"pan":58.4,"zoom":66.7},"contingency":"T_relation_13260100-400-225 (HAVRE LE CENTRALE)","svgNodes":104213}}
benchmarks/interaction_paint/fluidity_result.realNAD.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"env":{"gpu":"Apple M4 Pro (ANGLE Metal)","dpr":1,"display_hz":120,"idle_median_ms":8.3,"svgChildren":98959,"svgMB":9.1,"labels":"<text> (15706), not foreignObject","grid":"pypsa_eur_eur220_225_380_400 REAL pypowsybl NAD"},
2
+ "main":{"pan":{"plain":100.0,"cull":58.0,"gpu":41.9},"zoom":{"plain":126.7,"cull":65.1,"gpu":43.3}},
3
+ "diagnostic":{"micro_translate_median_ms":41.7,"bitmap_pan_median_ms":8.3,"bitmap_zoom_median_ms":8.3},
4
+ "notes":"zoom plain p95 spikes to ~1008ms (text-label re-raster). bitmap=120fps/0-drop on real NAD too."}