diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..1104fc26 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,136 @@ +on: + push: + workflow_dispatch: + inputs: + dry_run: + description: "Build only, skip docker push" + type: boolean + default: false + +env: + DOCKER_PROJECT: gridappsd + +jobs: + push: + runs-on: ubuntu-latest + name: Build and push the docker container + strategy: + matrix: + include: + - image_name: glimpse-backend + dockerfile: Dockerfile.backend + readme: docker/README-backend.md + - image_name: glimpse-frontend + dockerfile: Dockerfile.frontend + readme: docker/README-frontend.md + env: + DOCKER_IMAGE_NAME: ${{ matrix.image_name }} + SKIP_BUILD: "false" + steps: + - uses: actions/checkout@v7 + + - name: Checking environment + run: | + if [ "x${{ env.DOCKER_IMAGE_NAME }}" == "x" ]; then + echo "Error: missing DOCKER_IMAGE_NAME" + exit 1 + fi + + OWNER=`echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]'` + PROJECT=`echo "${{ env.DOCKER_PROJECT }}" | tr '[:upper:]' '[:lower:]'` + if [ "$OWNER" != "$PROJECT" ]; then + echo "Skipping: repository owner '$OWNER' does not match DOCKER_PROJECT '$PROJECT'" + echo "SKIP_BUILD=true" >> $GITHUB_ENV + else + echo "SKIP_BUILD=false" >> $GITHUB_ENV + fi + + - name: Log in to docker + if: env.SKIP_BUILD != 'true' + run: | + if [ -n "${{ secrets.DOCKER_USERNAME }}" -a -n "${{ secrets.DOCKER_TOKEN }}" ]; then + + echo " " + echo "Connecting to docker" + echo "${{ secrets.DOCKER_TOKEN }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + status=$? + if [ $status -ne 0 ]; then + echo "Error: status $status" + exit 1 + fi + fi + + - name: Build the image + if: env.DOCKER_IMAGE_NAME != null && env.SKIP_BUILD != 'true' + run: | + TAG="${GITHUB_REF#refs/heads/}" + TAG="${TAG#refs/tags/}" + TAG="${TAG//\//_}" + ORG=`echo "${{ env.DOCKER_PROJECT }}" | tr '[:upper:]' '[:lower:]'` + ORG="${ORG:-gridappsd}" + ORG="${ORG:+${ORG}/}" + IMAGE="${ORG}${{ env.DOCKER_IMAGE_NAME }}" + TIMESTAMP=`date +'%y%m%d%H'` + GITHASH=`git log -1 --pretty=format:"%h"` + BUILD_VERSION="${TIMESTAMP}_${GITHASH}${BRANCH:+:$TAG}" + echo "BUILD_VERSION $BUILD_VERSION" + echo "TAG ${IMAGE}:${TIMESTAMP}_${GITHASH}" + docker build --build-arg VERSION="${TAG}" --build-arg TIMESTAMP="${BUILD_VERSION}" -f ${{ matrix.dockerfile }} -t ${IMAGE}:${TIMESTAMP}_${GITHASH} . + status=$? + if [ $status -ne 0 ]; then + echo "Error: status $status" + exit 1 + fi + + + - name: Push the image + if: env.DOCKER_IMAGE_NAME != null && env.SKIP_BUILD != 'true' && github.event.inputs.dry_run != 'true' + run: | + TAG="${GITHUB_REF#refs/heads/}" + TAG="${TAG#refs/tags/}" + TAG="${TAG//\//_}" + ORG=`echo "${{ env.DOCKER_PROJECT }}" | tr '[:upper:]' '[:lower:]'` + ORG="${ORG:-gridappsd}" + ORG="${ORG:+${ORG}/}" + IMAGE="${ORG}${{ env.DOCKER_IMAGE_NAME }}" + if [ -n "${{ secrets.DOCKER_USERNAME }}" -a -n "${{ secrets.DOCKER_TOKEN }}" ]; then + + if [ -n "$TAG" -a -n "$ORG" ]; then + # Get the built container name + CONTAINER=`docker images --format "{{.Repository}}:{{.Tag}}" ${IMAGE}` + + echo "docker push ${CONTAINER}" + docker push "${CONTAINER}" + status=$? + if [ $status -ne 0 ]; then + echo "Error: status $status" + exit 1 + fi + + echo "docker tag ${CONTAINER} ${IMAGE}:$TAG" + docker tag ${CONTAINER} ${IMAGE}:$TAG + status=$? + if [ $status -ne 0 ]; then + echo "Error: status $status" + exit 1 + fi + + echo "docker push ${IMAGE}:$TAG" + docker push ${IMAGE}:$TAG + status=$? + if [ $status -ne 0 ]; then + echo "Error: status $status" + exit 1 + fi + fi + + fi + + - name: Update Docker Hub overview + if: env.DOCKER_IMAGE_NAME != null && env.SKIP_BUILD != 'true' && github.event.inputs.dry_run != 'true' + uses: peter-evans/dockerhub-description@v5 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + repository: ${{ env.DOCKER_PROJECT }}/${{ env.DOCKER_IMAGE_NAME }} + readme-filepath: ${{ matrix.readme }} diff --git a/.gitignore b/.gitignore index 2817f00c..a39b4e40 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ lerna-debug.log* **/node_modules **/gridappsd-viz **/__pycache__ -**/glm **/.claude **/.venv **/.env @@ -22,13 +21,12 @@ lerna-debug.log* **/env/ **/CIM-Builder *.local -Dockerfile -.dockerignore CLAUDE.md # Editor directories and files .vscode/* +.zed/* !.vscode/extensions.json .idea .DS_Store diff --git a/Dockerfile.backend b/Dockerfile.backend index 40684d33..bf7b9ee6 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -5,13 +5,8 @@ # ---- builder: install Python deps into /usr/local -------------------------- FROM python:3.12-slim AS builder -# uv for fast installs; git to clone CIM-Builder. COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -# Install server deps (no pyinstaller). --prerelease=allow: cim-graph is 0.4.3a10. COPY local-server/requirements.txt ./requirements.txt RUN uv pip install --system -r requirements.txt @@ -22,8 +17,7 @@ ENV PYTHONUNBUFFERED=1 \ FLASK_HOST=0.0.0.0 \ FLASK_PORT=5052 -# Copy everything uv installed (site-packages + console scripts). git and uv -# stay behind in the builder stage and never reach the final image. +# Copy everything uv installed (site-packages + console scripts); uv stays behind. COPY --from=builder /usr/local /usr/local WORKDIR /app diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 6eecf6f2..480f6beb 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -21,20 +21,14 @@ RUN npm run build # ---- serve stage ----------------------------------------------------------- FROM nginx:alpine -# openssl is only needed so the TLS entrypoint can generate a self-signed -# certificate when none is mounted (see docker/45-glimpse-tls.sh). -RUN apk add --no-cache openssl - COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html -# Runtime env injection (writes env.js + patches CSP from $API_URL on startup) -# and TLS provisioning. nginx:alpine runs /docker-entrypoint.d/*.sh in order. +# Runtime env injection: writes env.js + patches CSP from $API_URL on startup. COPY docker/40-glimpse-env.sh /docker-entrypoint.d/40-glimpse-env.sh -COPY docker/45-glimpse-tls.sh /docker-entrypoint.d/45-glimpse-tls.sh # Strip CRs in case of a Windows checkout — a "#!/bin/sh\r" shebang fails at # startup with a confusing "not found". -RUN sed -i 's/\r$//' /docker-entrypoint.d/40-glimpse-env.sh /docker-entrypoint.d/45-glimpse-tls.sh \ - && chmod +x /docker-entrypoint.d/40-glimpse-env.sh /docker-entrypoint.d/45-glimpse-tls.sh +RUN sed -i 's/\r$//' /docker-entrypoint.d/40-glimpse-env.sh \ + && chmod +x /docker-entrypoint.d/40-glimpse-env.sh -EXPOSE 80 443 +EXPOSE 80 diff --git a/README.md b/README.md index 095955d3..776e304b 100755 --- a/README.md +++ b/README.md @@ -143,7 +143,6 @@ authenticate against. The backend is not reachable from outside the Codespace. > unavailable in a Codespace — there is no broker to connect to. GLIMPSE detects this at startup and > disables those panels; file upload, visualization, editing, and export all work normally. - ### Option 4: Build From Source #### Quick Overview @@ -281,41 +280,6 @@ The finished installer is written to the `release/` directory. The installed app > [!TIP] > If `pyinstaller` is not on your PATH, activate the Python environment you created for `local-server/` first (or, with UV, run `uv run pyinstaller server.spec --noconfirm` inside `local-server/`). -### Deployment & Security Configuration - -> [!NOTE] -> GLIMPSE runs a _single-session_ server: one loaded model, shared by every -> connected client. It is built for one user at a time, whether that is the -> desktop app or a Docker container on a machine you control. - -The desktop app runs the backend bound to `127.0.0.1` (loopback only), so the defaults below are safe as-is. **A networked deployment is different**: the Docker backend binds to `0.0.0.0`, which makes it reachable by any client that can route to the port. Because the backend has no per-user login, treat the following environment variables as required hardening before exposing it beyond localhost. - -| Variable | Applies to | Default | Purpose | -| :------------------------------------------------------------------------------- | :----------------- | :------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GLIMPSE_API_TOKEN` | backend + frontend | _(empty → auth **off**)_ | Shared bearer token. When set, **every** HTTP request and WebSocket connection must present it or is rejected (`401` / refused handshake). Compose passes the same value to the backend (`GLIMPSE_API_TOKEN`) and the frontend (`API_TOKEN`). | -| `CORS_ORIGINS` | backend | local dev ports | Comma-separated list of browser origins allowed to call the API (e.g. `https://glimpse.example.org`). `*` allows any origin but **disables credentialed CORS**. Pin this to your frontend's real origin in production. | -| `GLIMPSE_EXPORT_DIR` | backend | system temp `/glimpse_exports` | Directory that CIM export writes are confined to. Client-supplied export paths are resolved inside this directory; absolute paths and `..` traversal are rejected. | -| `GLIMPSE_ALLOW_ANY_EXPORT_PATH` | backend | `0` | Set to `1` only for a **desktop** build where the user intentionally picks any save location. Disables the export-path confinement above — do not enable on a shared/networked server. | -| `MAX_UPLOAD_MB` | backend | `50` | Maximum request body size (MB) for uploads, to bound memory use. Requests over the limit get `413`. | -| `GLIMPSE_MODELS_DIR` | backend | auto-detected | Directory holding the bundled example models offered in the "Example Models" tab. By default the backend looks for a `models/` folder next to the server (PyInstaller bundle / Docker bind mount) and then the repo's top-level `models/` folder. Missing files are simply not offered. | -| `EXPOSE_TRACEBACKS` | backend | `0` | When `1`, includes Python tracebacks in error responses (useful for local debugging). Leave off in production so internal details aren't leaked to clients. | -| `GRIDAPPSD_ADDRESS` / `GRIDAPPSD_PORT` / `GRIDAPPSD_USER` / `GRIDAPPSD_PASSWORD` | backend | `localhost` / `61613` / `system` / `manager` | GridAPPS-D broker connection. The defaults are GridAPPS-D's own defaults — **change the credentials** for any real broker and source them from your secret store, not the compose file. | -| `CIMG_URL` | backend | derived from `GRIDAPPSD_ADDRESS` | Blazegraph SPARQL endpoint used for CIM model loads (`http://:8889/bigdata/namespace/kb/sparql` by default, matching a standard GridAPPS-D deployment). Set explicitly when Blazegraph runs on a different host/port. The other `CIMG_*` cimgraph settings can be overridden the same way. | -| `GLIMPSE_SPARQL_TIMEOUT` | backend | `120` | Per-query timeout (seconds) for Blazegraph SPARQL queries during CIM model loads, so one stalled query can't hang a load forever. Raise it if a very slow Blazegraph instance times out on large models. | -| `GLIMPSE_SPARQL_MAX_CONCURRENT` | backend | `4` | Maximum in-flight SPARQL queries during a CIM model load. Caps the pressure on the Blazegraph JVM when loading large models (e.g. IEEE 9500); raise it on a beefy Blazegraph host for faster loads. | - -#### Enabling authentication - -Generate a random secret and set it before starting the stack — both containers pick it up: - -```bash -export GLIMPSE_API_TOKEN="$(openssl rand -hex 32)" -docker compose up --build -``` - -> [!IMPORTANT] -> This token is a **coarse gate**, not per-user authentication. It is embedded in the frontend bundle (served in `env.js` and sent on every request), so anyone who can load the UI can read it. Its job is to keep arbitrary network clients that _don't_ have the frontend from reaching the `0.0.0.0`-bound backend. If you need real per-user authorization, put GLIMPSE behind an authenticating reverse proxy or add session/OAuth login on top of this gate. For anything sensitive, also terminate TLS at a proxy so the token isn't sent in cleartext. - ## Supported Input Files ### JSON Formats diff --git a/docker-compose.yml b/docker-compose.yml index 0be133b8..2a20f25b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,6 @@ services: context: . dockerfile: Dockerfile.backend environment: - FLASK_HOST: 0.0.0.0 - FLASK_PORT: "5052" CORS_ORIGINS: "${CORS_ORIGINS:-}" GLIMPSE_API_TOKEN: "${GLIMPSE_API_TOKEN:-}" GRIDAPPSD_ADDRESS: "${GRIDAPPSD_ADDRESS:-host.docker.internal}" diff --git a/docker/README-backend.md b/docker/README-backend.md new file mode 100644 index 00000000..b5274344 --- /dev/null +++ b/docker/README-backend.md @@ -0,0 +1,30 @@ +# GLIMPSE Backend + +Flask + SocketIO backend for [GLIMPSE](https://github.com/pnnl/GLIMPSE), a +graph-based desktop application to visualize and update GridLAB-D power grid +models. + +This image serves the GLIMPSE API and connects to a GridAPPS-D broker and a +Blazegraph SPARQL endpoint for CIM model loads. Pair it with the +`gridappsd/glimpse-frontend` image using the project's +[docker-compose.yml](https://github.com/pnnl/GLIMPSE/blob/main/docker-compose.yml). + +## Quick start + +```bash +git clone http://github.com/pnnl/GLIMPSE +cd GLIMPSE +docker compose up --build +``` + +## Configuration + +Key environment variables: + +- `FLASK_HOST` / `FLASK_PORT` - bind address and port (default `0.0.0.0:5052`) +- `CORS_ORIGINS` - comma-separated allowed browser origins +- `GLIMPSE_API_TOKEN` - shared bearer token for API/socket auth +- `GRIDAPPSD_ADDRESS`, `GRIDAPPSD_PORT`, `GRIDAPPSD_USER`, `GRIDAPPSD_PASSWORD` - GridAPPS-D broker connection +- `CIMG_URL` - Blazegraph SPARQL endpoint (derived from `GRIDAPPSD_ADDRESS` if unset) + +See the [README](https://github.com/pnnl/GLIMPSE#readme) for full details. diff --git a/docker/README-frontend.md b/docker/README-frontend.md new file mode 100644 index 00000000..bdc912a4 --- /dev/null +++ b/docker/README-frontend.md @@ -0,0 +1,28 @@ +# GLIMPSE Frontend + +React frontend (served by nginx) for [GLIMPSE](https://github.com/pnnl/GLIMPSE), +a graph-based desktop application to visualize and update GridLAB-D power grid +models. + +This image serves the GLIMPSE UI and talks to the `gridappsd/glimpse-backend` +image over its API/socket endpoint. Pair the two using the project's +[docker-compose.yml](https://github.com/pnnl/GLIMPSE/blob/main/docker-compose.yml). + +## Quick start + +```bash +git clone http://github.com/pnnl/GLIMPSE +cd GLIMPSE +docker compose up --build +``` + +Then open `http://localhost:5173`. + +## Configuration + +Key environment variables: + +- `API_URL` - URL the browser uses to reach the backend (default `http://127.0.0.1:5052`) +- `API_TOKEN` - must match the backend's `GLIMPSE_API_TOKEN` (baked into `env.js` at container start) + +See the [README](https://github.com/pnnl/GLIMPSE#readme) for full details. diff --git a/electron/main.js b/electron/main.js index 866011f1..5c0cba95 100644 --- a/electron/main.js +++ b/electron/main.js @@ -16,12 +16,6 @@ let quitting = false; const MAX_SERVER_RESTARTS = 1; let restartsUsed = 0; -const notifyRenderer = (channel) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(channel); - } -}; - app.commandLine.appendSwitch("enable-unsafe-swiftshader"); const isWSL = @@ -82,18 +76,15 @@ const startServer = () => { if (restartsUsed < MAX_SERVER_RESTARTS) { restartsUsed += 1; console.warn(`[server] exited (code ${code}, signal ${signal}) — restarting.`); - notifyRenderer("backend-restarting"); startServer(); - waitForServer() - .then(() => notifyRenderer("backend-restarted")) - .catch((err) => { - dialog.showErrorBox( - "GLIMPSE backend stopped", - `The local server exited and could not be restarted.\n\n${err.message}`, - ); - app.quit(); - }); + waitForServer().catch((err) => { + dialog.showErrorBox( + "GLIMPSE backend stopped", + `The local server exited and could not be restarted.\n\n${err.message}`, + ); + app.quit(); + }); return; } @@ -248,7 +239,6 @@ const createWindow = () => { sandbox: true, nodeIntegration: false, contextIsolation: true, - enableRemoteModule: false, }, }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 9dbcb125..139d6d12 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,13 +25,8 @@ export default defineConfig([ reactRefresh.configs.vite, ], languageOptions: { - ecmaVersion: 2020, globals: globals.browser, - parserOptions: { - ecmaVersion: "latest", - ecmaFeatures: { jsx: true }, - sourceType: "module", - }, + parserOptions: { ecmaFeatures: { jsx: true } }, }, rules: { "no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }], diff --git a/local-server/agenthelper.py b/local-server/agenthelper.py index 16c5d9e8..ff786159 100644 --- a/local-server/agenthelper.py +++ b/local-server/agenthelper.py @@ -1,9 +1,3 @@ -import json -import logging -import os - -logger = logging.getLogger(__name__) - LEVELS = ("feeder", "switch", "secondary") SYSTEM_BUS_ID = "system" DEVICE_LABELS = { @@ -58,7 +52,9 @@ def invert_area_map(area_map: dict) -> dict: return index -def derive_agents(area_index: dict, object_index: dict, model_id: str) -> dict: +def build_agent_model(area_map: dict, object_index: dict, model_id: str) -> dict: + """The agent roster a loaded model implies: one agent per distribution area.""" + area_index = invert_area_map(area_map) buses = [ { "bus_id": SYSTEM_BUS_ID, @@ -82,7 +78,7 @@ def derive_agents(area_index: dict, object_index: dict, model_id: str) -> dict: ] for level in LEVELS: - for area_id, area in (area_index.get(level) or {}).items(): + for area_id, area in area_index[level].items(): buses.append( { "bus_id": area_id, @@ -108,75 +104,6 @@ def derive_agents(area_index: dict, object_index: dict, model_id: str) -> dict: return {"model": model_id, "source": "derived", "buses": buses, "agents": agents} -def agents_from_fixture(path: str) -> dict: - """Raw agent payload captured from the platform and saved to disk.""" - with open(path, "r", encoding="utf-8") as fixture: - return json.load(fixture) - - -def agents_from_gridappsd(gridappsd_helper, model_id: str) -> dict | None: - if gridappsd_helper is None or not gridappsd_helper.is_available(): - return None - - # TODO: replace with the real topic and request once the platform exposes it. - # topic = "goss.gridappsd.request.data." - # message = {"requestType": "GET_AGENTS", "modelId": model_id} - # return gridappsd_helper.get_agent_roster(topic, message) - logger.info( - "Live agent roster requested for %s, but the GridAPPS-D agent topic is " - "not implemented yet; falling back to the derived roster.", - model_id, - ) - return None - - -def normalize_agents(raw: dict | None, area_index: dict, model_id: str) -> dict: - raw_agents = (raw or {}).get("agents") or [] - area_by_id = { - area_id: (level, area) - for level in LEVELS - for area_id, area in (area_index.get(level) or {}).items() - } - - agents = [] - for entry in raw_agents: - if not isinstance(entry, dict): - continue - - area_id = entry.get("area_id") or entry.get("message_bus_id") - if area_id == SYSTEM_BUS_ID: - area_id = None - - level, area = area_by_id.get(area_id, (None, None)) - level = entry.get("level") or level or ("system" if area_id is None else "switch") - - agents.append( - { - "agent_id": str(entry.get("agent_id") or entry.get("name") or ""), - "agent_type": entry.get("agent_type") - or ("coordinating" if level == "system" else "distributed"), - "level": level, - "message_bus_id": entry.get("message_bus_id") or area_id or SYSTEM_BUS_ID, - "area_id": area_id, - "area_name": entry.get("area_name") - or (area["name"] if area else None) - or ("Distribution System" if area_id is None else _uuid_tail(area_id)), - "status": entry.get("status") or "unknown", - "devices": _normalize_devices(entry.get("devices")), - } - ) - - return { - "model": (raw or {}).get("model") or model_id, - "source": (raw or {}).get("source") or "gridappsd", - "buses": _buses_for(agents, area_index), - "agents": agents, - } - - -# ── internals ─────────────────────────────────────────────────────────────── - - def _uuid_tail(mrid) -> str: return str(mrid).split("-")[-1] @@ -194,11 +121,8 @@ def _area_devices(members: list, object_index: dict, level: str) -> list: label = CLASS_TYPE_LABELS.get(entry.get("class_type")) or DEVICE_LABELS.get( entry.get("objectType") ) - if not label: - continue - # An override mapping to None means "not shown at this level". - label = overrides[label] if label in overrides else label + label = overrides.get(label, label) if not label: continue @@ -222,84 +146,3 @@ def _device_rank(device_type: str) -> int: return DEVICE_PRIORITY.index(device_type) except ValueError: return len(DEVICE_PRIORITY) - - -def _normalize_devices(devices) -> list: - if not isinstance(devices, list): - return [] - - normalized = [] - for device in devices[:MAX_DEVICES_PER_AGENT]: - if not isinstance(device, dict): - continue - mrid = device.get("mrid") or device.get("@id") or "" - cim_type = device.get("cim_type") or device.get("cimType") or device.get("class_type") or "" - normalized.append( - { - "mrid": str(mrid), - "name": str(device.get("name") or _uuid_tail(mrid)), - "type": str(device.get("type") or "Device"), - "cim_type": CIM_TYPE_OVERRIDES.get(str(cim_type), str(cim_type)), - "phases": str(device.get("phases") or ""), - } - ) - return normalized - - -def _buses_for(agents: list, area_index: dict) -> list: - parent_by_area = { - area_id: area["parent_id"] - for level in LEVELS - for area_id, area in (area_index.get(level) or {}).items() - } - - buses = { - SYSTEM_BUS_ID: { - "bus_id": SYSTEM_BUS_ID, - "level": "system", - "name": "Distribution System", - "area_id": None, - "parent_bus_id": None, - } - } - - for agent in agents: - bus_id = agent["message_bus_id"] - if bus_id in buses: - continue - buses[bus_id] = { - "bus_id": bus_id, - "level": agent["level"], - "name": agent["area_name"], - "area_id": agent["area_id"], - "parent_bus_id": parent_by_area.get(agent["area_id"]) or SYSTEM_BUS_ID, - } - - return list(buses.values()) - - -def build_agent_model(area_map: dict, object_index: dict, model_id: str, source: str = "derived", gridappsd_helper=None) -> dict: - area_index = invert_area_map(area_map) - raw = None - - if source == "gridappsd": - raw = agents_from_gridappsd(gridappsd_helper, model_id) - elif source == "fixture": - path = os.environ.get("GLIMPSE_AGENTS_FIXTURE", "") - if path and os.path.isfile(path): - try: - raw = agents_from_fixture(path) - except (OSError, ValueError) as exc: - # Same outcome as a missing fixture: fall back to the derived - # roster rather than failing the request the caller made. - logger.warning("Agent fixture at %s could not be read (%s); deriving instead.", path, exc) - else: - logger.warning( - "Agent fixture requested but GLIMPSE_AGENTS_FIXTURE is unset or " - "does not point at a file; deriving agents from the model instead." - ) - - if raw: - return normalize_agents(raw, area_index, model_id) - - return derive_agents(area_index, object_index, model_id) diff --git a/local-server/cimgraph_patch.py b/local-server/cimgraph_patch.py index 28a575fe..1c2e98a9 100644 --- a/local-server/cimgraph_patch.py +++ b/local-server/cimgraph_patch.py @@ -16,8 +16,6 @@ _applied = False # Bound to cimgraph's logger when the patch is installed. _log = None -# The implementation replaced, so it can be put back. -_original = None def _patched_create_edge(self, graph, cim_class, identifier, attribute, edge_class, edge_mRID): @@ -80,7 +78,7 @@ def apply() -> bool: Safe to call more than once; only the first call does anything. """ - global _applied, _log, _original + global _applied, _log if _applied: return True @@ -107,31 +105,7 @@ def apply() -> bool: return False _log = cimgraph_log - _original = ConnectionInterface.create_edge ConnectionInterface.create_edge = _patched_create_edge _applied = True return True - -def revert() -> bool: - """Put the library's own implementation back. Returns whether it was on. - - Exists so the patch can be taken out of the picture without restarting — - when comparing against stock behaviour, or if it is ever suspected of - causing something. - """ - global _applied, _original - - if not _applied: - return False - - from cimgraph.databases import ConnectionInterface - - ConnectionInterface.create_edge = _original - _original = None - _applied = False - return True - - -def is_applied() -> bool: - return _applied diff --git a/local-server/cimhelper.py b/local-server/cimhelper.py index f676c669..f21f523c 100644 --- a/local-server/cimhelper.py +++ b/local-server/cimhelper.py @@ -2,6 +2,7 @@ import os import threading from dataclasses import fields, is_dataclass +from urllib.parse import urlsplit from uuid import UUID import cimgraph.data_profile.cimhub_2023 as cim @@ -30,8 +31,6 @@ def _gridappsd_host() -> str: address = os.environ.get("GRIDAPPSD_ADDRESS", "").strip() if not address: return "localhost" - from urllib.parse import urlsplit - parsed = urlsplit(address if "//" in address else f"//{address}") return parsed.hostname or "localhost" @@ -72,9 +71,67 @@ def _update_raw(self, update_message: str): return self._run_query(update_message) +UNDERGROUND_INFO = tuple( + c + for c in ( + getattr(cim, "ConcentricNeutralCableInfo", None), + getattr(cim, "TapeShieldCableInfo", None), + getattr(cim, "CableInfo", None), + ) + if c is not None +) + +SWITCH_TYPES = [ + cim.Breaker, + cim.Fuse, + cim.Switch, + cim.Sectionaliser, + cim.LoadBreakSwitch, + cim.Disconnector, + cim.Recloser, +] + +# Single-terminal equipment drawn as a node, by CIM class -> objectType +EQUIPMENT_NODE_TYPES = { + "RotatingMachine": "diesel_dg", + "SynchronousMachine": "diesel_dg", + "AsynchronousMachine": "diesel_dg", + "EnergySource": "diesel_dg", + "ShuntCompensator": "capacitor", + "LinearShuntCompensator": "capacitor", + "SeriesCompensator": "capacitor", + "PowerElectronicsConnection": "inverter_dyn", + "EnergyConsumer": "load", + "ConformLoad": "load", + "NonConformLoad": "load", +} + +# Reference fields summarized (list length or referenced name) in _add_attributes +DENSE_FIELDS = { + "ConnectivityNode", + "ConductingEquipment", + "ConnectivityNodeContainer", + "Location", + "PowerElectronicsConnection", + "PerLengthImpedance", + "PowerTransformer", + "TransformerEnds", + "BaseVoltage", + "VoltageLevel", + "TransformerTankInfo", + "LoadResponse", + "RegulatingControl", + "GeneratingUnit", + "WireSpacingInfo", +} + + # Current CIM network model - this holds the main graph data class CIMHelper: def __init__(self) -> None: + self.release() + + def release(self) -> None: self.active_measurement_map: dict = {"Discrete": {}, "Analog": {}} self.FEEDERS: dict[str, FeederModel] = {} # feeder_id -> { member mRID: ancestry record } (see _build_*_area_map) @@ -84,27 +141,7 @@ def __init__(self) -> None: # feeder_id -> { normalized id: CIM object }; see _build_mrid_index. self._mrid_indexes: dict[str, dict] = {} - @staticmethod - def count_objects(gjs: dict) -> int: - return sum(len(feeder.get("objects", [])) for feeder in (gjs or {}).values()) - - # ------------------------------------------------------------------ - # State lifecycle - # ------------------------------------------------------------------ - def release(self) -> None: - self.active_measurement_map = {"Discrete": {}, "Analog": {}} - self.FEEDERS = {} - self.area_maps = {} - self.object_index = {} - self._mrid_indexes = {} - def classify_line(self, line: object) -> str: - UNDERGROUND_INFO = ( - getattr(cim, "ConcentricNeutralCableInfo", None), - getattr(cim, "TapeShieldCableInfo", None), - getattr(cim, "CableInfo", None), - ) - UNDERGROUND_INFO = tuple(c for c in UNDERGROUND_INFO if c is not None) # Gather WireInfo objects from every phase of this segment infos = [] for phs in line.ACLineSegmentPhases or []: @@ -126,13 +163,6 @@ def classify_line(self, line: object) -> str: return "overhead_line" return "line" - def _get_cim_feeder(self, model_id: str): - database = SafeBlazegraphConnection() - # database = GridappsdConnection() - feeder = cim.Feeder(mRID=model_id) - feeder_model = FeederModel(connection=database, container=feeder) - return feeder_model - # gjs: GLIMPSE JSON Structure def cim_to_gjs( self, @@ -142,42 +172,29 @@ def cim_to_gjs( progress_cb=None, ): topology_outputs = topology_outputs or {} - self.active_measurement_map = {"Discrete": {}, "Analog": {}} # Reset measurement map for new model(s) # Reset once per load request (not per model) so a multi-model load # keeps every FeederModel available for object lookups and exports. - self.FEEDERS = {} - # Distribution-area ancestry and a light object index, kept per feeder so - # the agents endpoint can rebuild the area hierarchy after the load - # without re-running any SPARQL. See _parse_model. - self.area_maps = {} - self.object_index = {} - self._mrid_indexes = {} - if model_IDs is not None: - gjs = {id: {"objects": []} for id in model_IDs} - object_details: dict[str, dict] = {} + self.release() + gjs: dict[str, dict] = {} + object_details: dict[str, dict] = {} + if model_IDs is not None: for id in model_IDs: - gjs[id]["objects"], object_details[id] = self._parse_model( + objects, object_details[id] = self._parse_model( model_id=id, topology_json=topology_outputs.get(id), progress_cb=progress_cb, ) - - return gjs, object_details - - if filepaths is not None: - gjs = { os.path.basename(path): {"objects": []} for path in filepaths } - object_details = {} - + gjs[id] = {"objects": objects} + elif filepaths is not None: for path in filepaths: filename = os.path.basename(path) - gjs[filename]["objects"], object_details[filename] = self._parse_model( + objects, object_details[filename] = self._parse_model( filepath=path, topology_json=topology_outputs.get(filename) ) + gjs[filename] = {"objects": objects} - return gjs, object_details - - return {}, {} + return gjs, object_details def _parse_model( self, @@ -222,15 +239,7 @@ def _parse_model( cim.PowerElectronicsConnection, cim.BatteryUnit, ]), - ("switches", [ - cim.Breaker, - cim.Fuse, - cim.Switch, - cim.Sectionaliser, - cim.LoadBreakSwitch, - cim.Disconnector, - cim.Recloser, - ]), + ("switches", SWITCH_TYPES), ("measurements", [cim.Analog, cim.Discrete]), ] total_steps = len(load_stages) + 2 # + feeder graph + coordinates @@ -246,7 +255,9 @@ def report(stage: str, step: int): feeder_id = model_id report("feeder graph", 1) - self.FEEDERS[feeder_id] = self._get_cim_feeder(model_id=model_id) + self.FEEDERS[feeder_id] = FeederModel( + connection=SafeBlazegraphConnection(), container=cim.Feeder(mRID=model_id) + ) for index, (stage, cim_classes) in enumerate(load_stages, start=2): report(stage, index) @@ -264,20 +275,6 @@ def report(stage: str, step: int): objects = [] - TYPES = { - "RotatingMachine": "diesel_dg", - "SynchronousMachine": "diesel_dg", - "AsynchronousMachine": "diesel_dg", - "EnergySource": "diesel_dg", - "ShuntCompensator": "capacitor", - "LinearShuntCompensator": "capacitor", - "SeriesCompensator": "capacitor", - "PowerElectronicsConnection": "inverter_dyn", - "EnergyConsumer": "load", - "ConformLoad": "load", - "NonConformLoad": "load", - } - # Track equipment we've already emitted as a node so the same # single-terminal device isn't added twice if it shows up on # multiple connectivity nodes. @@ -317,10 +314,10 @@ def report(stage: str, step: int): if equipment is not None: class_type = equipment.__class__.__name__ - if class_type in TYPES and equipment.mRID not in seen_equipment: + if class_type in EQUIPMENT_NODE_TYPES and equipment.mRID not in seen_equipment: seen_equipment.add(equipment.mRID) new_obj = { - "objectType": TYPES[class_type], + "objectType": EQUIPMENT_NODE_TYPES[class_type], "elementType": "node", "attributes": { "id": equipment.mRID, @@ -431,72 +428,47 @@ def report(stage: str, step: int): new_edge["attributes"].update(self._area_attrs(area_map.get(p_transformer.mRID))) objects.append(new_edge) - cim_switch_types = [ - cim.Breaker, - cim.Fuse, - cim.Switch, - cim.Sectionaliser, - cim.LoadBreakSwitch, - cim.Disconnector, - cim.Recloser, - ] - - for cim_type in cim_switch_types: - if cim_type in self.FEEDERS[feeder_id].graph: - for switch_obj in self.FEEDERS[feeder_id].graph[cim_type].values(): - switch_terminals = switch_obj.Terminals - if ( - len(switch_terminals) < 2 - or switch_terminals[0].ConnectivityNode is None - or switch_terminals[1].ConnectivityNode is None - ): - continue - - # Collect measurement MRIDs associated with this switch - measurement_mrids = [] - if hasattr(switch_obj, "Measurements") and switch_obj.Measurements: - for m in switch_obj.Measurements: - if m.mRID: - measurement_mrids.append(str(m.mRID)) - - normal_open = ( - bool(switch_obj.normalOpen) - if switch_obj.normalOpen is not None - else False - ) - switch_status = ( - bool(switch_obj.open) - if switch_obj.open is not None - else normal_open - ) - rated_current = ( - str(switch_obj.ratedCurrent) - if switch_obj.ratedCurrent is not None - else None - ) + for cim_type in SWITCH_TYPES: + for switch_obj in self.FEEDERS[feeder_id].graph.get(cim_type, {}).values(): + switch_terminals = switch_obj.Terminals + if ( + len(switch_terminals) < 2 + or switch_terminals[0].ConnectivityNode is None + or switch_terminals[1].ConnectivityNode is None + ): + continue - new_edge = { - "objectType": "switch", - "elementType": "edge", - "attributes": { - "id": switch_obj.mRID, - "from": switch_terminals[0].ConnectivityNode.mRID, - "to": switch_terminals[1].ConnectivityNode.mRID, - "class_type": switch_obj.__class__.__name__, - "normalStatus": "OPEN" if normal_open else "CLOSED", - "open": switch_status, - "ratedCurrent": rated_current, - "measurement_mrids": measurement_mrids, - "feeder_id": feeder_id, - }, - } + measurement_mrids = [ + str(m.mRID) for m in (getattr(switch_obj, "Measurements", None) or []) if m.mRID + ] + normal_open = bool(switch_obj.normalOpen) + switch_status = bool(switch_obj.open) if switch_obj.open is not None else normal_open + rated_current = ( + str(switch_obj.ratedCurrent) if switch_obj.ratedCurrent is not None else None + ) - if hasattr(switch_obj, "breakingCapacity") and switch_obj.breakingCapacity is not None: - new_edge["attributes"]["breakingCapacity"] = str(switch_obj.breakingCapacity) + new_edge = { + "objectType": "switch", + "elementType": "edge", + "attributes": { + "id": switch_obj.mRID, + "from": switch_terminals[0].ConnectivityNode.mRID, + "to": switch_terminals[1].ConnectivityNode.mRID, + "class_type": switch_obj.__class__.__name__, + "normalStatus": "OPEN" if normal_open else "CLOSED", + "open": switch_status, + "ratedCurrent": rated_current, + "measurement_mrids": measurement_mrids, + "feeder_id": feeder_id, + }, + } + + if getattr(switch_obj, "breakingCapacity", None) is not None: + new_edge["attributes"]["breakingCapacity"] = str(switch_obj.breakingCapacity) - new_edge["attributes"].update(self._area_attrs(area_map.get(switch_obj.mRID))) - self._add_attributes(switch_obj, new_edge) - objects.append(new_edge) + new_edge["attributes"].update(self._area_attrs(area_map.get(switch_obj.mRID))) + self._add_attributes(switch_obj, new_edge) + objects.append(new_edge) for battery in self.FEEDERS[feeder_id].graph.get(cim.BatteryUnit, {}).values(): new_battery = { @@ -671,10 +643,6 @@ def _build_mrid_index(self, feeder_id: str) -> dict: self._mrid_indexes[feeder_id] = index return index - def _invalidate_index(self, feeder_id: str) -> None: - """Drop a feeder's cached index after the graph is mutated.""" - self._mrid_indexes.pop(feeder_id, None) - def resolve_object(self, feeder_id: str, uuid) -> object | None: """The CIM instance for an id, or None. Never touches SPARQL or the XML.""" feeder = self.FEEDERS.get(feeder_id) @@ -697,11 +665,7 @@ def _lookup_mrid(self, feeder_id: str, mrid_index: dict, mrid) -> object | None: def _resolve_area_name(self, feeder_id: str, area_mrid: str, mrid_index: dict) -> str: obj = self._lookup_mrid(feeder_id, mrid_index, area_mrid) - name = getattr(obj, "name", None) if obj is not None else None - if name: - return name - - return self._uuid_tail(area_mrid) + return getattr(obj, "name", None) or self._uuid_tail(area_mrid) def _build_topology_area_map(self, topology_json: dict, feeder_id: str) -> dict: area_by_mrid: dict = {} @@ -789,13 +753,9 @@ def _tag_topology_area_members( for mrid in mrids: area_by_mrid.setdefault(mrid, {}).update(context) - def _build_measurement_map(self, feeder_id: str) -> dict: - measurement_types = [cim.Analog, cim.Discrete] - for measurement_type in measurement_types: - if measurement_type not in self.FEEDERS[feeder_id].graph: - continue - - for measurement in self.FEEDERS[feeder_id].graph[measurement_type].values(): + def _build_measurement_map(self, feeder_id: str) -> None: + for measurement_type in (cim.Analog, cim.Discrete): + for measurement in self.FEEDERS[feeder_id].graph.get(measurement_type, {}).values(): if not measurement.mRID: continue @@ -878,9 +838,8 @@ def _object_to_detail(self, obj): return detail def get_cim_object(self, feeder_id: str, uuid: str): - if not self.FEEDERS[feeder_id]: - return {"error": "No active model available"} # 400 - + if feeder_id not in self.FEEDERS: + raise KeyError(feeder_id) obj = self.resolve_object(feeder_id, uuid) if obj is None: return {"error": f"Object {uuid} not found"} # 404 @@ -925,6 +884,8 @@ def export_cim_coords( yPosition=obj["y"], ) + # New Location/PositionPoint objects must be resolvable too. + self._mrid_indexes.pop(feeder_id, None) cim_utils.get_all_data(self.FEEDERS[feeder_id]) cim_utils.write_xml(self.FEEDERS[feeder_id], output_path) @@ -960,31 +921,13 @@ def find_shared_coordinates(self, cim_obj) -> dict: return {"x": x, "y": y} def _add_attributes(self, cim_obj, new_obj): - dense_fields = [ - "ConnectivityNode", - "ConductingEquipment", - "ConnectivityNodeContainer", - "Location", - "PowerElectronicsConnection", - "PerLengthImpedance", - "PowerTransformer", - "TransformerEnds", - "BaseVoltage", - "VoltageLevel", - "TransformerTankInfo", - "LoadResponse", - "RegulatingControl", - "GeneratingUnit", - "WireSpacingInfo" - ] - for field in fields(cim_obj): if field.name == "identifier": continue if field.metadata.get("type") == "Attribute": # association, aggregateof, and ofaggregate attribute = getattr(cim_obj, field.name) - if field.name in dense_fields and attribute is not None: + if field.name in DENSE_FIELDS and attribute is not None: if isinstance(attribute, list): new_obj["attributes"][field.name] = len(attribute) else: @@ -992,70 +935,6 @@ def _add_attributes(self, cim_obj, new_obj): elif attribute is not None: new_obj["attributes"][field.name] = str(attribute) - # def export_cim( - # self, feeder_id: str, dir2save: str, filename: str, data: list - # ) -> None: - # if len(data) == 0: - # cim_utils.get_all_data(self.FEEDERS[feeder_id]) - # cim_utils.write_xml(self.FEEDERS[feeder_id], dir2save + "\\cim_output.xml") - # return - - # feeder = self.FEEDERS[feeder_id].container - - # # [0] = new nerminal with type - # # [1] = new connectivity node - # # [2] = existing connectivity node - - # for nodeObj in data: - # # 1. get existing connectivity node - # existing_c_node = self.FEEDERS[feeder_id].graph[cim.ConnectivityNode][ - # UUID(nodeObj[2]["mRID"].upper()) - # ] - - # # 2. create new connectivity node - # new_c_node = cim.ConnectivityNode( - # mRID=nodeObj[1]["mRID"].upper(), name=nodeObj[1]["name"] - # ) - # self.FEEDERS[feeder_id].add_to_graph(new_c_node) - - # # 3. connect both connectivity nodes with new_two_terminal_obj function - # new_two_terminal_object( - # network=self.FEEDERS[feeder_id], - # container=feeder, - # class_type=cim.ACLineSegment, - # name=existing_c_node.mRID.split("-")[0], - # node1=existing_c_node, - # node2=new_c_node, - # ) - - # # 4. Finally create the new synchronous generator or energy consumer by connecting to new connectivity node - # if nodeObj[0]["type"] == "diesel_dg": - # new_synchronous_generator( - # network=self.FEEDERS[feeder_id], - # container=feeder, - # name=nodeObj[0]["name"], - # node=new_c_node, - # ) - # elif nodeObj[0]["type"] == "load": - # new_energy_consumer( - # network=self.FEEDERS[feeder_id], - # container=feeder, - # name=nodeObj[0]["name"], - # node=new_c_node, - # ) - # elif nodeObj[0]["type"] == "inverter_dyn": - # # new power electronics connection - # pass - # elif nodeObj[0]["type"] == "capacitor": - # # new one terminal object - # pass - - # out_dir = os.path.join( - # dir2save, os.path.splitext(os.path.basename(filename))[0] + "_out.xml" - # ) - # cim_utils.get_all_data(self.FEEDERS[feeder_id]) - # cim_utils.write_xml(self.FEEDERS[feeder_id], out_dir) - def get_mermaid(self, feeder_id: str, uuid: str) -> str: obj = self.resolve_object(feeder_id, uuid) if obj is None: @@ -1070,42 +949,12 @@ def get_mermaid(self, feeder_id: str, uuid: str) -> str: return json.dumps({"uuid": uuid, "mermaid": mermaid}) def delete_cim_object(self, feeder_id: str, uuid: str) -> bool: - if not self.FEEDERS[feeder_id]: - return False - - # Get the object first - obj = None - obj_class = None - obj_key = None - + feeder = self.FEEDERS[feeder_id] # KeyError (500) for an unknown feeder obj = self.resolve_object(feeder_id, uuid) if obj is None: - # Manual search - for cim_class, instances in self.FEEDERS[feeder_id].graph.items(): - for key, instance in instances.items(): - obj_id = str( - getattr(instance, "identifier", getattr(instance, "mRID", "")) - ) - if obj_id == uuid: - obj = instance - obj_class = cim_class - obj_key = key - break - if obj: - break - - if not obj: return False - # Delete the object. Either branch mutates the graph, so the cached - # index must go with it or a deleted object stays resolvable. - if hasattr(self.FEEDERS[feeder_id], "delete"): - self.FEEDERS[feeder_id].delete(obj) - self._invalidate_index(feeder_id) - return True - elif obj_class and obj_key: - del self.FEEDERS[feeder_id].graph[obj_class][obj_key] - self._invalidate_index(feeder_id) - return True - - return False + feeder.delete(obj) + # Otherwise the cached index keeps the deleted object resolvable. + self._mrid_indexes.pop(feeder_id, None) + return True diff --git a/local-server/glmhelper.py b/local-server/glmhelper.py index 3d275524..368cbabb 100644 --- a/local-server/glmhelper.py +++ b/local-server/glmhelper.py @@ -9,8 +9,7 @@ class GLMHelper: - def __init__(self): - self.max_file_size_mb = 5 + MAX_FILE_SIZE_MB = 5 def parse_glm(self, file_paths: list) -> dict: glm_dicts = {} @@ -18,10 +17,10 @@ def parse_glm(self, file_paths: list) -> dict: file_size_mb = os.path.getsize(glm_path) / (1024 * 1024) filename = os.path.basename(glm_path) - if file_size_mb > self.max_file_size_mb: + if file_size_mb > self.MAX_FILE_SIZE_MB: raise ValueError( f"File {filename} is too large ({file_size_mb:.2f} MB). " - f"Maximum allowed size is {self.max_file_size_mb} MB." + f"Maximum allowed size is {self.MAX_FILE_SIZE_MB} MB." ) result = glm_load(glm_path) diff --git a/local-server/glmparser/__init__.py b/local-server/glmparser/__init__.py index c88cdfee..563a4715 100644 --- a/local-server/glmparser/__init__.py +++ b/local-server/glmparser/__init__.py @@ -5,9 +5,7 @@ from .parser import Parser from .writer import dumps -__version__ = "1.0.0" - -__all__ = ["load", "loads", "dump", "dumps", "version", "GlmParseError"] +__all__ = ["load", "loads", "dump", "dumps", "GlmParseError"] def loads(text): """Parse GLM source text into the AST dict.""" @@ -31,7 +29,3 @@ def dump(data, file): return None file.write(text) return None - - -def version(): - return __version__ diff --git a/local-server/gridappsdhelper.py b/local-server/gridappsdhelper.py index 635c0b4a..a6bf7cc7 100644 --- a/local-server/gridappsdhelper.py +++ b/local-server/gridappsdhelper.py @@ -2,7 +2,6 @@ import os import socket import time -from collections import OrderedDict from collections.abc import Callable from enum import Enum @@ -27,8 +26,6 @@ class SimulationState(Enum): class GridAPPSDError(Exception): """Custom exception for GridAPPS-D operations""" - pass - class GridAPPSDHelper: def __init__(self): @@ -36,71 +33,49 @@ def __init__(self): self.sim_id: str | None = None self.sim_state: SimulationState = SimulationState.IDLE self.current_limit_map = {} - # Keyed by a model id the client supplies, so it is bounded and evicts - # oldest-first: nothing else prunes it and the desktop server is a - # long-lived process. - self.agent_roster_cache = OrderedDict() self._available: bool = False self._topology_service_down: bool = False self._platform_ready: bool | None = None self._platform_checked_at: float = 0.0 - self._try_initial_connect() + self.try_connect() @staticmethod - def _is_port_open(host=None, port=None, timeout=2) -> bool: - host = host or os.environ.get("GRIDAPPSD_ADDRESS", "localhost") - port = int(port or os.environ.get("GRIDAPPSD_PORT", 61613)) + def _is_port_open() -> bool: + host = os.environ.get("GRIDAPPSD_ADDRESS", "localhost") + port = int(os.environ.get("GRIDAPPSD_PORT") or 61613) try: - with socket.create_connection((host, port), timeout=timeout): + with socket.create_connection((host, port), timeout=2): return True - except (ConnectionRefusedError, TimeoutError, OSError): + except OSError: return False - def _try_initial_connect(self): - if not self._is_port_open(): - logger.warning("GridAPPS-D port 61613 is not open — skipping connection. Features disabled.") - print("port not available") - self.gapps = None - self._available = False - return - - try: - self.gapps = GridAPPSD() - self._available = self.is_connected() - if self._available: - logger.info("Connected to GridAPPS-D") - else: - logger.warning("GridAPPS-D client built but no session established — features disabled.") - self.gapps = None - except Exception as e: - logger.warning(f"GridAPPS-D is not reachable — features disabled. ({e})") - self.gapps = None - self._available = False - def try_connect(self) -> bool: + """(Re)connect to the broker. Features stay disabled while it is absent.""" self.disconnect() if not self._is_port_open(): - logger.warning("GridAPPS-D port still not open.") + logger.warning("GridAPPS-D port is not open — features disabled.") self._available = False return False try: self.gapps = GridAPPSD() self._available = self.is_connected() - if not self._available: - logger.warning("GridAPPS-D client built but no session established.") - self.gapps = None - return False - self._topology_service_down = False # give the topology service another chance - logger.info("Reconnected to GridAPPS-D") - return True except Exception as e: - logger.warning(f"Reconnection failed: {e}") + logger.warning(f"GridAPPS-D is not reachable — features disabled. ({e})") self.gapps = None self._available = False return False + if not self._available: + logger.warning("GridAPPS-D client built but no session established — features disabled.") + self.gapps = None + return False + + self._topology_service_down = False # give the topology service another chance + logger.info("Connected to GridAPPS-D") + return True + def _ensure_connected(self): if not self._available: raise GridAPPSDError("GridAPPS-D is not available. Call try_connect() or restart with GridAPPS-D running.") @@ -110,6 +85,14 @@ def _ensure_connected(self): self.gapps = None raise GridAPPSDError("Lost connection to GridAPPS-D. Call try_connect() to re-establish.") + def _target_sim(self, sim_id: str | None = None) -> str: + """The simulation to act on: `sim_id`, else the tracked one.""" + self._ensure_connected() + target_id = sim_id or self.sim_id + if not target_id: + raise GridAPPSDError("No simulation ID available. Start a simulation first.") + return target_id + def is_connected(self) -> bool: """Check if connected to GridAPPS-D""" if self.gapps is None: @@ -119,18 +102,14 @@ def is_connected(self) -> bool: except Exception: return False - def is_available(self) -> bool: - return self._available and self.is_connected() - - def is_platform_ready(self, force: bool = False) -> bool: + def is_platform_ready(self) -> bool: if not self.is_connected(): self._platform_ready = None return False now = time.monotonic() if ( - not force - and self._platform_ready is not None + self._platform_ready is not None and now - self._platform_checked_at < PLATFORM_PROBE_TTL ): return self._platform_ready @@ -175,7 +154,7 @@ def get_models(self) -> list: logger.error(f"Failed to retrieve models: {e}") raise GridAPPSDError(f"Failed to retrieve models: {e}") from e - def get_distributed_areas(self, model_mrid: str, timeout: int | None = None) -> dict | None: + def get_distributed_areas(self, model_mrid: str) -> dict | None: if self._topology_service_down: logger.info( f"Skipping topology request for {model_mrid}: service marked " @@ -183,9 +162,6 @@ def get_distributed_areas(self, model_mrid: str, timeout: int | None = None) -> ) return None - if timeout is None: - timeout = int(os.environ.get("GLIMPSE_TOPOLOGY_TIMEOUT", "30")) - self._ensure_connected() topic = "goss.gridappsd.request.data.cimtopology" message = { @@ -193,6 +169,7 @@ def get_distributed_areas(self, model_mrid: str, timeout: int | None = None) -> "mRID": model_mrid, "resultFormat": "JSON", } + timeout = int(os.environ.get("GLIMPSE_TOPOLOGY_TIMEOUT", "30")) try: response = self.gapps.get_response(topic, message, timeout=timeout) except Exception as e: @@ -260,17 +237,7 @@ def start_simulation(self, sim_config: dict) -> dict: raise GridAPPSDError(f"Failed to start simulation: {e}") from e def _send_sim_command(self, command: str, sim_id: str | None = None) -> dict: - """Send a command to the simulation (internal helper).""" - self._ensure_connected() - target_id = sim_id or self.sim_id - - if not target_id: - raise GridAPPSDError( - "No simulation ID available. Start a simulation first." - ) - - topic = topics.simulation_input_topic(target_id) - + topic = topics.simulation_input_topic(self._target_sim(sim_id)) try: response = self.gapps.get_response(topic, {"command": command}, timeout=30) return response or {} @@ -293,20 +260,12 @@ def resume_simulation(self, sim_id: str | None = None) -> dict: def stop_simulation(self, sim_id: str | None = None) -> dict: """Stop the current or specified simulation.""" - self._ensure_connected() - target_id = sim_id or self.sim_id - - if not target_id: - raise GridAPPSDError("No simulation ID available.") - - topic = topics.simulation_input_topic(target_id) - + target_id = self._target_sim(sim_id) try: - self.gapps.send(topic, {"command": "stop"}) + self.gapps.send(topics.simulation_input_topic(target_id), {"command": "stop"}) self.sim_state = SimulationState.STOPPED logger.info(f"Simulation stopped: {target_id}") - # Cleanup if stopping the tracked simulation if target_id == self.sim_id: self.sim_id = None @@ -315,64 +274,27 @@ def stop_simulation(self, sim_id: str | None = None) -> dict: self.sim_state = SimulationState.ERROR raise GridAPPSDError(f"Failed to stop simulation: {e}") from e - def send_simulation_input(self, input_data: dict, sim_id: str | None = None) -> None: - """Send input data to the simulation.""" - self._ensure_connected() - target_id = sim_id or self.sim_id - - if not target_id: - raise GridAPPSDError("No simulation ID available. Start a simulation first.") - - topic = topics.simulation_input_topic(target_id) - + def send_simulation_input(self, input_data: dict) -> None: + """Send input data to the tracked simulation.""" + target_id = self._target_sim() try: - self.gapps.send(topic, input_data) + self.gapps.send(topics.simulation_input_topic(target_id), input_data) logger.debug(f"Sent input to simulation {target_id}: {input_data}") except Exception as e: raise GridAPPSDError(f"Failed to send input: {e}") from e # ─── Simulation Output Subscription ─────────────────────────────── - def subscribe_to_simulation_output(self, callback: Callable, sim_id: str | None = None): - """Subscribe to simulation output. Callback receives (headers, message).""" - self._ensure_connected() - target_id = sim_id or self.sim_id + def subscribe_to_simulation_output(self, callback: Callable): + """Callback receives (headers, message).""" + self._subscribe(topics.simulation_output_topic(self._target_sim()), callback, "simulation output") - if not target_id: - raise GridAPPSDError("No simulation ID available. Start a simulation first.") - - topic = topics.simulation_output_topic(target_id) - logger.info(f"Subscribing to simulation output on: {topic}") - - try: - self.gapps.subscribe(topic, callback=callback) - except Exception as e: - raise GridAPPSDError( - f"Failed to subscribe to simulation output: {e}" - ) from e - - def subscribe_to_simulation_log(self, callback: Callable, sim_id: str | None = None): - """Subscribe to simulation log messages.""" - self._ensure_connected() - target_id = sim_id or self.sim_id - - if not target_id: - raise GridAPPSDError("No simulation ID available.") - - topic = topics.simulation_log_topic(target_id) - logger.info(f"Subscribing to simulation log on: {topic}") + def subscribe_to_simulation_log(self, callback: Callable): + self._subscribe(topics.simulation_log_topic(self._target_sim()), callback, "simulation log") + def _subscribe(self, topic: str, callback: Callable, what: str): + logger.info(f"Subscribing to {what} on: {topic}") try: self.gapps.subscribe(topic, callback=callback) except Exception as e: - raise GridAPPSDError(f"Failed to subscribe to simulation log: {e}") from e - - # ─── Status ─────────────────────────────────────────────────────── - - def get_status(self) -> dict: - """Return a full status snapshot.""" - return { - "connected": self.is_connected(), - "simulation_id": self.sim_id, - "simulation_state": self.sim_state.value, - } + raise GridAPPSDError(f"Failed to subscribe to {what}: {e}") from e diff --git a/local-server/jsonhelper.py b/local-server/jsonhelper.py index 3f765f2c..577d6fc5 100644 --- a/local-server/jsonhelper.py +++ b/local-server/jsonhelper.py @@ -9,9 +9,6 @@ class JSONHelper: # list under "edges" (>=3.6) or "links" (older releases), so either counts. _NODE_LINK_REQUIRED = ("directed", "multigraph", "nodes") - def __init__(self): - pass - def _load_schema(self, schema_name: str) -> dict: schema_path = os.path.join(os.path.dirname(__file__), "schemas", schema_name) with open(schema_path, "r") as f: @@ -32,7 +29,6 @@ def _node_link_to_objects(self, file_data: dict) -> list: """ objects = [] - # Process nodes for node in file_data.get("nodes", []): if "type" in node and isinstance(node["type"], dict): object_type = "-".join(node["type"].get("path", [])) @@ -54,7 +50,6 @@ def _node_link_to_objects(self, file_data: dict) -> list: } ) - # Process edges ("edges" in newer NetworkX, "links" in older releases) edge_list = file_data.get("edges") if edge_list is None: edge_list = file_data.get("links", []) @@ -82,64 +77,40 @@ def _node_link_to_objects(self, file_data: dict) -> list: return objects - def validate_json_data(self, json_data: dict) -> dict: - # Load the JSON schema once for the whole batch - json_upload_schema = self._load_schema("json_upload.schema.json") - - data = {} - for file_path, file_data in json_data.items(): - if self._is_node_link_data(file_data): - # Transform node-link data to GLIMPSE format - data[file_path] = {"objects": self._node_link_to_objects(file_data)} - else: - # Validate against the GLIMPSE JSON schema - try: - jsonschema.validate(instance=file_data, schema=json_upload_schema) - data[file_path] = file_data - except jsonschema.ValidationError as e: - raise ValueError( - f"JSON validation error for {file_path}: {e.message}" - ) - - return data - - def prepare_graph_payload(self, data, name: str = "socket-graph") -> dict: - if not isinstance(data, dict): - raise ValueError("Graph payload must be a JSON object.") - + def _to_objects_format(self, data, error_prefix: str) -> dict: + """Node-link data is converted; anything else must already match the schema.""" if self._is_node_link_data(data): - return {name: {"objects": self._node_link_to_objects(data)}} + return {"objects": self._node_link_to_objects(data)} - # Otherwise expect the GLIMPSE objects format and validate it - json_upload_schema = self._load_schema("json_upload.schema.json") try: - jsonschema.validate(instance=data, schema=json_upload_schema) + jsonschema.validate(instance=data, schema=self._load_schema("json_upload.schema.json")) except jsonschema.ValidationError as e: - raise ValueError(f"Graph validation error: {e.message}") + raise ValueError(f"{error_prefix}: {e.message}") + return data - return {name: data} + def validate_json_data(self, json_data: dict) -> dict: + return { + file_path: self._to_objects_format(file_data, f"JSON validation error for {file_path}") + for file_path, file_data in json_data.items() + } - def validate_json_theme(self, json_theme_filename: str) -> dict: - # Load the JSON schema - json_theme_schema = self._load_schema("theme_upload.schema.json") + def prepare_graph_payload(self, data) -> dict: + if not isinstance(data, dict): + raise ValueError("Graph payload must be a JSON object.") + return {"socket-graph": self._to_objects_format(data, "Graph validation error")} - theme_data = None + def validate_json_theme(self, json_theme_filename: str) -> dict: with open(json_theme_filename, "r") as f: theme_data = json.load(f) try: - jsonschema.validate(instance=theme_data, schema=json_theme_schema) + jsonschema.validate(instance=theme_data, schema=self._load_schema("theme_upload.schema.json")) return theme_data except jsonschema.ValidationError as e: raise ValueError(f"JSON theme validation error: {e.message}") - def is_theme_file(self, filename): - """Check if filename matches .theme.json pattern""" - parts = filename.split(".") - return len(parts) >= 3 and parts[-2] == "theme" and parts[-1] == "json" - - def get_theme_filename(self, paths: list[str]) -> str | None: - for path in paths: - if self.is_theme_file(os.path.basename(path)): - return path - return None + def split_theme(self, paths: list[str]) -> tuple[dict | None, list[str]]: + """(validated data of the first .theme.json or None, the other paths).""" + theme_path = next((p for p in paths if os.path.basename(p).endswith(".theme.json")), None) + theme_data = self.validate_json_theme(theme_path) if theme_path else None + return theme_data, [p for p in paths if p != theme_path] diff --git a/local-server/server.py b/local-server/server.py index 5dcd1867..19aa7d1d 100755 --- a/local-server/server.py +++ b/local-server/server.py @@ -1,9 +1,11 @@ +import functools import hmac import json import os import shutil import tempfile import traceback +from contextlib import contextmanager import gevent from flask import Flask, jsonify, request, send_file @@ -41,37 +43,34 @@ def run_cim_parse(fn, **kwargs): return result -# agents-update is keyed by a model id the caller chooses, so the cache is -# capped rather than left to grow for the life of the process. -MAX_CACHED_AGENT_ROSTERS = 32 +def count_objects(gjs: dict) -> int: + return sum(len(entry.get("objects", [])) for entry in (gjs or {}).values()) + + +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ("1", "true", "yes") # ================================================================================================ # FLASK APP SETUP # ================================================================================================ -_default_cors_origins = [ - "http://localhost:5173", - "http://localhost:4173", - "http://localhost:3000", - "http://localhost:61613", - "http://127.0.0.1:5173", - "http://127.0.0.1:4173", - "http://127.0.0.1:3000", - "http://127.0.0.1:61613", -] - _cors_env = os.environ.get("CORS_ORIGINS", "").strip() if _cors_env == "*": cors_origins = "*" elif _cors_env: cors_origins = [origin.strip() for origin in _cors_env.split(",") if origin.strip()] else: - cors_origins = _default_cors_origins - -methods = ["GET", "POST", "DELETE", "OPTIONS"] -allowed_headers = ["Content-Type", "Authorization"] -allow_credentials = cors_origins != "*" + cors_origins = [ + "http://localhost:5173", + "http://localhost:4173", + "http://localhost:3000", + "http://localhost:61613", + "http://127.0.0.1:5173", + "http://127.0.0.1:4173", + "http://127.0.0.1:3000", + "http://127.0.0.1:61613", + ] app = Flask(__name__) MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "65")) @@ -80,38 +79,48 @@ def run_cim_parse(fn, **kwargs): CORS( app, origins=cors_origins, - methods=methods, - allow_headers=allowed_headers, - supports_credentials=allow_credentials, + methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + supports_credentials=cors_origins != "*", ) socketio = SocketIO( app, async_mode="gevent", cors_allowed_origins=cors_origins, allow_upgrades=True ) +# The main thread's hub, captured here: get_hub() on a broker callback thread +# would create a separate hub for that thread. _hub = gevent.get_hub() def emit_threadsafe(event: str, payload): - """socketio.emit() from a non-greenlet thread. See _hub above.""" + """socketio.emit() from a non-greenlet thread.""" _hub.loop.run_callback_threadsafe(socketio.emit, event, payload) -EXPOSE_TRACEBACKS = os.environ.get("EXPOSE_TRACEBACKS", "").strip().lower() in ( - "1", - "true", - "yes", -) +EXPOSE_TRACEBACKS = _env_flag("EXPOSE_TRACEBACKS") -def error_body(exc, tb, message=None, extra=None): - """Build an error response body. Always logs the traceback server-side, but - only exposes it to the client when EXPOSE_TRACEBACKS is set.""" +def error_body(exc, message=None): + """Error body for the exception being handled. Always logs the traceback, + but only exposes it to the client when EXPOSE_TRACEBACKS is set.""" + tb = traceback.format_exc() print(tb) body = {"error": message if message is not None else str(exc)} - if extra: - body.update(extra) if EXPOSE_TRACEBACKS: body["traceback"] = tb return body +def json_errors(status=500): + """Answer any exception escaping the view with error_body at `status`.""" + def decorate(view): + @functools.wraps(view) + def wrapper(*args, **kwargs): + try: + return view(*args, **kwargs) + except Exception as e: + return error_body(e), status + return wrapper + return decorate + + # Every route returns { "error": ... } on the failures it anticipates. Without # these, anything else — the 413 Flask raises from MAX_CONTENT_LENGTH, a wrong # Content-Type, an exception escaping a view — comes back as Werkzeug's HTML @@ -130,24 +139,20 @@ def _http_error(exc): @app.errorhandler(Exception) def _unhandled_error(exc): - return error_body(exc, traceback.format_exc(), message=f"Server error: {exc}"), 500 + return error_body(exc, message=f"Server error: {exc}"), 500 # Without this an exception inside a socket handler returns nothing at all — # the caller's ack callback simply never fires and the script hangs. @socketio.on_error_default def _socket_error(exc): - return error_body(exc, traceback.format_exc()) + return error_body(exc) EXPORT_BASE_DIR = os.path.abspath( os.environ.get("GLIMPSE_EXPORT_DIR", os.path.join(tempfile.gettempdir(), "glimpse_exports")) ) -ALLOW_ANY_EXPORT_PATH = os.environ.get("GLIMPSE_ALLOW_ANY_EXPORT_PATH", "").strip().lower() in ( - "1", - "true", - "yes", -) +ALLOW_ANY_EXPORT_PATH = _env_flag("GLIMPSE_ALLOW_ANY_EXPORT_PATH") def safe_export_path(user_path): """Resolve a client-supplied export path. Raises ValueError if it escapes the @@ -165,6 +170,24 @@ def safe_export_path(user_path): return candidate +@contextmanager +def saved_uploads(prefix): + """Save the request's 'files' into a temp dir, yielding their paths; the dir + is removed on exit.""" + tmpdir = tempfile.mkdtemp(prefix=prefix) + try: + paths = [] + for f in request.files.getlist("files"): + if not f or f.filename == "": + continue + dest_path = os.path.join(tmpdir, secure_filename(f.filename)) + f.save(dest_path) + paths.append(dest_path) + yield paths + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- @@ -244,24 +267,6 @@ def _example_model_path(example_id): return path if os.path.isfile(path) else None -def _example_available(example_id) -> bool: - return _example_model_path(example_id) is not None - - -def build_example_payload(entry, path): - object_details = {} - if entry["format"] == "glm": - data = glm_helper.parse_glm([path]) - else: - data, object_details = run_cim_parse(cim_helper.cim_to_gjs, filepaths=[path]) - return { - "data": data, - "themeData": None, - "objectDetails": object_details, - "isCIM": entry["format"] == "cim", - } - - # ================================================================================================ # EXAMPLE MODEL ENDPOINTS # ================================================================================================ @@ -278,7 +283,7 @@ def list_examples(): "format": entry["format"], } for example_id, entry in EXAMPLE_MODELS.items() - if _example_available(example_id) + if _example_model_path(example_id) ] return jsonify({"examples": examples}), 200 @@ -289,17 +294,21 @@ def load_example(): return jsonify({"error": "Request must be JSON"}), 400 example_id = (request.get_json() or {}).get("id") - entry = EXAMPLE_MODELS.get(example_id) - if entry is None or not _example_available(example_id): + path = _example_model_path(example_id) + if path is None: return jsonify({"error": f"Unknown or unavailable example model: {example_id}"}), 404 - path = _example_model_path(example_id) - try: - return jsonify(build_example_payload(entry, path)) - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb, message=f"Server error: {str(e)}"), 500 + is_cim = EXAMPLE_MODELS[example_id]["format"] == "cim" + if is_cim: + data, object_details = run_cim_parse(cim_helper.cim_to_gjs, filepaths=[path]) + else: + data, object_details = glm_helper.parse_glm([path]), {} + return jsonify({ + "data": data, + "themeData": None, + "objectDetails": object_details, + "isCIM": is_cim, + }) # ================================================================================================ @@ -307,88 +316,54 @@ def load_example(): # ================================================================================================ -@app.route("/api/cim/objects", methods=["POST"]) -def get_object(): - try: - if not request.is_json: - return jsonify({"error": "Request must be JSON"}), 400 - - data = request.get_json() - feeder_id = data.get("feeder_id") - mRID = data.get("mRID") +def _feeder_and_mrid(): + """(feeder_id, mRID, None) from the JSON body, or (None, None, error response).""" + if not request.is_json: + return None, None, (jsonify({"error": "Request must be JSON"}), 400) - if not feeder_id or not mRID: - return jsonify({"error": "Both 'feeder_id' and 'mRID' are required"}), 400 + data = request.get_json() + feeder_id = data.get("feeder_id") + mRID = data.get("mRID") + if not feeder_id or not mRID: + return None, None, (jsonify({"error": "Both 'feeder_id' and 'mRID' are required"}), 400) + return feeder_id, mRID, None - res = cim_helper.get_cim_object(feeder_id, mRID) - if "error" in res: - return res, 404 - return res, 200 +@app.route("/api/cim/objects", methods=["POST"]) +@json_errors() +def get_object(): + feeder_id, mRID, error = _feeder_and_mrid() + if error: + return error - except Exception as e: - tb = traceback.format_exc() - print(tb) - return jsonify(error_body(e, tb)), 500 + res = cim_helper.get_cim_object(feeder_id, mRID) + return res, 404 if "error" in res else 200 @app.route("/api/cim/objects", methods=["DELETE"]) +@json_errors() def delete_object(): - try: - if not request.is_json: - return jsonify({"error": "Request must be JSON"}), 400 - - data = request.get_json() - feeder_id = data.get("feeder_id") - mRID = data.get("mRID") - - if not feeder_id or not mRID: - return jsonify({"error": "Both 'feeder_id' and 'mRID' are required"}), 400 - - if cim_helper.delete_cim_object(feeder_id, mRID): - return jsonify( - { - "success": True, - "feeder_id": feeder_id, - "mRID": mRID, - "message": "Object deleted successfully", - } - ) - else: - return ( - jsonify( - {"error": f"Failed to delete object {mRID} in feeder {feeder_id}"} - ), - 400, - ) + feeder_id, mRID, error = _feeder_and_mrid() + if error: + return error - except Exception as e: - tb = traceback.format_exc() - print(tb) - return jsonify(error_body(e, tb)), 500 + if not cim_helper.delete_cim_object(feeder_id, mRID): + return jsonify({"error": f"Failed to delete object {mRID} in feeder {feeder_id}"}), 400 + return jsonify({ + "success": True, + "feeder_id": feeder_id, + "mRID": mRID, + "message": "Object deleted successfully", + }) @app.route("/api/cim/objects/mermaid", methods=["POST"]) +@json_errors() def get_object_mermaid(): - try: - if not request.is_json: - return jsonify({"error": "Request must be JSON"}), 400 - - data = request.get_json() - print(data) - feeder_id = data.get("feeder_id") - mRID = data.get("mRID") - - if not feeder_id or not mRID: - return jsonify({"error": "Both 'feeder_id' and 'mRID' are required"}), 400 - - res = cim_helper.get_mermaid(feeder_id, mRID) - return res, 200 - - except Exception as e: - tb = traceback.format_exc() - print(tb) - return jsonify(error_body(e, tb)), 500 + feeder_id, mRID, error = _feeder_and_mrid() + if error: + return error + return cim_helper.get_mermaid(feeder_id, mRID), 200 # ================================================================================================ @@ -398,63 +373,25 @@ def get_object_mermaid(): @app.route("/api/upload/json", methods=["POST"]) def upload_json(): - # Validate presence of 'files' in form-data if "files" not in request.files: return {"error": "No 'files' part in the form data."}, 400 - files = request.files.getlist("files") - if not files: - return {"error": "No files uploaded."}, 400 - - tmpdir = tempfile.mkdtemp(prefix="json_upload_") - paths = [] - - try: - # Save uploaded files - for f in files: - if not f or f.filename == "": - continue - filename = secure_filename(f.filename) - dest_path = os.path.join(tmpdir, filename) - f.save(dest_path) - paths.append(dest_path) - + with saved_uploads("json_upload_") as paths: if not paths: return {"error": "No valid files received."}, 400 - # Filter out theme files from paths and store separately - theme_filename = json_helper.get_theme_filename(paths) - - themeData = None - if theme_filename: - themeData = json_helper.validate_json_theme(theme_filename) + theme_data, json_paths = json_helper.split_theme(paths) - # Read JSON files json_dict = {} - for path in paths: - if path == theme_filename: - continue + for path in json_paths: with open(path, "r") as json_file: json_dict[os.path.basename(path)] = json.load(json_file) - # Validate and transform JSON data try: validated_data = json_helper.validate_json_data(json_dict) - # themeData is already None to begin with if there was no theme file in the paths - response_data = {"data": validated_data, "themeData": themeData} - return jsonify(response_data) except ValueError as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb), 400 - - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb, message=f"Server error: {str(e)}"), 500 - finally: - # Clean up temp files/dir - shutil.rmtree(tmpdir, ignore_errors=True) + return error_body(e), 400 + return jsonify({"data": validated_data, "themeData": theme_data}) # ================================================================================================ @@ -464,52 +401,16 @@ def upload_json(): @app.route("/api/upload/glm", methods=["POST"]) def glm_upload(): - files = request.files.getlist("files") - if not files: + if not request.files.getlist("files"): return {"error": "No files uploaded."}, 400 - tmpdir = tempfile.mkdtemp(prefix="glm_upload_") - paths = [] - - try: - for f in files: - - if not f or f.filename == "": - continue - - filename = secure_filename(f.filename) - dest_path = os.path.join(tmpdir, filename) - f.save(dest_path) - paths.append(dest_path) - + with saved_uploads("glm_upload_") as paths: if not paths: return {"error": "No valid files received."}, 400 - print("\n" + "=" * 30) - print(f"Received files: {paths}") - print("=" * 30) - - theme_filename = json_helper.get_theme_filename(paths) - themeData = None - if theme_filename: - themeData = json_helper.validate_json_theme(theme_filename) - - filtered_paths = [p for p in paths if p != theme_filename] - - print(f"[SERVER] Processing {len(filtered_paths)} GLM files...") - glm_dict = glm_helper.parse_glm(filtered_paths) # expects list of paths - print(f"[SERVER] GLM parsing completed successfully") - - return jsonify({"data": glm_dict, "themeData": themeData}) - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb, message=f"Server error: {str(e)}"), 500 - finally: - # ignore_errors already tolerates a handle Windows hasn't released yet, - # which is all the old gc.collect() + sleep(0.5) here was buying — and - # that sleep stalled every other greenlet, live sim output included. - shutil.rmtree(tmpdir, ignore_errors=True) + print(f"[SERVER] Parsing GLM upload: {paths}") + theme_data, glm_paths = json_helper.split_theme(paths) + return jsonify({"data": glm_helper.parse_glm(glm_paths), "themeData": theme_data}) @app.route("/api/export/glm", methods=["POST"]) def export_glm(): @@ -525,27 +426,19 @@ def export_glm(): tmpdir = tempfile.mkdtemp(prefix="glm_export_") try: - zip_buffer = glm_helper.json_to_glm(data, tmpdir) - return send_file( zip_buffer, mimetype="application/zip", as_attachment=True, download_name="exported_model.zip" ) - except ValueError as e: # An unusable or escaping file name in the payload — a client error. return {"error": str(e)}, 400 - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb, message=f"Export failed: {str(e)}"), 500 - + return error_body(e, message=f"Export failed: {e}"), 500 finally: - # Clean up temp directory shutil.rmtree(tmpdir, ignore_errors=True) # ================================================================================================ @@ -555,35 +448,18 @@ def export_glm(): @app.route("/api/upload/cim", methods=["POST"]) def cim_to_glimpse(): - # Validate presence of 'files' in form-data if "files" not in request.files: return {"error": "No 'files' part in the form data."}, 400 - files = request.files.getlist("files") - if not files: - return {"error": "No files uploaded."}, 400 - - tmpdir = tempfile.mkdtemp(prefix="cim_upload_") - paths = [] - - try: - for f in files: - if not f or f.filename == "": - continue - filename = secure_filename(f.filename) - dest_path = os.path.join(tmpdir, filename) - f.save(dest_path) - paths.append(dest_path) - + with saved_uploads("cim_upload_") as paths: if not paths: return {"error": "No valid files received."}, 400 - # See run_cim_parse: serialized per process. glimpse_structure_data, object_details = run_cim_parse( cim_helper.cim_to_gjs, filepaths=paths ) - if cim_helper.count_objects(glimpse_structure_data) == 0: + if count_objects(glimpse_structure_data) == 0: # An unreadable model parses to an empty graph rather than raising, # which would otherwise load a blank canvas with no explanation. return { @@ -600,23 +476,11 @@ def cim_to_glimpse(): "isCIM": True, } - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb, message=f"Server error: {str(e)}"), 500 - finally: - # Cleanup - shutil.rmtree(tmpdir, ignore_errors=True) - @app.route("/api/export/export-cim", methods=["POST"]) def export_cim_file(): - # CIM structural export (adding / rewiring objects and writing a new XML) is - # unfinished: cim_helper.export_cim and its cimbuilder dependencies are still - # commented out (see cimhelper.py), and no client calls this route yet. Return - # an explicit 501 rather than the previous 500 AttributeError. When the export - # logic is completed, validate the destination with safe_export_path() before - # writing (as export-cim-coordinates does). + # CIM structural export is unfinished and no client calls this route yet. + # When implemented, validate the destination with safe_export_path(). return jsonify({"error": "CIM structural export is not implemented yet."}), 501 @@ -638,22 +502,16 @@ def export_cim_coordinates(): try: cim_helper.export_cim_coords(feeder_id, new_coords_obj, output_path) except Exception as e: - tb = traceback.format_exc() - return jsonify(error_body(e, tb)), 500 + return error_body(e), 500 return "", 204 @app.route("/api/cim/measurements", methods=["GET"]) +@json_errors() def get_cim_measurements(): - # Expose the measurement map built at CIM model-load time so the frontend plot - # creator can list device measurements before a simulation starts. Returns an - # empty list for non-CIM models (GLM/JSON have no measurement map). - try: - return jsonify({"measurements": cim_helper.get_measurement_catalog()}), 200 - except Exception as e: - tb = traceback.format_exc() - print(tb) - return jsonify(error_body(e, tb)), 500 + # The measurement map built at CIM load time, so the plot creator can list + # device measurements before a simulation starts. Empty for GLM/JSON models. + return jsonify({"measurements": cim_helper.get_measurement_catalog()}), 200 # ================================================================================================ @@ -685,12 +543,11 @@ def load_models(): else: print("No topology outputs retrieved from GridAPPS-D; falling back to CIM model") - gjs, object_details = cim_helper.cim_to_gjs( + return cim_helper.cim_to_gjs( model_IDs=req_data, topology_outputs=topology_outputs, progress_cb=progress.update, ) - return gjs, object_details with cim_load_lock: worker = gevent.get_hub().threadpool.spawn(load_models) @@ -706,55 +563,40 @@ def load_models(): return jsonify({"error": "No data returned for the given model IDs"}), 404 return jsonify({"data": gjs, "themeData": None, "objectDetails": object_details, "isCIM": True}), 200 except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb), 500 + return error_body(e), 500 @app.route("/api/gridappsd/agents", methods=["GET"]) +@json_errors() def get_agents(): model_id = request.args.get("model") or "" - source = request.args.get("source") or "derived" - try: - if not model_id: - loaded = list(cim_helper.area_maps.keys()) - if len(loaded) != 1: - return jsonify({ - "error": "A 'model' query parameter is required when zero or " - "several models are loaded.", - "loaded": loaded, - }), 400 - model_id = loaded[0] - - if model_id not in cim_helper.area_maps: - return jsonify({"error": f"Model {model_id} is not loaded."}), 404 - - return jsonify(agenthelper.build_agent_model( - area_map=cim_helper.area_maps.get(model_id, {}), - object_index=cim_helper.object_index.get(model_id, {}), - model_id=model_id, - source=source, - gridappsd_helper=gridappsd_helper, - )), 200 - except Exception as e: - tb = traceback.format_exc() - print(tb) - return jsonify(error_body(e, tb)), 500 + if not model_id: + loaded = list(cim_helper.area_maps.keys()) + if len(loaded) != 1: + return jsonify({ + "error": "A 'model' query parameter is required when zero or " + "several models are loaded.", + "loaded": loaded, + }), 400 + model_id = loaded[0] + + if model_id not in cim_helper.area_maps: + return jsonify({"error": f"Model {model_id} is not loaded."}), 404 + + return jsonify(agenthelper.build_agent_model( + area_map=cim_helper.area_maps[model_id], + object_index=cim_helper.object_index.get(model_id, {}), + model_id=model_id, + )), 200 @app.route("/api/gridappsd/model-info", methods=["GET"]) +@json_errors(status=503) def get_gridappsd_models(): - try: - if not gridappsd_helper.is_connected(): - return {"error": "Not connected to GridAPPS-D"}, 503 - - models = gridappsd_helper.get_models() - return jsonify(models), 200 - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb), 503 + if not gridappsd_helper.is_connected(): + return {"error": "Not connected to GridAPPS-D"}, 503 + return jsonify(gridappsd_helper.get_models()), 200 @app.route("/api/gridappsd/status", methods=["GET"]) @@ -784,18 +626,11 @@ def get_gridappsd_status(): else: message = "Not connected to GridAPPS-D" - return ( - json.dumps({"connected": connected, "message": message}), - 200, - ) + return json.dumps({"connected": connected, "message": message}), 200 except Exception as e: - tb = traceback.format_exc() - print(tb) - return ( - json.dumps(error_body(e, tb, extra={"connected": False})), - 200, - ) # Return 200 so React app can handle the response + # 200 so the React app can handle the response + return json.dumps({**error_body(e), "connected": False}), 200 # ================================================================================================ @@ -803,57 +638,46 @@ def get_gridappsd_status(): # ================================================================================================ @socketio.on("load-graph") def load_graph(data): + # Accept GLIMPSE objects format or NetworkX node-link data and normalize + # it into the { name: { objects: [...] } } shape the frontend consumes. try: - # Accept GLIMPSE objects format or NetworkX node-link data and normalize - # it into the { name: { objects: [...] } } shape the frontend consumes. prepared = json_helper.prepare_graph_payload(data) - socketio.emit("load-graph", {"data": prepared}) - - object_count = sum(len(f.get("objects", [])) for f in prepared.values()) - return {"status": "ok", "objectCount": object_count} except ValueError as e: print(str(e)) return {"error": str(e)} - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb) + + socketio.emit("load-graph", {"data": prepared}) + return {"status": "ok", "objectCount": count_objects(prepared)} @socketio.on("update") def update_data(data): # Update node/edge color, size, and/or hidden state on the connected frontends. - try: - if not isinstance(data, dict): - return {"error": "Update payload must be a JSON object."} + if not isinstance(data, dict): + return {"error": "Update payload must be a JSON object."} - object_id = data.get("id") - element_type = data.get("elementType") - updates = data.get("updates") + object_id = data.get("id") + element_type = data.get("elementType") + updates = data.get("updates") - if object_id is None or element_type not in ("node", "edge"): - return { - "error": "Update payload requires 'id' and 'elementType' ('node' or 'edge')." - } - if not isinstance(updates, dict): - return {"error": "Update payload requires an 'updates' object."} - - # Normalize to the supported update keys; null means "leave unchanged". - normalized = { - "id": object_id, - "elementType": element_type, - "updates": { - "color": updates.get("color"), - "size": updates.get("size"), - "hidden": updates.get("hidden"), - }, + if object_id is None or element_type not in ("node", "edge"): + return { + "error": "Update payload requires 'id' and 'elementType' ('node' or 'edge')." } - socketio.emit("update-data", normalized) - return {"status": "ok"} - except Exception as e: - tb = traceback.format_exc() - print(tb) - return error_body(e, tb) + if not isinstance(updates, dict): + return {"error": "Update payload requires an 'updates' object."} + + # Normalize to the supported update keys; null means "leave unchanged". + socketio.emit("update-data", { + "id": object_id, + "elementType": element_type, + "updates": { + "color": updates.get("color"), + "size": updates.get("size"), + "hidden": updates.get("hidden"), + }, + }) + return {"status": "ok"} @socketio.on("add-node") @@ -893,14 +717,6 @@ def agents_update(payload): if not isinstance(payload, dict) or not isinstance(payload.get("agents"), list): return {"error": "agents-update requires an object with an 'agents' list."} - model_id = payload.get("model") - if model_id: - cache = gridappsd_helper.agent_roster_cache - cache.pop(model_id, None) - cache[model_id] = payload - while len(cache) > MAX_CACHED_AGENT_ROSTERS: - cache.popitem(last=False) - socketio.emit("agents-update", payload) return {"status": "ok", "agentCount": len(payload["agents"])} @@ -909,82 +725,41 @@ def agents_update(payload): # GRIDAPPS-D REAL-TIME WEBSOCKET EVENTS # ================================================================================================ -# ─── SocketIO Event Handlers ────────────────────────────────────── - @socketio.on("start-simulation") def handle_start_simulation(config): try: result = gridappsd_helper.start_simulation(config) - # Subscribe to output and relay to the client via WebSocket + # Decode measurements through the CIM measurement map into + # equipment-level updates and relay them to the frontends. def on_sim_output(headers, message: dict): - sim_output = {"timestamp": "", "Analog": [], "Discrete": []} - - # Process measurements through the map to emit equipment-level updates - active_measurement_map = cim_helper.active_measurement_map - if active_measurement_map: - msg = message.get("message", message) - measurements = msg.get("measurements", {}) - sim_output["timestamp"] = msg.get("timestamp") - - - for measurment_mRID, measurment_data in measurements.items(): - - if measurment_mRID in active_measurement_map["Analog"]: - mapping = active_measurement_map["Analog"].get(measurment_mRID) - - if not mapping: - continue - - eq_type = mapping.get("conducting_equipment_type", "") - eq_mRID = mapping.get("conducting_equipment_mrid", "") - conducting_eq_name = mapping.get("conducting_equipment_name", "") - measurement_type = mapping.get("measurement_type", "") - - measurment_output = { - "equipment_mrid": eq_mRID, - "equipment_name": conducting_eq_name, - "equipment_type": eq_type, - "measurement_type": measurement_type, # Pos, PNV, VA - "phases": mapping.get("phases", ""), - "connectivity_node_mrid": mapping.get("connectivity_node_mrid", ""), - **measurment_data - } - - normal_limit = gridappsd_helper.current_limit_map.get(eq_mRID, None) - if normal_limit: - measurment_output["normal_limit"] = normal_limit - - sim_output["Analog"].append(measurment_output) - - - if measurment_mRID in active_measurement_map["Discrete"]: - mapping = active_measurement_map["Discrete"].get(measurment_mRID) - - if not mapping: - continue - - eq_type = mapping.get("conducting_equipment_type", "") - eq_mRID = mapping.get("conducting_equipment_mrid", "") - conducting_eq_name = mapping.get("conducting_equipment_name", "") - measurement_type = mapping.get("measurement_type", "") - - measurment_output = { - "equipment_mrid": eq_mRID, - "equipment_name": conducting_eq_name, - "equipment_type": eq_type, - "measurement_type": measurement_type, # Pos, PNV, VA - "phases": mapping.get("phases", ""), - "connectivity_node_mrid": mapping.get("connectivity_node_mrid", ""), - **measurment_data - } - - normal_limit = gridappsd_helper.current_limit_map.get(eq_mRID, None) - if normal_limit: - measurment_output["normal_limit"] = normal_limit - - sim_output["Discrete"].append(measurment_output) + msg = message.get("message", message) + sim_output = {"timestamp": msg.get("timestamp"), "Analog": [], "Discrete": []} + measurement_map = cim_helper.active_measurement_map + + for measurement_mrid, measurement_data in msg.get("measurements", {}).items(): + for measurement_class in ("Analog", "Discrete"): + mapping = measurement_map[measurement_class].get(measurement_mrid) + if not mapping: + continue + + eq_mrid = mapping.get("conducting_equipment_mrid", "") + measurement_output = { + "equipment_mrid": eq_mrid, + "equipment_name": mapping.get("conducting_equipment_name", ""), + "equipment_type": mapping.get("conducting_equipment_type", ""), + "measurement_type": mapping.get("measurement_type", ""), # Pos, PNV, VA + "phases": mapping.get("phases", ""), + "connectivity_node_mrid": mapping.get("connectivity_node_mrid", ""), + **measurement_data, + } + + normal_limit = gridappsd_helper.current_limit_map.get(eq_mrid) + if normal_limit: + measurement_output["normal_limit"] = normal_limit + + sim_output[measurement_class].append(measurement_output) emit_threadsafe("sim-output", sim_output) @@ -994,9 +769,7 @@ def on_sim_log(headers, message): gridappsd_helper.subscribe_to_simulation_output(on_sim_output) gridappsd_helper.subscribe_to_simulation_log(on_sim_log) - print("=" * 20 + "result" + "=" * 20) - print(json.dumps(result, indent=2)) - print("=" * 46) + print(f"Simulation started:\n{json.dumps(result, indent=2)}") return result except Exception as e: diff --git a/local-server/server.spec b/local-server/server.spec index bcc37a83..bb53195e 100755 --- a/local-server/server.spec +++ b/local-server/server.spec @@ -4,13 +4,10 @@ from PyInstaller.utils.hooks import collect_data_files sys.setrecursionlimit(sys.getrecursionlimit() * 5) -datas = [] -datas += collect_data_files("cimgraph", include_py_files=True) - -# JSON validation schemas loaded at runtime relative to jsonhelper.py -datas += [("schemas", "schemas")] - -datas += [ +datas = collect_data_files("cimgraph", include_py_files=True) + [ + # JSON validation schemas loaded at runtime relative to jsonhelper.py + ("schemas", "schemas"), + # Built-in example models served by server.py ("../models/CIM/IEEE123.xml", "models/CIM"), ("../models/CIM/IEEE9500bal.xml", "models/CIM"), ("../models/3000/3000_model.glm", "models/3000"), diff --git a/local-server/tests/test_smoke.py b/local-server/tests/test_smoke.py new file mode 100644 index 00000000..d9998d65 --- /dev/null +++ b/local-server/tests/test_smoke.py @@ -0,0 +1,67 @@ +"""End-to-end smoke test over the HTTP routes and socket events, using the +bundled sample models. Needs no GridAPPS-D broker.""" +import io +import json +import zipfile +from pathlib import Path + +import server + +MODELS = Path(__file__).resolve().parents[2] / "models" + + +def upload(client, route, name, content): + return client.post(route, data={"files": [(io.BytesIO(content), name)]}) + + +def test_json_upload_converts_node_link(): + node_link = { + "directed": False, "multigraph": True, "graph": {}, + "nodes": [{"id": 1, "type": "a"}, {"id": 2}], + "edges": [{"source": 1, "target": 2, "key": 0}], + } + client = server.app.test_client() + body = upload(client, "/api/upload/json", "g.json", json.dumps(node_link).encode()).get_json() + objects = body["data"]["g.json"]["objects"] + assert [o["elementType"] for o in objects] == ["node", "node", "edge"] + assert objects[2]["attributes"] == {"id": "1-2-0", "from": "1", "to": "2"} + + bad = upload(client, "/api/upload/json", "g.json", b'{"objects": 5}') + assert bad.status_code == 400 and "JSON validation error for g.json" in bad.get_json()["error"] + + +def test_glm_round_trip(): + client = server.app.test_client() + parsed = upload(client, "/api/upload/glm", "IEEE-13.glm", (MODELS / "13/IEEE-13.glm").read_bytes()) + data = parsed.get_json()["data"] + assert data["IEEE-13.json"]["objects"] + + exported = client.post("/api/export/glm", json={"data": data}) + assert exported.status_code == 200 + assert zipfile.ZipFile(io.BytesIO(exported.data)).namelist() == ["IEEE-13.glm"] + + +def test_cim_upload_objects_and_agents(): + client = server.app.test_client() + resp = upload(client, "/api/upload/cim", "IEEE13.xml", (MODELS / "CIM/IEEE13.xml").read_bytes()) + objects = resp.get_json()["data"]["IEEE13.xml"]["objects"] + assert {o["elementType"] for o in objects} == {"node", "edge"} + + mrid = objects[0]["attributes"]["id"] + found = client.post("/api/cim/objects", json={"feeder_id": "IEEE13.xml", "mRID": mrid}) + assert found.status_code == 200 + missing = client.post("/api/cim/objects", json={"feeder_id": "IEEE13.xml", "mRID": "nope"}) + assert missing.status_code == 404 + + agents = client.get("/api/gridappsd/agents?model=IEEE13.xml").get_json() + assert agents["agents"][0]["agent_type"] == "coordinating" + + +def test_socket_events_validate_and_broadcast(): + client = server.socketio.test_client(server.app) + ack = client.emit("update", {"id": "a", "elementType": "node", "updates": {"color": "red"}}, callback=True) + assert ack == {"status": "ok"} + assert client.get_received()[0]["args"][0]["updates"] == {"color": "red", "size": None, "hidden": None} + + assert "error" in client.emit("load-graph", [1], callback=True) + assert "error" in client.emit("stop-simulation", "sim", callback=True) diff --git a/package-lock.json b/package-lock.json index c94c2475..de065167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2625,9 +2625,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -4722,9 +4722,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -5526,9 +5526,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 036bdfa9..521eb9fb 100755 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "start": "concurrently \"npm run dev:backend\" \"vite preview\"", "codespace:build": "npm run build && bash scripts/write-codespace-env.sh", "codespace:start": "concurrently -k \"npm run dev:backend\" \"npm run preview\"", "electron:dev": "concurrently -k \"npm run dev:backend\" \"vite --strictPort --host 127.0.0.1\" \"npm run electron:start\"", @@ -23,9 +22,9 @@ "build:server": "cd local-server && pyinstaller server.spec --noconfirm", "test:server": "cd local-server && .venv/bin/python -m pytest", "dist": "npm run build && npm run build:server && electron-builder", - "dist:win": "npm run build && npm run build:server && electron-builder --win", - "dist:mac": "npm run build && npm run build:server && electron-builder --mac", - "dist:linux": "npm run build && npm run build:server && electron-builder --linux" + "dist:win": "npm run dist -- --win", + "dist:mac": "npm run dist -- --mac", + "dist:linux": "npm run dist -- --linux" }, "dependencies": { "@react-sigma/core": "^5.0.6", diff --git a/socket-testing/EVENTS_API.md b/socket-testing/EVENTS_API.md index 64fc77f2..de9806e6 100644 --- a/socket-testing/EVENTS_API.md +++ b/socket-testing/EVENTS_API.md @@ -117,7 +117,7 @@ Replace the visualized graph. Accepts **either** the GLIMPSE objects format **Emit payload** — one of: -GLIMPSE objects format (see [socialExample.json](../testing/models/demo_examples/socialExample.json)): +GLIMPSE objects format (see [socialExample.json](../models/demo_examples/socialExample.json)): ```json { diff --git a/socket-testing/common.py b/socket-testing/common.py index 1fe6cc94..df1fcf25 100644 --- a/socket-testing/common.py +++ b/socket-testing/common.py @@ -13,12 +13,22 @@ GLIMPSE_SERVER_URL=http://127.0.0.1:5052 python test_load_graph.py """ +import json import os import sys import socketio DEFAULT_URL = os.environ.get("GLIMPSE_SERVER_URL", "http://127.0.0.1:5052") +SOCIAL_EXAMPLE = os.path.join( + os.path.dirname(__file__), "..", "models", "demo_examples", "socialExample.json" +) + + +def social_example(): + """The socialExample.json graph, in GLIMPSE objects format.""" + with open(SOCIAL_EXAMPLE, "r") as f: + return json.load(f) def _obj_id(data): diff --git a/socket-testing/test_add_delete.py b/socket-testing/test_add_delete.py index d7442a24..de5614a6 100644 --- a/socket-testing/test_add_delete.py +++ b/socket-testing/test_add_delete.py @@ -12,20 +12,8 @@ python socket-testing/test_add_delete.py """ -import json -import os - import common -SOCIAL_EXAMPLE = os.path.join( - os.path.dirname(__file__), - "..", - "testing", - "models", - "demo_examples", - "socialExample.json", -) - NEW_NODE_ID = "Tony-Stark" EXISTING_NODE_ID = "John-Doe" # exists in socialExample.json NEW_EDGE_ID = "TonyStark-JohnDoe" @@ -34,8 +22,7 @@ def main(): sio = common.connect() try: - with open(SOCIAL_EXAMPLE, "r") as f: - common.call(sio, "load-graph", json.load(f)) + common.call(sio, "load-graph", common.social_example()) sio.sleep(1.5) # Add a new node diff --git a/socket-testing/test_load_graph.py b/socket-testing/test_load_graph.py index 420accb6..f3763bc5 100644 --- a/socket-testing/test_load_graph.py +++ b/socket-testing/test_load_graph.py @@ -9,22 +9,10 @@ python socket-testing/test_load_graph.py """ -import json -import os - import networkx as nx import common -SOCIAL_EXAMPLE = os.path.join( - os.path.dirname(__file__), - "..", - "testing", - "models", - "demo_examples", - "socialExample.json", -) - def build_networkx_graph(): """A small typed social graph so the node/edge types show up in GLIMPSE.""" @@ -47,10 +35,8 @@ def main(): sio = common.connect() try: # 1) GLIMPSE objects format - with open(SOCIAL_EXAMPLE, "r") as f: - social_graph = json.load(f) print("\n[1/2] Loading GLIMPSE objects format (socialExample.json)") - common.call(sio, "load-graph", social_graph) + common.call(sio, "load-graph", common.social_example()) sio.sleep(2) # pause so you can see it in the UI before it's replaced diff --git a/socket-testing/test_update.py b/socket-testing/test_update.py index 1ba799cc..05aafe43 100644 --- a/socket-testing/test_update.py +++ b/socket-testing/test_update.py @@ -15,20 +15,8 @@ python socket-testing/test_update.py """ -import json -import os - import common -SOCIAL_EXAMPLE = os.path.join( - os.path.dirname(__file__), - "..", - "testing", - "models", - "demo_examples", - "socialExample.json", -) - # ids that exist in socialExample.json NODE_ID = "John-Doe" EDGE_ID = "JaneDoe-JohnDoe" @@ -37,8 +25,7 @@ def main(): sio = common.connect() try: - with open(SOCIAL_EXAMPLE, "r") as f: - common.call(sio, "load-graph", json.load(f)) + common.call(sio, "load-graph", common.social_example()) sio.sleep(1.5) # Recolor + enlarge a node (all three properties) diff --git a/src/app/App.jsx b/src/app/App.jsx index b15e75c0..b0c008bb 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import "../styles/App.css"; import { App as AntApp, ConfigProvider, Layout, theme } from "antd"; import { Content } from "antd/es/layout/layout"; @@ -58,8 +58,10 @@ const useUnsavedChangesGuard = () => { }, []); }; -const AppContent = ({ onAboutModalMount, openAboutModalRef, openLoadModelModalRef }) => { +const AppContent = () => { const { view, darkMode } = useGraph(); + const [aboutOpen, setAboutOpen] = useState(false); + const [loadModelOpen, setLoadModelOpen] = useState(true); useUnsavedChangesGuard(); @@ -134,14 +136,10 @@ const AppContent = ({ onAboutModalMount, openAboutModalRef, openLoadModelModalRe - { - openLoadModelModalRef.current = setter; - }} + onAboutClick={() => setAboutOpen(true)} + onLoadClick={() => setLoadModelOpen(true)} /> + setLoadModelOpen(false)} />
{/* Must stay inside ConfigProvider — a modal rendered outside it gets antd's default (light) algorithm regardless of darkMode. */} - + setAboutOpen(false)} /> ); }; function App() { - const openAboutModalRef = useRef(null); - const openLoadModelModalRef = useRef(null); - - const handleAboutModalMount = (setter) => { - openAboutModalRef.current = setter; - }; - return ( - + ); } diff --git a/src/app/AppHeader.jsx b/src/app/AppHeader.jsx index 2f21552e..e84194ad 100644 --- a/src/app/AppHeader.jsx +++ b/src/app/AppHeader.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; -import { Button, Flex, Dropdown, Select, Switch, Tag, Tooltip } from "antd"; +import { useState, useEffect, useMemo, useRef } from "react"; +import { Button, Flex, Dropdown, Select, Switch, Tag, Tooltip, Typography } from "antd"; import { GiHamburgerMenu } from "react-icons/gi"; import ConnectionStatus from "../components/ConnectionStatus"; import MetricsModal from "../components/modals/MetricsModal"; @@ -13,11 +13,10 @@ import { useGraph } from "../contexts/GraphContext"; import { API_BASE_URL } from "../config"; import { notify, reportError } from "../utils/notify"; import { useShortcut } from "../hooks/useShortcut"; -import Typography from "antd/es/typography/Typography"; const { Text } = Typography; -const AppHeader = ({ onAboutClick, openModelLoader }) => { +const AppHeader = ({ onAboutClick, onLoadClick }) => { const [graphLoaded, setGraphLoaded] = useState(false); const [selectedTheme, setSelectedTheme] = useState("feeder-model-theme"); const [showMetrics, setShowMetrics] = useState(false); @@ -30,7 +29,7 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { const searchRef = useRef(null); const canExport = graphLoaded && !graphHelper.isCIM; - const hasAgents = useMemo(() => graphLoaded && graphHelper.agents.agents.length > 0, [graphLoaded]); + const hasAgents = graphLoaded && graphHelper.agents.agents.length > 0; const menuItems = [ { @@ -84,7 +83,6 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { }, ]; - // Listen for graph load/clear events emitted by the Graph component // Loading a model without a roster (any file upload) while the agents view // is open would leave the user on a permanently empty tab whose menu entry // has just disappeared. @@ -110,14 +108,13 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { window.addEventListener("graph-loaded", handleGraphLoaded); window.addEventListener("graph-cleared", handleGraphCleared); window.addEventListener("graph-dirty-change", handleDirtyChange); - graphHelper.themeName = selectedTheme; return () => { window.removeEventListener("graph-loaded", handleGraphLoaded); window.removeEventListener("graph-cleared", handleGraphCleared); window.removeEventListener("graph-dirty-change", handleDirtyChange); }; - }, [selectedTheme]); + }, []); // One option per node + edge — 20k+ on the larger feeders. The graph key and // element type are kept as fields on the option (Select passes the whole @@ -147,7 +144,6 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { }, [graphLoaded, graphUpdateTrigger]); const handleExport = async () => { - // Get updated graph data from GraphHelper const exportData = graphHelper.export(); if (!exportData || Object.keys(exportData).length === 0) { @@ -171,7 +167,6 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { }, ); - // Create a download link and trigger it const blob = new Blob([response.data], { type: "application/zip" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); @@ -180,7 +175,6 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { document.body.appendChild(link); link.click(); - // Cleanup link.remove(); window.URL.revokeObjectURL(url); @@ -206,9 +200,6 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { const handleMenuClick = ({ key }) => { switch (key) { case "feeder-model-theme": - setSelectedTheme(key); - graphHelper.themeName = key; - break; case "custom-theme": setSelectedTheme(key); graphHelper.themeName = key; @@ -231,13 +222,10 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { case "dark-mode": setDarkMode(!darkMode); break; - case "export-theme": } }; - const focusSearch = useCallback(() => searchRef.current?.focus(), []); - - useShortcut("/", focusSearch, { enabled: graphLoaded }); + useShortcut("/", () => searchRef.current?.focus(), { enabled: graphLoaded }); useShortcut("d", () => setDarkMode((v) => !v)); useShortcut("?", () => setShowShortcuts(true)); // Allowed while typing so it also gets the user out of the search box. @@ -302,7 +290,7 @@ const AppHeader = ({ onAboutClick, openModelLoader }) => { diff --git a/src/components/AnimatedEdgeTicker.jsx b/src/components/AnimatedEdgeTicker.jsx index 81d31dc0..4adc2348 100644 --- a/src/components/AnimatedEdgeTicker.jsx +++ b/src/components/AnimatedEdgeTicker.jsx @@ -8,7 +8,6 @@ const AnimatedEdgeTicker = () => { useEffect(() => { const RECHECK_MS = 250; let frameId; - let running = true; let wasPulsing = false; let cachedHasAnimated = false; let checkedAt = 0; @@ -24,8 +23,6 @@ const AnimatedEdgeTicker = () => { }; const animate = (now) => { - if (!running) return; - const graph = sigma.getGraph(); const pulseId = graphHelper.getFocusedEdgeId(); const pulsing = Boolean( @@ -34,9 +31,8 @@ const AnimatedEdgeTicker = () => { if (hasAnimatedEdges(now)) { sigma.refresh({ skipIndexation: true }); - } else if (pulsing) { - sigma.refresh({ partialGraph: { edges: [pulseId] }, skipIndexation: true }); - } else if (wasPulsing && pulseId && graph.hasEdge(pulseId)) { + } else if ((pulsing || wasPulsing) && pulseId && graph.hasEdge(pulseId)) { + // One last repaint after the pulse ends clears its final frame. sigma.refresh({ partialGraph: { edges: [pulseId] }, skipIndexation: true }); } @@ -46,10 +42,7 @@ const AnimatedEdgeTicker = () => { frameId = requestAnimationFrame(animate); - return () => { - running = false; - if (frameId) cancelAnimationFrame(frameId); - }; + return () => cancelAnimationFrame(frameId); }, [sigma]); return null; diff --git a/src/components/DistributionAreaSelector.jsx b/src/components/DistributionAreaSelector.jsx index cd4e3b85..a7ea9a71 100644 --- a/src/components/DistributionAreaSelector.jsx +++ b/src/components/DistributionAreaSelector.jsx @@ -4,6 +4,7 @@ import { useGraph } from "../contexts/GraphContext"; import graphHelper from "../graph-helper/GraphHelper"; import useAreaHighlight from "../hooks/useAreaHighlight"; import { FILL_ALPHA, BORDER_ALPHA } from "./graph/AreaHighlightLayers"; +import { surfaceFor } from "./agents/agent-palette"; const buildTreeData = (areas) => Object.entries(areas).map(([type, areaList]) => ({ @@ -14,10 +15,7 @@ const buildTreeData = (areas) => })); const DistributionAreaSelector = () => { - const [treeData, setTreeData] = useState(() => { - const current = graphHelper.distributionAreas; - return Object.keys(current).length > 0 ? buildTreeData(current) : []; - }); + const [treeData, setTreeData] = useState(() => buildTreeData(graphHelper.distributionAreas)); const { darkMode } = useGraph(); const areaHighlight = useAreaHighlight(); const { selection, colors } = areaHighlight; @@ -56,9 +54,7 @@ const DistributionAreaSelector = () => { if (treeData.length === 0) return null; - const c = darkMode - ? { bg: "rgba(31,31,31,0.92)", text: "#e0e0e0", border: "#3a3a3a" } - : { bg: "rgba(255,255,255,0.92)", text: "#1f1f1f", border: "#e0e0e0" }; + const c = surfaceFor(darkMode); return ( <> @@ -82,7 +78,7 @@ const DistributionAreaSelector = () => { marginTop: 8, width: 240, padding: "8px 10px", - background: c.bg, + background: c.panelBg, color: c.text, border: `1px solid ${c.border}`, borderRadius: 6, diff --git a/src/components/ExampleModels.jsx b/src/components/ExampleModels.jsx index 0d729e4e..87954668 100644 --- a/src/components/ExampleModels.jsx +++ b/src/components/ExampleModels.jsx @@ -2,10 +2,10 @@ import { useEffect, useState } from "react"; import { Flex, Card, Button, Tag, Alert, Empty, Typography } from "antd"; import axios from "axios"; import { useGraph } from "../contexts/GraphContext"; -import graphHelper from "../graph-helper/GraphHelper"; import socketClientHelper from "../socket-client-helper/SocketClientHelper"; import { API_BASE_URL, PARSE_TIMEOUT_MS } from "../config"; import { confirmDiscardChanges, errorText, reportError } from "../utils/notify"; +import { replaceModel } from "./modals/load-model"; const ExampleModels = ({ closeModal }) => { const { newGraphUpdate } = useGraph(); @@ -45,17 +45,7 @@ const ExampleModels = ({ closeModal }) => { { headers: { "Content-Type": "application/json" }, timeout: PARSE_TIMEOUT_MS }, ); - if ("error" in response) throw new Error(response.error); - - if (graphHelper.graph.order > 0) { - graphHelper.clearGraphData(); - window.dispatchEvent(new CustomEvent("graph-cleared")); - } - - graphHelper.setIsCIM(response.isCIM); - graphHelper.setThemeObject(response.themeData ?? null); - graphHelper.setObjectDetails(response.objectDetails); - graphHelper.setGraphData(response.data ?? response); + replaceModel(response, response.isCIM); // Example models aren't driveable via GridAPPS-D, so detach from any // previous run: hides the controls/log/charts/id badge and stops a diff --git a/src/components/FileUpload.jsx b/src/components/FileUpload.jsx index cfe427bc..1b7f98e7 100644 --- a/src/components/FileUpload.jsx +++ b/src/components/FileUpload.jsx @@ -1,12 +1,12 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Upload, Progress, Alert, Button } from "antd"; import { InboxOutlined } from "@ant-design/icons"; import axios from "axios"; import { useGraph } from "../contexts/GraphContext"; -import graphHelper from "../graph-helper/GraphHelper"; import socketClientHelper from "../socket-client-helper/SocketClientHelper"; import { API_BASE_URL, PARSE_TIMEOUT_MS } from "../config"; import { confirmDiscardChanges, errorText } from "../utils/notify"; +import { replaceModel } from "./modals/load-model"; const { Dragger } = Upload; @@ -83,9 +83,6 @@ const FileUpload = ({ closeModal }) => { const { newGraphUpdate } = useGraph(); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); - // Set once the bytes are up and the server is parsing. Distinct from - // `progress`, which only tracks the transfer — a big CIM model spends - // seconds uploading and minutes parsing. const [error, setError] = useState(null); // Aborts the upload and the job poll together — on unmount, and on Cancel. const abortRef = useRef(null); @@ -137,18 +134,7 @@ const FileUpload = ({ closeModal }) => { }, }); - if ("error" in response) throw new Error(response.error); - - if (graphHelper.graph.order > 0) { - graphHelper.clearGraphData(); - window.dispatchEvent(new CustomEvent("graph-cleared")); - } - - graphHelper.isCIM = endpoint === "api/upload/cim"; - graphHelper.setThemeObject(response.themeData ?? null); - graphHelper.setObjectDetails(response.objectDetails); - const modelData = response.data ?? response; - graphHelper.setGraphData(modelData); + replaceModel(response, endpoint === "api/upload/cim"); // A file-uploaded model isn't driveable via GridAPPS-D, so detach // from any previous run: hides the controls/log/charts/id badge and diff --git a/src/components/SimulationCharts.jsx b/src/components/SimulationCharts.jsx index dd05804c..221adbb6 100644 --- a/src/components/SimulationCharts.jsx +++ b/src/components/SimulationCharts.jsx @@ -2,16 +2,16 @@ import { useEffect, useRef, useCallback } from "react"; import ReactECharts from "echarts-for-react"; import socketClientHelper from "../socket-client-helper/SocketClientHelper"; import { useGraph } from "../contexts/GraphContext"; -import { - TIMELINE_GRID_BOTTOM, - timelineDataZoom, - trimHistory, - useChartTimeline, -} from "../hooks/useChartTimeline"; +import { trimHistory, useChartTimeline } from "../hooks/useChartTimeline"; import LiveButton from "./plots/LiveButton"; +import { baseChartOption, chartColors } from "./plots/plotConstants"; import "../styles/SimulationCharts.css"; const LOAD_TYPES = new Set(["EnergyConsumer", "ConformLoad", "NonConformLoad"]); +const LOAD_KEYS = ["loadP", "loadQ", "batP", "batQ", "solP", "solQ"]; + +const emptyVoltage = () => ({ timestamps: [], min: [], avg: [], max: [] }); +const emptyLoad = () => ({ timestamps: [], ...Object.fromEntries(LOAD_KEYS.map((key) => [key, []])) }); function polarToRect(magnitude, angleDeg) { if (!isFinite(magnitude) || !isFinite(angleDeg)) return [0, 0]; @@ -19,41 +19,61 @@ function polarToRect(magnitude, angleDeg) { return [magnitude * Math.cos(rad), magnitude * Math.sin(rad)]; } +// Which load-demand series ("load" | "bat" | "sol") a VA measurement feeds, or null. +const loadCategory = (m) => { + if (LOAD_TYPES.has(m.equipment_type)) return "load"; + const name = m.equipment_name || ""; + if (name.startsWith("PowerElectronicsConnection_BatteryUnit")) return "bat"; + if (name.startsWith("PowerElectronicsConnection_PhotovoltaicUnit")) return "sol"; + return null; +}; + +const line = (name, color, dashed = false) => ({ + name, + type: "line", + smooth: true, + showSymbol: false, + lineStyle: { color, width: 1.5, ...(dashed ? { type: "dashed" } : {}) }, + itemStyle: { color }, +}); + const SimulationCharts = () => { const { darkMode } = useGraph(); - const vd = useRef({ timestamps: [], min: [], avg: [], max: [] }); - const ld = useRef({ - timestamps: [], - loadP: [], - loadQ: [], - batP: [], - batQ: [], - solP: [], - solQ: [], - }); + const vd = useRef(emptyVoltage()); + const ld = useRef(emptyLoad()); const voltageChartRef = useRef(null); const loadChartRef = useRef(null); - // Each chart owns its own scroll position, so the two timelines are - // independent — scrolling back through voltage doesn't move load demand. - const clearVoltage = useCallback(() => { - vd.current = { timestamps: [], min: [], avg: [], max: [] }; + const renderVoltage = useCallback(() => { + const v = vd.current; voltageChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [] }, - series: [{ data: [] }, { data: [] }, { data: [] }], + xAxis: { data: [...v.timestamps] }, + series: [{ data: [...v.min] }, { data: [...v.avg] }, { data: [...v.max] }], }); }, []); - const clearLoad = useCallback(() => { - ld.current = { timestamps: [], loadP: [], loadQ: [], batP: [], batQ: [], solP: [], solQ: [] }; + const renderLoad = useCallback(() => { + const l = ld.current; loadChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [] }, - series: [{ data: [] }, { data: [] }, { data: [] }, { data: [] }, { data: [] }, { data: [] }], + xAxis: { data: [...l.timestamps] }, + series: LOAD_KEYS.map((key) => ({ data: [...l[key]] })), }); }, []); + // Each chart owns its own scroll position, so the two timelines are + // independent — scrolling back through voltage doesn't move load demand. + const clearVoltage = useCallback(() => { + vd.current = emptyVoltage(); + renderVoltage(); + }, [renderVoltage]); + + const clearLoad = useCallback(() => { + ld.current = emptyLoad(); + renderLoad(); + }, [renderLoad]); + const voltageTimeline = useChartTimeline( voltageChartRef, useCallback(() => vd.current.timestamps.length, []), @@ -81,139 +101,70 @@ const SimulationCharts = () => { .filter(isFinite); if (pnvMags.length > 0) { - const minV = Math.min(...pnvMags); - const maxV = Math.max(...pnvMags); const avgV = pnvMags.reduce((a, b) => a + b, 0) / pnvMags.length; const v = vd.current; v.timestamps.push(ts); - v.min.push(parseFloat(minV.toFixed(2))); + v.min.push(parseFloat(Math.min(...pnvMags).toFixed(2))); v.avg.push(parseFloat(avgV.toFixed(2))); - v.max.push(parseFloat(maxV.toFixed(2))); + v.max.push(parseFloat(Math.max(...pnvMags).toFixed(2))); // Trimmed only at the retention cap — the run's history is kept so - // it can be scrolled back through, not discarded after 20 samples. + // it can be scrolled back through. [v.timestamps, v.min, v.avg, v.max].forEach(trimHistory); - voltageChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [...v.timestamps] }, - series: [{ data: [...v.min] }, { data: [...v.avg] }, { data: [...v.max] }], - }); + renderVoltage(); syncVoltage(); } // ── Load Demand (VA) ─────────────────────────────────────────────── - let lP = 0, - lQ = 0, - bP = 0, - bQ = 0, - sP = 0, - sQ = 0; - for (const m of Analog.filter((m) => m.measurement_type === "VA")) { + const sums = Object.fromEntries(LOAD_KEYS.map((key) => [key, 0])); + for (const m of Analog) { + if (m.measurement_type !== "VA") continue; + const category = loadCategory(m); + if (!category) continue; const [P, Q] = polarToRect(m.magnitude, m.angle); - const name = m.equipment_name || ""; - if (LOAD_TYPES.has(m.equipment_type)) { - lP += P; - lQ += Q; - } else if (name.startsWith("PowerElectronicsConnection_BatteryUnit")) { - bP += P; - bQ += Q; - } else if (name.startsWith("PowerElectronicsConnection_PhotovoltaicUnit")) { - sP += P; - sQ += Q; - } + sums[`${category}P`] += P; + sums[`${category}Q`] += Q; } const l = ld.current; - const push = (arr, val) => { - arr.push(parseFloat((val / 1000).toFixed(3))); - trimHistory(arr); - }; l.timestamps.push(ts); trimHistory(l.timestamps); - push(l.loadP, lP); - push(l.loadQ, lQ); - push(l.batP, bP); - push(l.batQ, bQ); - push(l.solP, sP); - push(l.solQ, sQ); + for (const key of LOAD_KEYS) { + l[key].push(parseFloat((sums[key] / 1000).toFixed(3))); + trimHistory(l[key]); + } - loadChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [...l.timestamps] }, - series: [ - { data: [...l.loadP] }, - { data: [...l.loadQ] }, - { data: [...l.batP] }, - { data: [...l.batQ] }, - { data: [...l.solP] }, - { data: [...l.solQ] }, - ], - }); + renderLoad(); syncLoad(); }, - [syncVoltage, syncLoad], + [renderVoltage, renderLoad, syncVoltage, syncLoad], ); useEffect(() => { return socketClientHelper.on("sim-output", processOutput); }, [processOutput]); - // ── ECharts theme helpers ────────────────────────────────────────────── - const text = darkMode ? "#cccccc" : "#333333"; - const bg = darkMode ? "#1f1f1f" : "#fafafa"; - const gridLine = darkMode ? "#2e2e2e" : "#ebebeb"; + useEffect(() => { + if (vd.current.timestamps.length === 0 && ld.current.timestamps.length === 0) return; - const accent = darkMode ? "#8ab4f8" : "#5470c6"; - // Extra bottom room for the zoom slider. - const sharedGrid = { left: 52, right: 10, top: 38, bottom: TIMELINE_GRID_BOTTOM }; - const xAxisBase = { - type: "category", - axisLabel: { color: text, fontSize: 8, rotate: 30, interval: "auto" }, - splitLine: { lineStyle: { color: gridLine } }, - axisTick: { show: false }, - }; - const yAxisBase = (name) => ({ - type: "value", - name, - nameTextStyle: { color: text, fontSize: 9 }, - axisLabel: { color: text, fontSize: 8 }, - splitLine: { lineStyle: { color: gridLine } }, - }); - const legendBase = { - top: 4, - textStyle: { color: text, fontSize: 9 }, - itemWidth: 14, - itemHeight: 7, - }; + renderVoltage(); + renderLoad(); + syncVoltage(); + syncLoad(); + }, [renderVoltage, renderLoad, syncVoltage, syncLoad]); - const line = (name, color, dashed = false) => ({ - name, - type: "line", - smooth: true, - showSymbol: false, - lineStyle: { color, width: 1.5, ...(dashed ? { type: "dashed" } : {}) }, - itemStyle: { color }, - }); + const { text, bg } = chartColors(darkMode); const voltageOption = { - backgroundColor: bg, - textStyle: { color: text }, - grid: sharedGrid, - tooltip: { trigger: "axis", confine: true, textStyle: { fontSize: 10 } }, - legend: { ...legendBase, data: ["Min", "Avg", "Max"] }, - xAxis: xAxisBase, - yAxis: yAxisBase("V"), - dataZoom: timelineDataZoom(accent), + ...baseChartOption(darkMode, { legend: ["Min", "Avg", "Max"], yAxis: { name: "V" } }), series: [line("Min", "#5470c6"), line("Avg", "#91cc75"), line("Max", "#ee6666")], }; const loadOption = { - backgroundColor: bg, - textStyle: { color: text }, - grid: sharedGrid, - tooltip: { trigger: "axis", confine: true, textStyle: { fontSize: 10 } }, - legend: { ...legendBase, data: ["Load P", "Load Q", "Bat P", "Bat Q", "Sol P", "Sol Q"] }, - xAxis: xAxisBase, - yAxis: yAxisBase("kVA"), - dataZoom: timelineDataZoom(accent), + ...baseChartOption(darkMode, { + legend: ["Load P", "Load Q", "Bat P", "Bat Q", "Sol P", "Sol Q"], + yAxis: { name: "kVA" }, + }), series: [ line("Load P", "#5470c6"), line("Load Q", "#5470c6", true), @@ -224,31 +175,6 @@ const SimulationCharts = () => { ], }; - useEffect(() => { - const v = vd.current; - const l = ld.current; - if (v.timestamps.length === 0 && l.timestamps.length === 0) return; - - voltageChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [...v.timestamps] }, - series: [{ data: [...v.min] }, { data: [...v.avg] }, { data: [...v.max] }], - }); - loadChartRef.current?.getEchartsInstance()?.setOption({ - xAxis: { data: [...l.timestamps] }, - series: [ - { data: [...l.loadP] }, - { data: [...l.loadQ] }, - { data: [...l.batP] }, - { data: [...l.batQ] }, - { data: [...l.solP] }, - { data: [...l.solQ] }, - ], - }); - - syncVoltage(); - syncLoad(); - }, [syncVoltage, syncLoad]); - return (
diff --git a/src/components/VisToolbar.jsx b/src/components/VisToolbar.jsx index 09ec852e..27d8de4a 100644 --- a/src/components/VisToolbar.jsx +++ b/src/components/VisToolbar.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import "../styles/VisToolbar.css"; import { Button, Divider, Space, Tooltip } from "antd"; import graphHelper from "../graph-helper/GraphHelper"; @@ -28,15 +28,7 @@ const VisToolbar = ({ onToggleCharts, activePanel }) => { return () => window.removeEventListener("graph-violation-mode-change", handler); }, []); - useEffect(() => { - const unsubSimState = socketClientHelper.on("sim-state-change", (simState) => { - setSimulationState(simState); - }); - - return () => { - unsubSimState(); - }; - }, []); + useEffect(() => socketClientHelper.on("sim-state-change", setSimulationState), []); const rotateCCW = () => { graphHelper.rotateCCW(); @@ -48,40 +40,32 @@ const VisToolbar = ({ onToggleCharts, activePanel }) => { graphHelper.sigmaInstance?.refresh(); }; - const unHighlightCurrent = (obj) => { - if (obj.type === "edge") { - graphHelper.graph.setEdgeAttribute(obj.id, "highlighted", false); + const clearCurrentHighlight = () => { + const current = graphHelper.getCurrentHighlightedObject(); + if (!current) return; + + if (current.type === "edge") { + graphHelper.graph.setEdgeAttribute(current.id, "highlighted", false); } else { - graphHelper.graph.setNodeAttribute(obj.id, "highlighted", false); + graphHelper.graph.setNodeAttribute(current.id, "highlighted", false); } }; const goToPrevious = () => { if (graphHelper.highlightedObjects.length === 0) return; - - if (graphHelper.getCurrentHighlightedObject()) { - unHighlightCurrent(graphHelper.getCurrentHighlightedObject()); - } - + clearCurrentHighlight(); graphHelper.focus(graphHelper.getPrevious()); }; const goToNext = () => { if (graphHelper.highlightedObjects.length === 0) return; - - if (graphHelper.getCurrentHighlightedObject()) { - unHighlightCurrent(graphHelper.getCurrentHighlightedObject()); - } - + clearCurrentHighlight(); graphHelper.focus(graphHelper.getNext()); }; const handleReset = () => { if (graphHelper.graph.order === 0) return; - - if (graphHelper.getCurrentHighlightedObject()) { - unHighlightCurrent(graphHelper.getCurrentHighlightedObject()); - } + clearCurrentHighlight(); graphHelper.reset(); graphHelper.sigmaInstance?.refresh(); @@ -161,7 +145,7 @@ const VisToolbar = ({ onToggleCharts, activePanel }) => { )} @@ -260,33 +231,28 @@ const LegendPanel = () => { onToggleHighlight={toggleHighlight} onOpenMenu={openMenu} /> +
+ Double-click a type to highlight · right-click for options +
)} - - {/* Extension slot — future legend actions (theme editor, filters, - export, etc.) can be added below this divider. */} - {!isEmpty && ( -
- Double-click a type to highlight · right-click for options -
- )}
)} - setContext({ open: false, x: 0, y: 0 })} - onHideAll={handleHideAll} - onEditTheme={() => {}} + width="8rem" + items={MENU_ITEMS} + onClick={hideAll} />
); diff --git a/src/components/legend/ViolationLegend.jsx b/src/components/legend/ViolationLegend.jsx index 9a55c437..bfba9f9a 100644 --- a/src/components/legend/ViolationLegend.jsx +++ b/src/components/legend/ViolationLegend.jsx @@ -3,6 +3,7 @@ import graphHelper from "../../graph-helper/GraphHelper"; import { useGraph } from "../../contexts/GraphContext"; import { useSimLiveTick } from "../../hooks/useSimLiveTick"; import { VIOLATION_LEGEND, isViolation } from "../../utils/electrical"; +import { panelStyle, surfaceFor } from "../agents/agent-palette"; // Colour scale for violation mode, with a live count beside each band so the // user can tell whether anything is actually wrong without scanning the canvas. @@ -34,24 +35,10 @@ const ViolationLegend = () => { const counts = graphHelper.getViolationCounts(); - const c = darkMode - ? { bg: "#1f1f1f", text: "#e0e0e0", sub: "#8c8c8c", border: "#3a3a3a" } - : { bg: "#ffffff", text: "#1f1f1f", sub: "#8c8c8c", border: "#e0e0e0" }; + const c = surfaceFor(darkMode); return ( -
+
{ + const menuRef = useRef(null); + const [position, setPosition] = useState({ x: context.x, y: context.y }); + + useEffect(() => { + if (!context.open || !menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + setPosition({ + x: Math.max(0, Math.min(context.x, window.innerWidth - rect.width)), + y: Math.max(0, Math.min(context.y, window.innerHeight - rect.height)), + }); + }, [context.open, context.x, context.y]); + + if (!context.open) return null; + + return createPortal( +
+ +
, + document.getElementById("portal"), + ); +}; + +export default ContextMenu; diff --git a/src/components/menus/GraphContextMenu.jsx b/src/components/menus/GraphContextMenu.jsx index 405b4375..ae07aa10 100644 --- a/src/components/menus/GraphContextMenu.jsx +++ b/src/components/menus/GraphContextMenu.jsx @@ -1,34 +1,27 @@ -import React, { useRef, useEffect, useState } from "react"; -import ReactDOM from "react-dom"; -import { Menu } from "antd"; import { downloadAsImage } from "@sigma/export-image"; import { useGraph } from "../../contexts/GraphContext"; import graphHelper from "../../graph-helper/GraphHelper"; -import NewObjectModal from "../modals/NewObjectModal"; - -const NODE_ITEMS = [ - { key: "edit-attributes", label: "Edit Attributes" }, - { type: "divider" }, - { key: "delete-node", label: "Delete Node" }, -]; -const EDGE_ITEMS = [ - { key: "edit-attributes", label: "Edit Attributes" }, - { key: "hide-edge", label: "Hide Edge", disabled: false }, - { key: "animate-edge", label: "Toggle Animation", disabled: false }, - { type: "divider" }, - { key: "delete-edge", label: "Delete Edge" }, -]; -const GRAPH_ITEMS = [ - { key: "new-node", label: "Add New Node", disabled: false }, - { key: "new-edge", label: "Add New Edge", disabled: false }, - { type: "divider" }, - { key: "save-image", label: "Save image as..." }, -]; +import ContextMenu from "./ContextMenu"; const ITEMS = { - nodeItems: NODE_ITEMS, - edgeItems: EDGE_ITEMS, - graphItems: GRAPH_ITEMS, + nodeItems: [ + { key: "edit-attributes", label: "Edit Attributes" }, + { type: "divider" }, + { key: "delete-node", label: "Delete Node" }, + ], + edgeItems: [ + { key: "edit-attributes", label: "Edit Attributes" }, + { key: "hide-edge", label: "Hide Edge" }, + { key: "animate-edge", label: "Toggle Animation" }, + { type: "divider" }, + { key: "delete-edge", label: "Delete Edge" }, + ], + graphItems: [ + { key: "new-node", label: "Add New Node" }, + { key: "new-edge", label: "Add New Edge" }, + { type: "divider" }, + { key: "save-image", label: "Save image as..." }, + ], }; const GraphContextMenu = ({ @@ -38,40 +31,8 @@ const GraphContextMenu = ({ openNewNodeModal, openNewEdgeModal, }) => { - const menuRef = useRef(null); - const [position, setPosition] = useState({ x: context.x, y: context.y }); const { darkMode } = useGraph(); - useEffect(() => { - if (!context.open || !menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - let x = context.x; - let y = context.y; - - if (x + rect.width > vw) x = vw - rect.width; - if (y + rect.height > vh) y = vh - rect.height; - if (x < 0) x = 0; - if (y < 0) y = 0; - - setPosition({ x, y }); - }, [context.open, context.x, context.y]); - - if (!context.open) return null; - - // Go through the helper rather than graph.dropNode/dropEdge directly: it - // also decrements objectTypeCount and rebuilds the legend, which a raw drop - // left stale (the legend kept counting objects that no longer existed). - const deleteNode = (nodeID) => { - if (graphHelper.deleteNode(nodeID)) graphHelper.markDirty(); - }; - - const deleteEdge = (edgeID) => { - if (graphHelper.deleteEdge(edgeID)) graphHelper.markDirty(); - }; - const handleImageSave = () => { // Match the canvas background of the active theme — node labels are drawn // white in dark mode, so a fixed white background exported them invisible. @@ -83,21 +44,12 @@ const GraphContextMenu = ({ }); }; - /** - * Updates the type of the edge from `"straight"` to `"animated"` - * @param {string} edgeID - */ + // Toggles an edge between the "animated" program and its resting type. const animateEdge = (edgeID) => { - const currentEdgeType = graphHelper.graph.getEdgeAttribute(edgeID, "type"); - const edgeGroup = graphHelper.graph.getEdgeAttribute(edgeID, "group"); - - if (currentEdgeType === "animated") { - if (edgeGroup === "switch") { - graphHelper.graph.setEdgeAttribute(edgeID, "type", "switch"); - return; - } + const { type, group } = graphHelper.graph.getEdgeAttributes(edgeID); - graphHelper.graph.setEdgeAttribute(edgeID, "type", "straight"); + if (type === "animated") { + graphHelper.graph.setEdgeAttribute(edgeID, "type", group === "switch" ? "switch" : "straight"); return; } @@ -111,8 +63,6 @@ const GraphContextMenu = ({ }; const handleMenuClick = ({ key }) => { - console.log(`Clicked on menu item: ${key}`); - switch (key) { case "edit-attributes": openAttributesModal(); @@ -120,11 +70,13 @@ const GraphContextMenu = ({ case "hide-edge": hideEdge(context.edge); break; + // Deletes go through the helper rather than graph.dropNode/dropEdge so + // objectTypeCount and the legend stay in sync. case "delete-node": - deleteNode(context.node); + if (graphHelper.deleteNode(context.node)) graphHelper.markDirty(); break; case "delete-edge": - deleteEdge(context.edge); + if (graphHelper.deleteEdge(context.edge)) graphHelper.markDirty(); break; case "animate-edge": animateEdge(context.edge); @@ -138,33 +90,18 @@ const GraphContextMenu = ({ case "save-image": handleImageSave(); break; - default: } close(); }; - return ReactDOM.createPortal( -
- -
, - document.getElementById("portal"), + return ( + ); }; diff --git a/src/components/menus/LegendContextMenu.jsx b/src/components/menus/LegendContextMenu.jsx deleted file mode 100644 index c2faf15c..00000000 --- a/src/components/menus/LegendContextMenu.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useRef, useEffect, useState } from "react"; -import ReactDOM from "react-dom"; -import { Menu } from "antd"; - -const ITEMS = [{ key: "hide-all", label: "Hide All", disabled: false }]; - -const LegendContextMenu = ({ context, close, onHideAll: hideAll, onEditTheme: showThemeEditor }) => { - const menuRef = useRef(null); - const [position, setPosition] = useState({ x: context.x, y: context.y }); - - useEffect(() => { - if (!context.open || !menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - let x = context.x; - let y = context.y; - - if (x + rect.width > vw) x = vw - rect.width; - if (y + rect.height > vh) y = vh - rect.height; - if (x < 0) x = 0; - if (y < 0) y = 0; - - setPosition({ x, y }); - }, [context.open, context.x, context.y]); - - if (!context.open) return null; - - // type is edge or node - // group is the object type for that type - const handleMenuClick = ({ key }) => { - console.log(`Clicked on menu item: ${key}`); - switch (key) { - case "hide-all": - hideAll(context.type, context.group); - break; - case "edit-theme": - showThemeEditor(context.type, context.group); - break; - } - - close(); - }; - - return ReactDOM.createPortal( -
- -
, - document.getElementById("portal"), - ); -}; - -export default LegendContextMenu; diff --git a/src/components/modals/AboutModal.jsx b/src/components/modals/AboutModal.jsx index 30c5c7e3..c27f265a 100644 --- a/src/components/modals/AboutModal.jsx +++ b/src/components/modals/AboutModal.jsx @@ -1,20 +1,8 @@ -import { useEffect, useState } from "react"; import { Modal } from "antd"; import ReactDom from "react-dom"; import "../../styles/About.css"; -const AboutModal = ({ onMount }) => { - const [open, setOpen] = useState(false); - - // Sending state setter to parent on mount - useEffect(() => { - if (onMount) { - onMount(setOpen); - } - }, [onMount]); - - const close = () => setOpen(false); - +const AboutModal = ({ open, close }) => { return ReactDom.createPortal( value !== null && typeof value === "object"; + const EditAttributesModal = ({ close, context }) => { const [form] = Form.useForm(); const { token } = theme.useToken(); @@ -41,36 +44,19 @@ const EditAttributesModal = ({ close, context }) => { const [hasChanges, setHasChanges] = useState(false); const { open, object } = context; - // Arrays/objects (e.g. dist_areas) can't render in a plain Input; show them - // as pretty JSON in a read-only textarea instead. - const isComplexValue = useCallback( - (value) => Array.isArray(value) || (value !== null && typeof value === "object"), - [], - ); - const formatValue = useCallback( - (value) => (isComplexValue(value) ? JSON.stringify(value, null, 2) : value), - [isComplexValue], - ); - // Snapshot the object's attributes when the modal opens. Derived during // render (not in an effect) so the form renders in a single pass. const { attributes, loadError } = useMemo(() => { if (!open || !object) return { attributes: {}, loadError: null }; try { + let attributes = {}; if (object.type === "node") { - return { - attributes: graphHelper.graph.getNodeAttribute(object.id, "attributes") || {}, - loadError: null, - }; - } - if (object.type === "edge") { - return { - attributes: graphHelper.graph.getEdgeAttribute(object.id, "attributes") || {}, - loadError: null, - }; + attributes = graphHelper.graph.getNodeAttribute(object.id, "attributes") || {}; + } else if (object.type === "edge") { + attributes = graphHelper.graph.getEdgeAttribute(object.id, "attributes") || {}; } - return { attributes: {}, loadError: null }; + return { attributes, loadError: null }; } catch (error) { return { attributes: {}, loadError: error }; } @@ -88,11 +74,6 @@ const EditAttributesModal = ({ close, context }) => { form.setFieldsValue(attributes); }, [open, object, attributes, loadError, form]); - // Track form changes to enable/disable save button - const handleFormChange = useCallback(() => { - setHasChanges(true); - }, []); - const handleSave = async () => { if (!hasChanges) { notify.info("No changes to save"); @@ -109,7 +90,6 @@ const EditAttributesModal = ({ close, context }) => { // dist_areas) are preserved unchanged. const merged = { ...attributes, ...values }; - // Update the graph with new attribute values if (object.type === "node") { graphHelper.graph.setNodeAttribute(object.id, "attributes", merged); // Rebuild the hover card so it reflects the edited attributes @@ -186,7 +166,7 @@ const EditAttributesModal = ({ close, context }) => { setHasChanges(true)} autoComplete="off" > {attributeEntries.map(([attributeName, value], i) => { @@ -211,19 +191,11 @@ const EditAttributesModal = ({ close, context }) => { ); - // Read-only fields — and any complex value (arrays/objects - // such as per-phase regulator taps like AN/BN/CN, or - // dist_areas) — are not registered with the form. A - // name-bound Form.Item makes AntD inject the raw store value - // into the input, which for an object renders as - // "[object Object]"; leaving off `name` lets our explicit - // pretty-JSON `value` show instead. Both are preserved - // unchanged via the merge on save. + // Read-only fields are not registered with the form (a + // name-bound Form.Item would render objects as + // "[object Object]"); the merge on save preserves them. + // Shown as full-contrast text in a token-styled box. if (isReadOnly) { - // Render read-only values as full-contrast text in a - // bordered box (not a greyed-out disabled input). Uses - // AntD theme tokens so it tracks the active light/dark - // theme and lines up with the input metrics. const boxStyle = { minHeight: token.controlHeight, border: `1px solid ${token.colorBorder}`, @@ -237,11 +209,7 @@ const EditAttributesModal = ({ close, context }) => { {isComplexValue(value) ? (
 {
                                                     fontSize: token.fontSizeSM,
                                                 }}
                                             >
-                                                {formatValue(value)}
+                                                {JSON.stringify(value, null, 2)}
                                             
) : (
{ - const [open, setOpen] = useState(true); +const LoadModelModal = ({ open, close }) => { const [loading, setLoading] = useState(false); const [loadProgress, setLoadProgress] = useState(null); const [selectedGridappsdModels, setSelectedGridappsdModels] = useState(null); @@ -50,40 +50,31 @@ const LoadModelModal = ({ onMount }) => { const handleModelSelect = (selectedModels) => { const models = selectedModels.map((m) => JSON.parse(m)); - console.log(models); graphHelper.selectedGridappsdModels = models; setSelectedGridappsdModels(models); }; - const ITEMS = [ + const items = [ { label: "File Upload", key: "file-upload", - children: setOpen(false)} />, + children: , }, { label: "Example Models", key: "example-models", - children: setOpen(false)} />, + children: , }, ]; if (gridappsdAvailable) { - ITEMS.push({ + items.push({ label: "Load w/ GridAPPS-D", key: "load-gridappsd", children: , }); } - useEffect(() => { - if (onMount) { - onMount(setOpen); - } - }, [onMount]); - - const close = () => setOpen(false); - const handleLoad = async () => { setError(null); @@ -93,7 +84,7 @@ const LoadModelModal = ({ onMount }) => { setLoading(true); try { - const resPromise = axios.post( + const { data: response } = await axios.post( `${API_BASE_URL}/api/gridappsd/models`, selectedGridappsdModels.map((m) => m.modelId), { @@ -101,26 +92,12 @@ const LoadModelModal = ({ onMount }) => { timeout: PARSE_TIMEOUT_MS, }, ); - const { data: response } = await resPromise; - - if ("error" in response) throw new Error(response.error); - - // Set graph data which triggers clear and render - if (graphHelper.graph.order > 0) { - graphHelper.clearGraphData(); - window.dispatchEvent(new CustomEvent("graph-cleared")); - } - // Close modal after data is set - console.log(response); - graphHelper.setIsCIM(true); + replaceModel(response, true); // Fallback feeder for objects that carry no feeder_id of their own. // With several feeders selected the first one wins; per-object // feeder_id still takes precedence (see resolveFeederIdFromGraph). graphHelper.currentFeederID = selectedGridappsdModels[0]?.modelId ?? null; - graphHelper.setThemeObject(response.themeData ?? null); - graphHelper.setObjectDetails(response.objectDetails); - graphHelper.setGraphData(response.data ?? response); // Before graph-loaded, so the agent panel and views are populated by // the time they resync on that event. await loadAgentRoster(graphHelper.currentFeederID); @@ -132,7 +109,7 @@ const LoadModelModal = ({ onMount }) => { // state (VisToolbar / GraphLayout). socketClientHelper.detachSimulation(); socketClientHelper.setSimulationState("idle"); - setOpen(false); + close(); } catch (e) { // Inline (not a toast): a CIM pull can take minutes, and the user is // still looking at this modal when it fails. @@ -174,7 +151,7 @@ const LoadModelModal = ({ onMount }) => { style={{ marginBottom: "1rem" }} /> )} - + , document.getElementById("portal"), ); diff --git a/src/components/modals/MetricsModal.jsx b/src/components/modals/MetricsModal.jsx index 474753de..63b5c056 100644 --- a/src/components/modals/MetricsModal.jsx +++ b/src/components/modals/MetricsModal.jsx @@ -1,32 +1,18 @@ -import React, { useMemo } from "react"; +import { useMemo } from "react"; import ReactDom from "react-dom"; import { Modal, Table } from "antd"; import { density } from "graphology-metrics/graph"; import graphHelper from "../../graph-helper/GraphHelper"; -import { UndirectedGraph } from "graphology"; -const MetricsModal = ({ open, close }) => { - const columns = [ - { - title: "Metric", - dataIndex: "metric", - key: "metric", - }, - { - title: "Value", - dataIndex: "value", - key: "value", - }, - { - title: "Description", - dataIndex: "description", - key: "description", - }, - ]; +const COLUMNS = [ + { title: "Metric", dataIndex: "metric", key: "metric" }, + { title: "Value", dataIndex: "value", key: "value" }, + { title: "Description", dataIndex: "description", key: "description" }, +]; +const MetricsModal = ({ open, close }) => { // Recomputed each time the modal opens (the graph is a module singleton, - // so `open` is the signal that fresh metrics are needed). Previously this - // useMemo had no dependency array at all, which recomputed every render. + // so `open` is the signal that fresh metrics are needed). const metricsData = useMemo(() => { if (!open || graphHelper.graph.order === 0) return []; @@ -103,7 +89,7 @@ const MetricsModal = ({ open, close }) => { return ReactDom.createPortal( - +
, document.getElementById("portal"), ); diff --git a/src/components/modals/NewEdgeModal.jsx b/src/components/modals/NewEdgeModal.jsx deleted file mode 100644 index 3689f7d1..00000000 --- a/src/components/modals/NewEdgeModal.jsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useMemo, useState } from "react"; -import ReactDOM from "react-dom"; -import { Modal, Form, Select, Input, Button, Alert, Space, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import graphHelper from "../../graph-helper/GraphHelper"; - -const NewEdgeModal = ({ open, close }) => { - const [formFields, setFormFields] = useState({ - edgeID: "", - edgeType: "", - fromNode: "", - toNode: "", - }); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - const handleSubmit = async () => { - try { - setError(""); - setLoading(true); - // Validate required fields - await form.validateFields(); - graphHelper.newEdge(formFields); - form.resetFields(); - close(); - } catch (err) { - setError(err.message || "Failed to create edge. Please check your inputs."); - } finally { - setLoading(false); - } - }; - - const handleValuesChange = (changedValue) => { - setFormFields((prev) => ({ ...prev, ...changedValue })); - }; - - const searchFilterFunc = (input, option) => { - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()); - }; - - // The graph is a module singleton (mutations don't re-render this modal), - // so `open` is the recompute signal: options are rebuilt each time the - // modal opens and stay stable while it's up. - const nodeIDs = useMemo(() => { - // return nothing if there are no nodes in the graph - if (!open || graphHelper.graph.order === 0) return []; - - return graphHelper.graph.mapNodes((node, attrs) => ({ - label: attrs.attributes.name ?? node, - value: node, - })); - }, [open]); - - const edgeTypes = useMemo(() => { - if (!open || graphHelper.graph.order === 0) return []; - return graphHelper.edgeTypes.map((type) => ({ - label: type, - value: type, - })); - }, [open]); - - const footer = [ - , - , - ]; - - const hasNoGraph = graphHelper.graph.order === 0; - const hasNoNodes = graphHelper.graph.order < 2; - - return ReactDOM.createPortal( - { - form.resetFields(); - setError(""); - close(); - }} - footer={footer} - title="Create New Edge" - width={520} - > - {hasNoGraph && ( - - )} - {!hasNoGraph && hasNoNodes && ( - - )} - {error && ( - setError("") }} - style={{ marginBottom: 20 }} - /> - )} - - - Edge Definition - - - - Edge ID{" "} - - - - - } - name="edgeID" - rules={[ - { required: true, message: "Please enter an edge ID" }, - { - pattern: /^[a-zA-Z0-9_-]+$/, - message: "ID can only contain letters, numbers, hyphens, and underscores", - }, - ]} - > - - - - - Edge Type{" "} - - - - - } - name="edgeType" - rules={[{ required: true, message: "Please select an edge type" }]} - > - - - - - To Node{" "} - - - - - } - name="toNode" - rules={[{ required: true, message: "Please select a destination node" }]} - > - - - - - Node ID{" "} - - - - - } - name="nodeID" - rules={[ - { required: true, message: "Please enter a node ID" }, - { - pattern: /^[a-zA-Z0-9_-]+$/, - message: "ID can only contain letters, numbers, hyphens, and underscores", - }, - ]} - > - - - - - Connection - - - - Connect To{" "} - - - - - } - name="connectTo" - rules={[{ required: true, message: "Please select a node to connect to" }]} - > - - + + {children} , document.getElementById("portal"), ); }; -export default NewObjectModal; +export const NewNodeModal = ({ open, close }) => { + const { nodeTypes, nodeIDs, edgeTypes } = useGraphOptions(open); + const hasNoGraph = graphHelper.graph.order === 0; + + return ( + + ) + } + onCreate={(values) => graphHelper.newNodeWithEdge(values)} + > + Node Definition + + } + name="nodeType" + rules={[{ required: true, message: "Please select a node type" }]} + > + + + + Connection + + + } + name="connectTo" + rules={[{ required: true, message: "Please select a node to connect to" }]} + > + + + + ); +}; + +export const NewEdgeModal = ({ open, close }) => { + const { nodeIDs, edgeTypes } = useGraphOptions(open); + const hasNoGraph = graphHelper.graph.order === 0; + const hasNoNodes = graphHelper.graph.order < 2; + + let notice = null; + if (hasNoGraph) { + notice = ( + + ); + } else if (hasNoNodes) { + notice = ( + + ); + } + + return ( + graphHelper.newEdge(values)} + > + Edge Definition + + } + name="edgeID" + rules={[{ required: true, message: "Please enter an edge ID" }, ID_RULE]} + > + + + + } + name="edgeType" + rules={[{ required: true, message: "Please select an edge type" }]} + > + + + + } + name="toNode" + rules={[{ required: true, message: "Please select a destination node" }]} + > + trigger.parentElement} /> diff --git a/src/components/modals/UpdateRegulatorModal.jsx b/src/components/modals/UpdateRegulatorModal.jsx index 34a66f31..45f63b2e 100644 --- a/src/components/modals/UpdateRegulatorModal.jsx +++ b/src/components/modals/UpdateRegulatorModal.jsx @@ -1,10 +1,10 @@ -import React, { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo } from "react"; import ReactDOM from "react-dom"; import { Modal, Form, Select, InputNumber, Button, Divider, Space, Tag, Spin, theme } from "antd"; import graphHelper from "../../graph-helper/GraphHelper"; import socketClientHelper from "../../socket-client-helper/SocketClientHelper"; -import { v4 as uuidv4 } from "uuid"; import { notify } from "../../utils/notify"; +import { emitDifferences } from "./device-control"; // Control modes mirror the legacy gridappsd-viz RegulatorControlMenu. const CONTROL_MODE = { @@ -12,6 +12,11 @@ const CONTROL_MODE = { LINE_DROP_COMPENSATION: "LINE_DROP_COMPENSATION", }; +const CONTROL_MODE_OPTIONS = [ + { label: "Manual", value: CONTROL_MODE.MANUAL }, + { label: "Line drop compensation", value: CONTROL_MODE.LINE_DROP_COMPENSATION }, +]; + // A regulator edge carries per-phase tap-changer info under a phase key // (e.g. "AN"/"BN"/"CN"), each of the shape { step, tap } where `tap` is the // RatioTapChanger mRID for that phase and `step` is its current tap position. @@ -36,7 +41,7 @@ const UpdateRegulatorModal = ({ open, close, object }) => { const [form] = Form.useForm(); const { token } = theme.useToken(); const [loading, setLoading] = useState(false); - const [simulationState, setSimulationState] = useState("inactive"); // inactive | idle | running | paused | stopped + const [simulationState, setSimulationState] = useState(() => socketClientHelper.simulationState); // Mirrors the "controlMode" form field (set below on open, changed by the // Select) so the phase inputs can switch between the two layouts. @@ -81,14 +86,7 @@ const UpdateRegulatorModal = ({ open, close, object }) => { form.setFieldsValue(initial); }, [open, phaseValues, loadError, form]); - useEffect(() => { - const unsubSimState = socketClientHelper.on("sim-state-change", (simState) => { - setSimulationState(simState); - }); - return () => { - unsubSimState(); - }; - }); + useEffect(() => socketClientHelper.on("sim-state-change", setSimulationState), []); const handleSave = async () => { try { @@ -97,7 +95,6 @@ const UpdateRegulatorModal = ({ open, close, object }) => { if (socketClientHelper.simulationState !== "running") { notify.error("Simulation is not running. Cannot update tap positions."); - setLoading(false); return; } @@ -154,25 +151,10 @@ const UpdateRegulatorModal = ({ open, close, object }) => { if (forwardDifferences.length === 0) { notify.info("No changes to apply."); - setLoading(false); return; } - const inputMessage = { - command: "update", - input: { - simulation_id: socketClientHelper.simulationID, - message: { - timestamp: Math.floor(Date.now() / 1000), - difference_mrid: uuidv4(), - reverse_differences: reverseDifferences, - forward_differences: forwardDifferences, - }, - }, - }; - - console.log(inputMessage); - socketClientHelper.socket.emit("sim-input", inputMessage); + emitDifferences(reverseDifferences, forwardDifferences); // Optimistically reflect new tap steps in the local graph so reopening // the modal shows the requested state before the sim echoes it back. @@ -208,11 +190,6 @@ const UpdateRegulatorModal = ({ open, close, object }) => { const deviceName = attributes.attributes?.name || attributes.attributes?.mRID || object; const hasPhases = phases.length > 0; - const controlModeOptions = [ - { label: "Manual", value: CONTROL_MODE.MANUAL }, - { label: "Line drop compensation", value: CONTROL_MODE.LINE_DROP_COMPENSATION }, - ]; - return ReactDOM.createPortal( { <>