Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
178e60f
0
Parent(s):
Deploy 0f87067
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/hooks/init-sync-check.sh +108 -0
- .claude/settings.json +14 -0
- .dockerignore +33 -0
- .editorconfig +15 -0
- .env.example +32 -0
- .gitattributes +7 -0
- .github/workflows/canary.yml +34 -0
- .github/workflows/code-quality.yml +64 -0
- .github/workflows/deploy-huggingface.yml +124 -0
- .github/workflows/parity.yml +253 -0
- .github/workflows/test.yml +163 -0
- .gitignore +50 -0
- AUTHORS.txt +15 -0
- CHANGELOG.md +0 -0
- CLAUDE.md +549 -0
- CONTRIBUTING.md +153 -0
- Dockerfile +108 -0
- LICENSE.md +373 -0
- Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html +0 -0
- README.md +64 -0
- benchmarks/README.md +123 -0
- benchmarks/_bench_common.py +137 -0
- benchmarks/bench_analyze_suggest.py +390 -0
- benchmarks/bench_load_flow_modes.py +132 -0
- benchmarks/bench_load_study.py +103 -0
- benchmarks/bench_n1_diagram.py +110 -0
- benchmarks/bench_n1_diagram_patch.py +152 -0
- benchmarks/bench_nad_n_state.py +140 -0
- benchmarks/bench_nad_toggles.py +189 -0
- benchmarks/bench_topology_cache.py +70 -0
- benchmarks/bench_voltage_level_queries.py +73 -0
- benchmarks/interaction_paint/.gitignore +8 -0
- benchmarks/interaction_paint/FLUIDITY_FINDINGS.md +75 -0
- benchmarks/interaction_paint/README.md +65 -0
- benchmarks/interaction_paint/app_bitmap_driver.mjs +60 -0
- benchmarks/interaction_paint/app_bitmap_n1_driver.mjs +56 -0
- benchmarks/interaction_paint/app_gesture_driver.mjs +126 -0
- benchmarks/interaction_paint/app_measure_snapshot.mjs +41 -0
- benchmarks/interaction_paint/app_n1_driver.mjs +51 -0
- benchmarks/interaction_paint/app_render_driver.mjs +75 -0
- benchmarks/interaction_paint/app_toggle_driver.mjs +48 -0
- benchmarks/interaction_paint/bench_candidates.html +145 -0
- benchmarks/interaction_paint/bench_fluidity.html +308 -0
- benchmarks/interaction_paint/bench_pan_zoom.mjs +111 -0
- benchmarks/interaction_paint/capture.mjs +13 -0
- benchmarks/interaction_paint/cdp_driver.mjs +76 -0
- benchmarks/interaction_paint/cdp_pipe_driver.mjs +73 -0
- benchmarks/interaction_paint/fluidity_result.phaseA.json +3 -0
- benchmarks/interaction_paint/fluidity_result.realApp.json +3 -0
- 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."}
|