diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md new file mode 100644 index 0000000..3f6fd8a --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md @@ -0,0 +1,303 @@ +# EPS / Machine Status Page — Design, Findings, Integration Plan + +cSAXS (X12SA). A standalone daemon that reads EPICS PVs over Channel Access, +writes `eps_status.json` + two HTML pages, serves them locally, and optionally +uploads them. Built to run on its own now, and to fold into the flOMNI +webpage generator later. + +--- + +## 1. What the page is + +Two browser pages backed by one JSON file: + +**`eps_status.html`** — the overview. Ordered top to bottom: +1. **Machine status** — ring current, lifetime, light delivery, undulator gap, + beamline status, orbit feedback, injection rate, ring vacuum. +2. **Operator messages** — the SLS control-room message board. +3. **Alarm status** — the EPS alarm banner (green when `AlarmCnt_EPS == 0`, + red with the alarm text otherwise), plus front-end alarms. +4. **Mono cryocooler** — run / alarm / inhibit / interlock / heartbeat. +5. **Beamline synoptic** — a link to the full synoptic page. +6. **Zone detail** — FE / OP / ES / EH1 cards, each a severity-colored summary + with an expandable table of every channel. +7. **Recent history** — the last 5 EPS + machine events. + +**`eps_synoptic.html`** — a faithful replica of the caQtDM EPS expert panel +(`X_EPS_X12SA-Graphic_new.ui`), transpiled to SVG at true geometry. Pipes, +valves, gauges, ion pumps, chambers, and live value readouts, colored by EPICS +severity, with valves showing their real open/closed state. Pan (drag), zoom +(wheel / pinch), fit. Works as a desktop wall display or a phone pan-around. + +Both pages auto-refresh every 10 s and degrade gracefully: unreachable PVs +render grey, a stale bulk sweep is flagged, and a demo banner shows when values +are synthetic. + +--- + +## 2. How it works + +### Data flow + +``` + EPICS IOCs + │ Channel Access + ▼ + ┌──────────────────┐ tier 1 (hot): CA monitors, ~15 s, ~22 PVs + │ CaReader │ tier 2 (bulk): periodic caget, ~5 min, ~366 PVs + └────────┬─────────┘ + │ values + severity + connection state + ▼ + ┌──────────────────┐ builds one payload dict: + │ EpsStatusGenerator│ hot, bulk{zones,synoptic,synoptic_values,cryo, + │ _build_payload │ machine,messages}, history, generator + └────────┬─────────┘ + │ eps_status.json (+ history file) + ├─────────────► LocalHttpServer (port 8082) + └─────────────► HttpUploader (optional, → omny.online) + │ + ▼ + eps_status.html / eps_synoptic.html + fetch JSON every 10 s, render +``` + +### Two tiers + +| Tier | Cadence | Mechanism | What | +|---|---|---|---| +| hot | 15 s | CA **monitors** (push) | shutters, EPS alarms, ring current, light delivery, undulator gap | +| bulk | 5 min | periodic `caget` | ~366 gauges, pumps, temps, valves, cooling, cryocooler, machine | + +Hot PVs are monitored so a shutter close appears within seconds. Bulk uses +plain `caget` because holding hundreds of subscriptions for data nobody is +watching isn't worth the IOC load. Both write into one JSON with separate +timestamps, so the page can grey out detail readings independently when the +bulk sweep goes stale. + +### The synoptic transpiler (`eps_synoptic_svg.py`) + +Runs once at startup. Parses the caQtDM `.ui` and emits `eps_synoptic.svg` +with three layers: +- **static** — fixed structure (pipes, chamber outlines, section labels) +- **dynamic** — shapes recolored / shown-hidden live, tagged `data-pv` and + `data-vis*` +- **text** — live value fields, tagged `data-pv` + +It expands each `caInclude` by loading the sub-template (`EPS_Valve.ui`, +`EPS_Gauge.ui`, …), offsetting its shapes to the include's position and +substituting the macro — so real device glyphs are drawn, not just the pipe +skeleton. The frontend then, every refresh: evaluates visibility rules, +recolors alarm shapes from `.SEVR`, and fills value text. + +### History + +`eps_alarm_history.json`, persisted across restarts, three streams: +- **eps_events** — opened when `AlarmCnt_EPS` goes 0 → non-zero, closed when it + returns; rapid re-alarms with the same text are coalesced. +- **machine_events** — verbatim transitions of key PVs (light delivery, BL + status, EPS permit, shutters, FOFB). No inference — "PV said X, now Y, at T". +- **ring_current_trace** — one sample per bulk cycle, ~24 h, for the sparkline. + +50 events/stream persisted; 5 shown on the page. + +--- + +## 3. Key findings (the non-obvious things) + +These are the things that cost time to discover and are easy to get wrong +again. Worth reading before touching the code. + +**Three PV namespaces, not one.** The EPS lives in three disjoint namespaces: +per-device PLC channels (`X12SA-OP-DMM-VPIG-3010:PRESS-DISP`), subsystem +rollups (`X12SA-EPS-VS:SYSTEM_COM`), and the beamline-native names +(`X12SA-OP-MO:TC1`). An early assumption that the flat panel `X_EPS_X12SA.ui` +was a pre-resolved copy of the synoptic was wrong — they share **zero** PV +names. The manifest is scoped to what the EPS expert panel actually draws. + +**caQtDM panels are template-driven.** The synoptic is ~120 `caInclude` +instantiations of `EPS_*.ui` sub-templates, each stamped with a macro carrying +the device name. The PVs only exist once you expand include × template × +suffix. The suffix map is in the manifest header. + +**Colors come from EPICS severity, not thresholds.** `colorMode: Alarm` in +caQtDM means the widget colors from the PV's `.SEVR` +(NO_ALARM/MINOR/MAJOR/INVALID → green/amber/red/grey). No per-device thresholds +are reimplemented anywhere; the browser reads severity and maps it. Units come +from `.EGU`, read once and cached. + +**Visibility rules are how valves work.** A valve is a green OPEN bowtie + a red +CLOSED bowtie + a stem, each shown/hidden by a `visibility` rule bound to +`PLC_OPEN`/`PLC_CLOSE`. You must carry those rules through and evaluate them +live; drawing all states at once is meaningless. The full `visibilityCalc` +grammar (`(A=0)||(B=0)||(C=0)`, `!(A=1&&B=1)`, lowercase `a=0`, channels A–D) +is evaluated in the frontend. + +**caGraphics Lines are axis-aligned through the box centre.** A caQtDM Line is +horizontal (or vertical) through the middle of its bounding box, not +corner-to-corner. Drawing it corner-to-corner gives every pipe a slope equal to +the box height — the cause of the skewed-pipe bug. + +**Every PV the synoptic references must be in the payload.** Valve +`PLC_OPEN`/`PLC_CLOSE` are visibility channels, not in `PANEL_ITEMS` (which +carries only `PLC_STATUS`). They were being polled but dropped from the JSON, so +valves showed the transition stem. `bulk.synoptic_values` now exposes every +synoptic PV's value/severity/connection. + +**pyepics teardown.** Do not call `PV.disconnect()` on shutdown — pyepics' +`atexit` handler finalizes libca, and a manual disconnect races its background +thread into freed memory (native segfault after "generator stopped"). Clear +callbacks, drop references, let atexit finalize. + +**Channel Access is asynchronous — read in parallel.** Creating all PV objects +issues all searches at once; a dead IOC doesn't answer slowly, it doesn't answer +at all. Bulk read = create all, poll once in a shared window, then `get()` the +connected ones. Serial create-then-get costs `N × timeout` (tens of minutes for +unreachable IOCs) and stalls the write loop. + +**SSRF hardening.** `allow_redirects=False` on every upload POST, so a +compromised public server can't redirect the internal process to an arbitrary +internal address. (This is in `web_common.py`; the base flOMNI generator's copy +of `HttpUploader` predates the fix — see integration note below.) + +**Live `--verify` result (beamline in shutdown):** 291/302 reachable. 8 were a +real bug — chamber `$(SP)` relay PVs mis-stored as the chamber's `DEV` label, +now fixed. 3 were correct names on an offline IOC (FE photon shutter, down +during shutdown). The one macro-expansion guess (`X12SA-UIND:GAP-RBV`) was +confirmed correct. + +--- + +## 4. Files + +| File | Role | +|---|---| +| `eps_status_generator.py` | the daemon: CA reader, tiers, history, payload, CLI, `--verify` | +| `eps_pv_manifest.py` | 248 panel PVs + hot/machine/cryo sets, grouped by zone/role with geometry | +| `eps_synoptic_svg.py` | caQtDM `.ui` → SVG transpiler (build-time) | +| `eps_synoptic_pvs.py` | auto-generated list of PVs the SVG references | +| `eps_synoptic.svg` | prebuilt synoptic template (regenerate if the panel changes) | +| `eps_status.html` | overview page | +| `eps_synoptic.html` | synoptic page (loads the SVG, applies live data) | +| `web_common.py` | `HttpUploader` + `LocalHttpServer`, shared with flOMNI | + +Regenerate the SVG when the panel changes: +``` +python eps_synoptic_svg.py /sls/plc/config/qt/X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg +``` +(needs the `EPS_*.ui` + `VTP.ui` templates on the caQtDM path or alongside.) + +--- + +## 5. Running standalone + +```bash +python eps_status_generator.py --verify # check every PV, exit +python eps_status_generator.py --demo # synthetic values, off-network +python eps_status_generator.py # live, local only, port 8082 +python eps_status_generator.py --upload-url https://omny.online/upload.php +``` + +Port is 8082 (8081 is `_MISMATCH_LOCAL_PORT` in the flOMNI generator). + +--- + +## 6. Integration with the flOMNI webpage generator + +The daemon was built decoupled specifically so this step is low-risk. Nothing +here changes `_cycle()` in the base generator. + +### Principle + +The EPS generator owns its own threads (hot monitors, bulk sweep, write loop) +and its own output files (`eps_status.json`, `eps_status.html`, +`eps_synoptic.*`). Those filenames don't collide with the experiment page's +`status.html` / `status.json`. So integration is mostly *lifecycle wiring*, not +code surgery. + +### Step 1 — share `web_common.py` + +Move `web_common.py` next to `flomni_webpage_generator.py` and have the base +generator import `HttpUploader` / `LocalHttpServer` from it instead of defining +its own. Behaviour is identical **plus** the `allow_redirects=False` SSRF fix, +which the base copy lacks. This is the only change to existing code, and it's a +strict improvement. Verify the base generator's existing `handle_error` +override still lives on the server subclass (it does in `web_common`). + +### Step 2 — construct and start the EPS generator from the base generator + +In `WebpageGeneratorBase.start()`: +```python +from eps_status_generator import EpsStatusGenerator +self._eps = EpsStatusGenerator( + output_dir=self._output_dir, # same dir as the experiment page + serve=False, # let the existing server serve both + upload_url=self._upload_url, # reuse the same endpoint +) +self._eps.start() +``` +and in `stop()`: +```python +if getattr(self, "_eps", None): + self._eps.stop() +``` +With `serve=False`, the EPS generator writes its files into the shared output +directory and the base generator's existing `LocalHttpServer` serves them +alongside `status.html`. One port, both pages, cross-linked in their headers. + +### Step 3 — decide on cadence ownership + +Two options: +- **(simplest) leave the EPS generator's own threads running.** It self-manages + hot (15 s) and bulk (5 min). No coupling to the experiment cycle. Recommended. +- **(tighter) drive it from the base cycle.** Expose an `EpsStatusGenerator.tick()` + that does one hot snapshot + conditional bulk sweep + write, and call it from + the base `_cycle()`. Only worth it if you want a single heartbeat. Costs the + decoupling that makes a CA hiccup unable to stall the experiment page. + +Recommendation: keep option 1. The decoupling is the whole point — a hung CA +read must never delay the experiment status page, which is the one people rely +on. + +### Step 4 — capability flag + +Fits the existing plugin/flag pattern (`HAS_TOMO_QUEUE`, `TOMO_TYPES`). Add +`HAS_EPS_STATUS` and only construct the EPS generator when the BEC session is on +the X12SA network and the flag is set — so the commissioning subnet and +off-network dev sessions skip it cleanly (they'd only produce a page of grey +tiles anyway). + +### Step 5 — EPICS environment + +The EPS generator needs `import epics` (pyepics) and CA network access to the +EPS PLC IOCs and the `AGE*`/`ARS*` accelerator IOCs. Both are reachable from the +BEC host on the beamline. Off the X12SA network most IOCs are unreachable — in +that case simply don't start it (the capability flag handles this). + +### What stays separate + +The experiment-status producer logic (`flomni.py` items like +`estimated_finish_time`, `tomo_start_scan_number`) is untouched. The EPS page is +additive: a second JSON and two more HTML files in the same output directory, +uploaded through the same endpoint, served by the same server. + +### Deployment note + +The web-server side (`omny.online`, Apache) needs no change — the upload +endpoint already accepts `.html`/`.json`/`.svg` (all in +`HttpUploader._UPLOAD_SUFFIXES`). The `.svg` is uploaded once and only +re-uploaded when the panel is regenerated. Auth gate, staleness handling, and +IP-whitelisted upload all apply unchanged. + +--- + +## 7. Open items + +- **`--verify` on a running beamline** (this was done during shutdown). Re-run + when the machine is up to confirm the FE photon-shutter PVs + (`X12SA-EPS-PH:SYSTEM`, `X12SA-FE-PH1:OPEN_EPS/CLOSE_EPS`) come back. +- **Cryocooler detail.** The card shows run/alarm/inhibit/heartbeat. Full + temperature/pressure detail needs `XDS_CRYOCOOLER_OVERVIEW.ui` (a sub-template + not yet supplied). +- **LamNI / Omny subclasses.** The experiment page has a subclass factory; the + EPS page is beamline-wide and setup-independent, so it likely needs no + subclassing — but confirm when integrating. diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/README.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/README.md new file mode 100644 index 0000000..abd5d76 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/README.md @@ -0,0 +1,398 @@ +# EPS / SLS Machine Status Page — iteration 1 + +Standalone status page for the cSAXS (X12SA) Equipment Protection System and +SLS machine status. Reads EPICS PVs over Channel Access, writes +`eps_status.json` + `eps_status.html`, serves them locally, and optionally +uploads them to `omny.online`. + +No BEC dependency. Runs inside a BEC session or as a plain script. + +## Iteration 8: --verify results (live, against the beamline) + +First live verification run: 291/302 PVs reachable. The 11 unreachable break +down as: + +REAL BUG (fixed) - 8 chamber label leaks: + Pumpst.3, Pumpst.4, DMM, CCM, Exp-Box1, Exp-Box2, Exp-Box3, KB + These were the chamber DEV *labels* stored as PVs. The EPS_Chamber + templates bind $(SP) (a relay PV like VMMG-0010:PLC_RELAY-E) as the + channel, with DEV being just the display name - the extractor had stored + DEV instead of SP. Fixed: chambers now contribute their SP relay PV (which + in most cases already existed in the manifest, so PANEL_ITEMS went 256->248 + and the bogus entries are gone). No non-PV entries remain. + +NOT A BUG - 3 offline-IOC PVs (beamline was in shutdown): + X12SA-EPS-PH:SYSTEM, X12SA-FE-PH1:OPEN_EPS, X12SA-FE-PH1:CLOSE_EPS + These names are correct (they appear in the panels). They failed because + the FE photon-shutter IOC was powered down. Corroborated by the rest of the + run: CURRENT=0, LIFETIME=nan, LIGHT-DELIVERY='No Light', BL-STATUS='offline', + DUO-SHIFT-TYPE='Shutdown', FE pumps 'HV OFF'. They will return with the + machine; the page correctly shows them grey meanwhile. + +Also confirmed from the run: UNDULATOR_GAP_PV (X12SA-UIND:GAP-RBV) resolves +(20.0 mm) - the one macro-expansion guess from iteration 1 was correct. +AlarmList_EPS returns readable text, so the STRING_PVS/as_string handling is +right. Several temperatures read ~3276 (RTD open-circuit sentinel) - real +sensor state, shown in alarm colour, not a bug. + +## Iteration 7: vacuum valves showed as transition + +Valves that were physically OPEN rendered as the indeterminate/transition +state (purple stem) on the web page. Cause: a valve's green/red triangles are +shown by visibility rules on $(DEV):PLC_OPEN / :PLC_CLOSE, but those channels +were being POLLED (they are in SYNOPTIC_PVS) and then DROPPED from the JSON - +the payloads synoptic array is built from PANEL_ITEMS, which only carries the +valves :PLC_STATUS. With no open/close data, the frontend calc +(A=0&&B=0)||(A=1&&B=1) saw null/null, evaluated the both-zero branch true, and +showed the transition stem. + +Fixes: +- generator now emits bulk.synoptic_values: the live value/severity/connected + of every SYNOPTIC_PVS entry, so the visibility rules have their data. +- frontend merges synoptic_values into its PV lookup map. +- shapes carrying a visibility rule are no longer severity-recolored - their + colour is intrinsic (green=open, red=closed). +- demo valve-state matching fixed for the PLC_OPEN / PLC_CLOSE naming. + +Verified: 14/14 demo valves render as clean OPEN (green shown, red + stem +hidden); zero purple/red pixels in the valve band. + +## Iteration 6: calc visibility + pipe fix confirmed + +- **Multi-channel calc visibility.** eps_synoptic.html now evaluates full + caQtDM visibilityCalc expressions - (A=0)||(B=0)||(C=0), (A=1)&&(B=1)&&(C=1), + !(A=1&&B=1), lowercase a=0, and the valve-stem indeterminate case - mapping + channels A..D to data-vis-pv..pvd. Verified against all cooling-valve + expressions (8/8). Replaces the previous hardcoded two-channel handling. +- **Pipes confirmed straight.** After the Line-centre fix, zero diagonal + elements remain anywhere in the SVG; the only diagonals are valve + bowtie polygons, which are correct. + +Note on EPS_CoolingValves.ui: these templates are not actually included by +X_EPS_X12SA-Graphic_new.ui (grep count 0) - they belong to a different EPS +panel. The bottom-left boxes in this synoptic are the XBPM1/DIA1/PSH1 +temperature glyphs (EPS_Temp.ui). The earlier diagonal pipes there were the +Line-skew bug, now fixed. The calc evaluator added for these templates is +still correct and general, so if a future panel does include them it works. + +## Iteration 5: straight pipes + +The beam pipe skew was a Line-rendering bug. A caQtDM caGraphics Line is drawn +axis-aligned through the CENTRE of its bounding box (horizontal if the box is +wider than tall, vertical otherwise). All 79 beam-pipe segments have a 10px +-tall box; drawing them corner-to-corner gave every one a 10px slope. Now +Lines are emitted horizontal/vertical through the box centre, and the beam +pipe is straight. + +Known limitation: the FE cooling manifold (bottom-left) is built from +EPS_CoolingValves.ui includes, a template not present in the files provided. +Its connector pipes therefore cannot be transpiled faithfully yet - the boxes +shown there are the XBPM1/DIA1/PSH1 temperature glyphs (EPS_Temp.ui), which do +resolve. To complete the manifold, add EPS_CoolingValves.ui (and any +EPS_CoolingValvesSmall.ui) to the template directory and regenerate. + +## Iteration 4: synoptic fidelity + zoning fix + +Four issues from the side-by-side comparison with the real EPS panel: + +- **Valves were mush.** A caQtDM valve is a green OPEN bowtie + a red CLOSED + bowtie + a stem, each governed by a visibility rule bound to PLC_OPEN / + PLC_CLOSE. The first transpiler ignored visibility and drew all three at + once. The transpiler now emits data-vis / data-vis-pv / data-vis-calc on + every conditional element, and eps_synoptic.html evaluates those rules live + each refresh (IfZero / IfNotZero / Calc), so exactly one valve state shows - + matching caQtDM. +- **Pipes were skewed.** Near-horizontal / near-vertical polyline and line + segments are now snapped straight (SNAP_TOL px). +- **Labels were missing.** Device names from the $(DESC) macro are drawn under + each glyph; static caLabel text (section headings) is rendered too. +- **Values hard to read.** Value fields now use a larger bold font on a subtle + backing box, like the panel readouts. +- **Zoning bug (OP devices shown under End Station).** Zones were assigned by + x-pixel-thirds, which misfiled 34 OP devices as ES and 23 as FE. Zone is now + derived from the PV name (-FE- / -OP- / -ES- / -EH1-), which is + authoritative. FE 75 / OP 147 / ES 31 / EH1 3. + +## Iteration 3: full synoptic + +The compressed inline dot-synoptic was dropped - reducing a 3071x774 +engineering drawing to a 1000x260 dot field discarded the pipe routing that +makes a synoptic readable. Replaced with a faithful transpile on its own page. + +- **eps_synoptic_svg.py** parses X_EPS_X12SA-Graphic_new.ui once and emits + **eps_synoptic.svg** at true 3071x774 geometry: static pipe/chamber + structure, PV-colored alarm shapes (tagged data-pv), and value fields. It + expands each caInclude by loading the sub-template (EPS_Valve.ui etc.), + offsetting its shapes to the include position, and substituting the macro - + so actual valve/gauge/pump glyphs are drawn, not just the pipe skeleton. + Result: 463 static shapes, 192 PV-colored shapes, 178 live value fields. +- **eps_synoptic.html** loads that SVG + eps_status.json, recolors shapes from + .SEVR and fills value text live every 10 s. Pan (drag), zoom (wheel/pinch), + fit button; works as a desktop wall display or phone pan-around. +- Colors are the panel's literal RGB for static elements; alarm elements use + severity colors. Unresolved caQtDM colors fall back to pipe-grey (close, as + agreed). +- The synoptic references 39 setpoint fields beyond the core manifest; + eps_synoptic_pvs.py lists all 251, and the generator merges them into the + bulk sweep so every drawn field has data. Total PVs: 327. +- The main page now links to the synoptic instead of embedding it. + +Regenerate the SVG whenever the panel changes: + python eps_synoptic_svg.py /sls/plc/config/qt/X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg +(needs the EPS_*.ui + VTP.ui templates on the same path or alongside.) + +## Iteration 2 changes + +- **Manifest re-scoped to the EPS expert panel.** Instead of the full + 600-PV macro expansion, the manifest now carries the 256 curated channels + actually drawn on `X_EPS_X12SA-Graphic_new.ui` (the panel operators watch), + each tagged with its real geometry, role, and zone. Total PV count dropped + from 602 to 288. +- **SVG synoptic.** A schematic mimicking the expert panel: FE / Optics / + End-Station bands, beam axis, and every device glyph placed by its true + panel position, shape-coded by role and colored by `.SEVR`. Scales down for + phones (min-width 680 px, horizontal scroll rather than crushed glyphs). +- **Mono cryocooler card.** `X12SA-OP-CC-ECCN-0010` run/alarm/inhibit + + toplevel `PLC_STS`/`Heartbeat_MON` + `CCM:DISABLE`, mirroring what the EPS + panel shows. Full temperature/pressure detail needs + `XDS_CRYOCOOLER_OVERVIEW.ui` (not yet supplied); the card expands when it is. +- **Layout reordered:** machine status -> operator messages -> alarm status -> + cryocooler -> synoptic -> zone detail -> history. +- **History display capped at 5** per stream (50 still persisted on disk). + +## Files + +| File | Purpose | +|---|---| +| `eps_pv_manifest.py` | 602 PVs, grouped by zone and role | +| `web_common.py` | `HttpUploader` + `LocalHttpServer`, lifted from `flomni_webpage_generator.py` | +| `eps_status_generator.py` | The daemon | +| `eps_status.html` | Frontend (desktop-first, 4-column zone grid) | + +## Running + +```bash +# frontend work, no network, no EPICS needed +python eps_status_generator.py --demo +# -> http://localhost:8082/eps_status.html + +# real data, local only, no upload +python eps_status_generator.py + +# with upload +python eps_status_generator.py --upload-url https://omny.online/upload.php +``` + +**Do this first on a BEC session:** + +```bash +python eps_status_generator.py --verify +``` + +Reads every PV once and reports reachability per group. See "Verify this" +below — this is the only thing standing between the manifest and a set of +possibly-wrong PV names. + +Embedded: + +```python +from eps_status_generator import EpsStatusGenerator +gen = EpsStatusGenerator(output_dir="~/eps_status") +gen.start() +... +gen.stop() +``` + +## Port + +Default **8082**. Not 8081 — that is `_MISMATCH_LOCAL_PORT` in +`flomni_webpage_generator.py`, the fallback port bound when the BEC account +does not match the system user. Using 8081 here would make that fallback fail +to bind, with a confusing error. + +## Tiers + +| Tier | Cadence | PVs | Mechanism | +|---|---|---|---| +| hot | 15 s | 26 | CA monitors (callback-driven) | +| bulk | 5 min | 576 | periodic `caget`, 2 s timeout | + +Hot PVs are monitored so a shutter close appears within seconds. Bulk uses +plain `caget` — 576 open subscriptions for data nobody is watching is not +worth the IOC load. + +Both tiers write into one JSON with separate timestamps, so the frontend +greys out detail readings independently when the bulk sweep goes stale. + +## PV provenance — read this before trusting the manifest + +Three independent namespaces, with different confidence levels: + +**`SUMMARY_PVS` (259) — HIGH confidence.** Verbatim from +`X_EPS_X12SA.ui` and `X_EPS_X12SA-IonPumps.ui`. EPS subsystem rollups: +`X12SA-EPS-VS:SYSTEM_COM`, `X12SA-EPS-CC:SYSTEM`, `X12SA-GE-CS:WP1`, etc. +Copied straight out of the panels, no transformation. + +**`DEVICE_PVS` (298) — MEDIUM confidence, unvalidated.** Expanded from the +120 `caInclude` macro instantiations in `X_EPS_X12SA-Graphic_new.ui` combined +with the `EPS_*.ui` templates. The suffix map: + +| Template | Suffixes | +|---|---| +| `EPS_Temp` | `:TEMP` | +| `EPS_Gauge` | `:PRESSURE`, `:STATUS` | +| `EPS_IonPump` | `:PRESS-DISP`, `:VOLTAGE`, `:HV`, `:ERROR`, `:SETPOINT-STATUS` | +| `EPS_Valve`, `_inv` | `:PLC_STATUS`, `:PLC_OPEN`, `:PLC_CLOSE`, `:PLC_ERROR` | +| `EPS_BST` | `-EMLS:STATE`, `-EMLS-0010:OPEN`, `-EMLS-0020:CLOSE` | +| `VTP` | `:PLC_TURBO-RBV`, `:PLC_PUMP-RBV`, `:PLC_SPEED-SP` | +| `EPS_ShtrCtrl` | `:SH-A-ENABLE`, `:EXTERN-EPS-ENABLE`, `:EXTERN-MIS-ENABLE` | + +These were **never cross-validated**. An early assumption that +`X_EPS_X12SA.ui` was a pre-resolved copy of the synoptic turned out to be +wrong — the two files share **zero** PV names, because they are different +namespaces (device-level PLC channels vs. subsystem rollups). So there is no +independent check available; `--verify` against live IOCs is the only one. + +**`MACHINE_PVS` (21) — HIGH confidence.** Verbatim from +`A_OP_Info_Panel_inc_*.ui`, except the undulator gap (below). + +## Verify this + +Two specific things `--verify` should settle: + +1. **`UNDULATOR_GAP_PV = "X12SA-UIND:GAP-RBV"`** — expanded from the nested + default macro `$(ID=$(Beamline)-UIND$(IDNR=)):GAP-RBV` assuming `IDNR` is + empty. Could be `X12SA-UIND1:GAP-RBV`. Isolated as a module constant in + `eps_pv_manifest.py` so it is a one-line fix. + +2. **`X12SA-EPS-PLC:AlarmList_EPS` type** — if it is a char waveform rather + than a string record, it needs `as_string=True`. It is already listed in + `STRING_PVS`; `--verify` will show whether the text comes back readable or + as a byte array. + +Also worth checking: whether the accelerator PVs (`AGEBD-*`, `AGEOP-*`, +`ARSGE-*`, `ARIVA-*`) are reachable from the BEC host. They live on a +different subnet from the beamline IOCs. + +## Performance with unreachable PVs + +Channel Access is asynchronous: creating PV objects issues all searches in +parallel, and a dead IOC does not answer *slowly*, it does not answer *at +all*. So the cost of unreachable PVs is paid once per sweep, not once per PV. + +Iteration 1 got this wrong — it created and read each channel serially, so +576 unreachable PVs cost `576 x timeout` (tens of minutes), and because the +sweep ran inline in the write loop, the page stopped updating entirely. + +Now: + +- All channels are created first, then polled together in one shared 2 s + window, then read (a `get()` on a connected channel is local and instant). +- Channels are cached across sweeps, so steady-state sweeps skip reconnection. +- The bulk sweep runs on **its own thread**. The write loop never blocks on + EPICS, so the page keeps updating at the hot cadence no matter what the + network does. +- `start()` writes a page immediately, before the first sweep, so the browser + always has valid JSON to load. +- Monitor setup never calls a blocking `.get()`. + +Measured with all 576 bulk PVs unreachable: sweep **2.0 s**, `start()` +**0.0 s**, page updating continuously. Each sweep logs +`bulk sweep: N/576 connected in T s`. + +## Shutdown / segfault + +Iteration 1 segfaulted on Ctrl-C after logging "generator stopped". Cause: +`CaReader.stop()` called `PV.disconnect()` on every monitored channel. +pyepics registers its own `atexit` handler that runs `ca.finalize_libca()`, +so the channels were being torn down twice, while libca's background thread +could still be delivering monitor events into freed structures. That crash is +in native code, where a Python `try/except` cannot help. + +Now `stop()` clears callbacks, sets a `_closing` flag that makes any +in-flight callback return immediately, drops the references, and lets +pyepics finalize libca itself. A second Ctrl-C during shutdown is also +swallowed so it cannot interrupt teardown halfway. + +Note: this could not be reproduced in the development container, because +pyepics there ships `libca.so` without the `caRepeater` binary, so no channel +ever connects and `disconnect()` is a no-op. The fix follows pyepics' +documented teardown contract, but **the real confirmation is Ctrl-C on a BEC +session with channels actually connected**. Please report back. + +## Demo mode + +`--demo` synthesises plausible values so the frontend can be developed +off-network. It is **never entered implicitly**: an unreachable PV in real +mode renders as a grey "no conn" and is counted in the footer's +"N of M PVs unreachable" line. It never silently becomes a fake number. When +demo mode is on the page shows a purple DEMO banner. + +Off the X12SA network without `--demo`, the page renders fully with every +tile grey — a decent smoke test of the frontend on its own. + +## History + +`eps_alarm_history.json`, written alongside the status files, survives daemon +restarts. Three streams: + +- **`eps_events`** — opened when `AlarmCnt_EPS` goes 0 → non-zero, closed when + it returns to 0. A re-alarm within 60 s with identical text extends the + existing event rather than opening a new one, so flapping does not spam + the list. +- **`machine_events`** — verbatim transitions of `HISTORY_PVS` + (`LIGHT-DELIVERY`, `AGEOP-BL:STATUS-X12SA`, `EPS-PH:SYSTEM`, OP/EH1 shutter, + FOFB). No inference, just "this PV said X, now says Y, at this time". +- **`ring_current_trace`** — one sample per bulk interval, 288 points ≈ 24 h, + rendered as a sparkline. Beam dumps are *visible* next to the + `LIGHT-DELIVERY` transition rather than inferred from a threshold, which + would be wrong during top-up commissioning. + +Both event streams are interleaved on one timeline in the page, colour-coded +by source. + +## Layout + +Expanded/collapsed state of the per-zone detail tables persists across the +10 s auto-refresh and across reloads (`sessionStorage`, keyed by zone), and +scroll position is preserved across re-renders. On first visit, zones with +40 or fewer channels start expanded. + +Desktop-first. Zone cards are 4 across above 1500 px, 3 above 1100 px, 2 above +700 px, 1 below. Detail tables are `
` when a zone has ≤ 40 +channels, collapsed otherwise. Severity comes from EPICS `.SEVR` +(`NO_ALARM`/`MINOR`/`MAJOR`/`INVALID` → green/amber/red/grey), matching what +`colorMode: Alarm` does in caQtDM — no thresholds are reimplemented anywhere. +Units come from `.EGU`, read once and cached. + +Colours match `status.html` (Catppuccin, light/dark via +`prefers-color-scheme`). + +## Integrating into the BEC generator later + +1. Move `web_common.py` next to `flomni_webpage_generator.py` and have the + base generator import `HttpUploader` / `LocalHttpServer` from it instead of + defining them. Behaviour is identical, plus `allow_redirects=False` (the + SSRF fix, which is not in the current base-generator copy). +2. Construct `EpsStatusGenerator` inside `WebpageGeneratorBase.start()` and + call its `stop()` from `stop()`. It owns its own threads, so no changes to + `_cycle()`. +3. Either keep it on its own port, or set `serve=False` and let the existing + `LocalHttpServer` serve both pages from one directory — the filenames do + not collide. + +Both pages already cross-link in their headers. + +## Known gaps in iteration 1 + +- Zone assignment is derived from the PV name prefix, so `GE` (general + services: cooling, compressed air) is its own column rather than being + distributed to the hutches it serves. +- The `:DISABLE` bypass flags in `X_EPS_X12SA-Components.ui` are not yet + surfaced. They answer "why is the shutter open when vacuum is bad" and are + worth adding in iteration 2. +- No SVG synoptic. Deliberate — cards first, and every widget in + `X_EPS_X12SA-Graphic_new.ui` carries exact geometry, so a transpiled SVG can + reuse this same JSON later without rework. +- `EPS_PLCStatus.ui` watchdogs are in the manifest but not given a dedicated + panel. diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/__init__.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py new file mode 100644 index 0000000..3f9c943 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py @@ -0,0 +1,371 @@ +""" +EPS / machine-status PV manifest for cSAXS (X12SA) - iteration 2. + +Scoped to the curated channels of the EPS expert panel +X_EPS_X12SA-Graphic_new.ui (the panel operators actually watch), +NOT the full 600-PV macro expansion. Each entry carries the widget +geometry from the panel so the SVG synoptic can place it faithfully. + +PANEL_ITEMS entries: (pv, zone, role, desc, xf, yf) + xf, yf are normalized 0..1 positions from the 3064x774 panel. +""" + +BML = "X12SA" +UNDULATOR_GAP_PV = "X12SA-UIND:GAP-RBV" # unverified nested-macro default + +HOT_PVS = [ + ("X12SA-EPS-PLC:AlarmCnt_EPS", "eps_alarm_count", "EPS active alarm count"), + ("X12SA-EPS-PLC:AlarmList_EPS", "eps_alarm_list", "EPS alarm text"), + ("X12SA-EPS-PLC:AlarmWatchdog_EPS", "eps_watchdog", "EPS PLC watchdog"), + ("ARS00-MIS-PLC-01:AlarmCnt_Frontends", "fe_alarm_count", "Front-end alarm count"), + ("ARS00-MIS-PLC-01:AlarmList_Frontends", "fe_alarm_list", "Front-end alarm text"), + ("X12SA-EPS-PH:SYSTEM", "eps_permit", "EPS photon-shutter permit"), + ("X12SA-OP-PSYS:SH-A-OPEN", "sh_op_a_open", "OP shutter A open"), + ("X12SA-OP-PSYS:SH-A-CLOSE", "sh_op_a_closed", "OP shutter A closed"), + ("X12SA-OP-PSYS:SH-A-OK", "sh_op_a_ok", "OP shutter A OK"), + ("X12SA-EH1-PSYS:SH-A-OPEN", "sh_eh1_a_open", "EH1 shutter A open"), + ("X12SA-EH1-PSYS:SH-A-CLOSE", "sh_eh1_a_closed", "EH1 shutter A closed"), + ("X12SA-EH1-PSYS:SH-A-OK", "sh_eh1_a_ok", "EH1 shutter A OK"), + ("X12SA-OP-PSYS:ALARM-COLLECTIVE", "psys_op_alarm", "OP PSYS collective alarm"), + ("X12SA-EH1-PSYS:ALARM-COLLECTIVE", "psys_eh1_alarm", "EH1 PSYS collective alarm"), + ("X12SA-FE-PH1:OPEN_EPS", "fe_ph1_open", "FE photon shutter open"), + ("X12SA-FE-PH1:CLOSE_EPS", "fe_ph1_closed", "FE photon shutter closed"), + ("AGEBD-PARAMS:CURRENT", "ring_current", "SLS ring current"), + ("AGEBD-PARAMS:LIFETIME", "lifetime", "Beam lifetime"), + ("AGEOP-STATUS:LIGHT-DELIVERY", "light_delivery", "Light delivery state"), + ("AGEOP-BL:STATUS-X12SA", "bl_status", "Beamline status"), + ("ARSGE-CECL-FOFB1:FOFB-OPERATE", "fofb_on", "Fast orbit feedback"), + (UNDULATOR_GAP_PV, "undulator_gap", "Undulator gap"), +] + +STRING_PVS = { + "X12SA-EPS-PLC:AlarmList_EPS", + "ARS00-MIS-PLC-01:AlarmList_Frontends", + "AGEOP-STATUS:LIGHT-DELIVERY", + "AGEOP-STATUS:DUO-SHIFT-TYPE", + "AGEOP-BL:STATUS-X12SA", + "X12SA-EPS-PH:SYSTEM", + "X12SA-OP-CC-ECCN-0010:ALARM.DESC", + "X12SA-OP-CC-ECCN-0010:RUN.DESC", + "X12SA-OP-CC:PLC_STS", +} + +HISTORY_PVS = [ + ("AGEOP-STATUS:LIGHT-DELIVERY", "Light delivery"), + ("AGEOP-BL:STATUS-X12SA", "Beamline status"), + ("X12SA-EPS-PH:SYSTEM", "EPS permit"), + ("X12SA-OP-PSYS:SH-A-OPEN", "OP shutter A"), + ("X12SA-EH1-PSYS:SH-A-OPEN", "EH1 shutter A"), + ("ARSGE-CECL-FOFB1:FOFB-OPERATE", "Orbit feedback"), +] + +MACHINE_PVS = [ + ("AGEBD-PARAMS:INJECTION-RATE", "Injection rate"), + ("AGEBD-PARAMS:TRANSMISSION", "Transmission"), + ("ARSGE-CECL-FOFB1:B-ERR-X-RMS-UM", "Orbit RMS X"), + ("ARSGE-CECL-FOFB1:B-ERR-Y-RMS-UM", "Orbit RMS Y"), + ("ARIVA-VMAVE:PRESS-MAX", "Ring vacuum"), + ("AGEOP-STATUS:DUO-SHIFT-TYPE", "Shift type"), +] + +OPMSG_PVS = [(f"AGEOP-CPCL-OPMSG:CR-MSG{i}", + f"AGEOP-CPCL-OPMSG:CR-MSG-DATE{i}") for i in range(1, 6)] + +# XDS cryocooler (mono), mirroring the EPS panel + toplevel liveness. +# Full temp/pressure detail needs XDS_CRYOCOOLER_OVERVIEW.ui. +CRYO_PVS = [ + ("X12SA-OP-CC:PLC_STS", "status", "PLC status"), + ("X12SA-OP-CC:Heartbeat_MON", "heartbeat", "Heartbeat"), + ("X12SA-OP-CC-ECCN-0010:RUN", "run", "Running"), + ("X12SA-OP-CC-ECCN-0010:RUN.DESC", "run_desc", "Run state text"), + ("X12SA-OP-CC-ECCN-0010:ALARM", "alarm", "Alarm"), + ("X12SA-OP-CC-ECCN-0010:ALARM.DESC", "alarm_desc", "Alarm text"), + ("X12SA-OP-CC-ECCN-0010:INHIBIT", "inhibit", "Inhibited"), + ("X12SA-OP-CCM:DISABLE", "disable", "Interlock disabled"), +] + +# Curated EPS-panel channels, with normalized geometry for the synoptic. +PANEL_ITEMS = [ + ("X12SA-FE-VVPG-0000:PLC_STATUS", "FE", "valve", "", 0.0155, 0.5181), + ("X12SA-FE-ABFE-VPIG-1010:PRESS-DISP", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403), + ("X12SA-FE-ABFE-VPIG-1010:VOLTAGE", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403), + ("X12SA-FE-ABFE-VPIG-1010:HV", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403), + ("X12SA-FE-ABFE-VPIG-1010:ERROR", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403), + ("X12SA-FE-VMCC-0000:PRESSURE", "FE", "gauge", "IKR", 0.0307, 0.4134), + ("X12SA-FE-VMCC-0000:STATUS", "FE", "gauge", "IKR", 0.0307, 0.4134), + ("X12SA-FE-VMTC-0000:PRESSURE", "FE", "gauge", "TPR", 0.0307, 0.3656), + ("X12SA-FE-VMTC-0000:STATUS", "FE", "gauge", "TPR", 0.0307, 0.3656), + ("X12SA-FE-AP-EPSG-0010:OK", "FE", "permit", "", 0.0307, 0.1938), + ("X12SA-FE-VCVS-0000:ENA", "FE", "signal", "", 0.0317, 0.5297), + ("X12SA-FE-VMMG-0010:PLC_RELAY-B", "FE", "relay", "", 0.0317, 0.5297), + ("X12SA-FE-XBPM1-ETTC-0010:TEMP", "FE", "temperature", "", 0.0483, 0.5969), + ("X12SA-FE-XBPM1-ETTC-0020:TEMP", "FE", "temperature", "", 0.0483, 0.6305), + ("X12SA-FE-CS-ECVW-0010:PLC_STATUS", "FE", "valve", "", 0.0635, 0.7506), + ("X12SA-FE-CS-ECVW-0020:PLC_STATUS", "FE", "valve", "", 0.0635, 0.8463), + ("X12SA-FE-PUM1-VPIG-1020:PRESS-DISP", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403), + ("X12SA-FE-PUM1-VPIG-1020:VOLTAGE", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403), + ("X12SA-FE-PUM1-VPIG-1020:HV", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403), + ("X12SA-FE-PUM1-VPIG-1020:ERROR", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403), + ("X12SA-FE-DIA1-ETTC-0010:TEMP", "FE", "temperature", "", 0.074, 0.5969), + ("X12SA-FE-DIA1-ETTC-0020:TEMP", "FE", "temperature", "", 0.074, 0.6305), + ("X12SA-FE-XBPM1-EFSW-0010:FLOW", "FE", "flow", "", 0.0885, 0.8036), + ("X12SA-FE-PSH1-VPIG-1030:PRESS-DISP", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403), + ("X12SA-FE-PSH1-VPIG-1030:VOLTAGE", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403), + ("X12SA-FE-PSH1-VPIG-1030:HV", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403), + ("X12SA-FE-PSH1-VPIG-1030:ERROR", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403), + ("X12SA-FE-PSH1-VMTC-1010:PRESSURE", "FE", "gauge", "TPR", 0.0986, 0.3656), + ("X12SA-FE-PSH1-VMTC-1010:STATUS", "FE", "gauge", "TPR", 0.0986, 0.3656), + ("X12SA-FE-PSH1-VMCC-1010:PRESSURE", "FE", "gauge", "IKR", 0.0986, 0.4134), + ("X12SA-FE-PSH1-VMCC-1010:STATUS", "FE", "gauge", "IKR", 0.0986, 0.4134), + ("X12SA-FE-PSH1-EMLS:STATE", "FE", "shutter", "", 0.099, 0.5181), + ("X12SA-FE-PSH1-ETTC-0020:TEMP", "FE", "temperature", "", 0.1006, 0.6305), + ("X12SA-FE-PSH1-ETTC-0010:TEMP", "FE", "temperature", "", 0.1006, 0.5969), + ("X12SA-FE-DIA1-EFSW-0010:FLOW", "FE", "flow", "", 0.1067, 0.8036), + ("X12SA-FE-CS-ECVW-0020:PLC_OPEN", "FE", "signal", "", 0.1108, 0.8269), + ("X12SA-FE-CS-ECVW-0010:PLC_OPEN", "FE", "signal", "", 0.1108, 0.7674), + ("X12SA-FE-PSH1-EFSW-0010:FLOW", "FE", "shutter", "", 0.1219, 0.8036), + ("X12SA-FE-VVPG-1010:PLC_STATUS", "FE", "valve", "", 0.1239, 0.5181), + ("X12SA-FE-CS-ECVW-0040:PLC_STATUS", "FE", "valve", "", 0.1348, 0.8463), + ("X12SA-FE-CS-ECVW-0030:PLC_STATUS", "FE", "valve", "", 0.1348, 0.7506), + ("X12SA-FE-VVFV-2010:PLC_CLOSE", "FE", "fast_valve", "", 0.1557, 0.5207), + ("X12SA-FE-SL1-EFSW-0010:FLOW", "FE", "flow", "", 0.1611, 0.8036), + ("X12SA-FE-CS-ECVW-0030:PLC_OPEN", "FE", "signal", "", 0.1658, 0.7674), + ("X12SA-FE-STO1-VPIG-2010:PRESS-DISP", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403), + ("X12SA-FE-STO1-VPIG-2010:VOLTAGE", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403), + ("X12SA-FE-STO1-VPIG-2010:HV", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403), + ("X12SA-FE-STO1-VPIG-2010:ERROR", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403), + ("X12SA-FE-STO1-VMTC-2010:PRESSURE", "FE", "gauge", "TPR", 0.1678, 0.3656), + ("X12SA-FE-STO1-VMTC-2010:STATUS", "FE", "gauge", "TPR", 0.1678, 0.3656), + ("X12SA-FE-STO1-VMCC-2010:PRESSURE", "FE", "gauge", "IKR", 0.1678, 0.4134), + ("X12SA-FE-STO1-VMCC-2010:STATUS", "FE", "gauge", "IKR", 0.1678, 0.4134), + ("X12SA-FE-STO1-EMLS:STATE", "FE", "shutter", "", 0.1695, 0.5181), + ("X12SA-FE-SL2-EFSW-0010:FLOW", "FE", "flow", "", 0.1766, 0.8036), + ("X12SA-FE-CS-ECVW-0040:PLC_OPEN", "FE", "signal", "", 0.1817, 0.8269), + ("X12SA-FE-SL1-ETTC-0010:TEMP", "FE", "temperature", "", 0.1982, 0.5969), + ("X12SA-FE-SL1-ETTC-0020:TEMP", "FE", "temperature", "", 0.1982, 0.6305), + ("X12SA-FE-SL2-ETTC-0020:TEMP", "FE", "temperature", "", 0.1982, 0.6977), + ("X12SA-FE-SL2-ETTC-0010:TEMP", "FE", "temperature", "", 0.1982, 0.6641), + ("X12SA-FE-PUM2-VPIG-2020:PRESS-DISP", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403), + ("X12SA-FE-PUM2-VPIG-2020:VOLTAGE", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403), + ("X12SA-FE-PUM2-VPIG-2020:HV", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403), + ("X12SA-FE-PUM2-VPIG-2020:ERROR", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403), + ("X12SA-FE-VMMG-0010:PLC_RELAY-F", "FE", "relay", "", 0.2506, 0.5297), + ("X12SA-FE-PUM3-VMCC-2020:PRESSURE", "FE", "gauge", "IKR", 0.2624, 0.4134), + ("X12SA-FE-PUM3-VMCC-2020:STATUS", "FE", "gauge", "IKR", 0.2624, 0.4134), + ("X12SA-FE-PUM3-VPIG-2030:PRESS-DISP", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403), + ("X12SA-FE-PUM3-VPIG-2030:VOLTAGE", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403), + ("X12SA-FE-PUM3-VPIG-2030:HV", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403), + ("X12SA-FE-PUM3-VPIG-2030:ERROR", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403), + ("X12SA-FE-PUM3-VMTC-2020:PRESSURE", "FE", "gauge", "TPR", 0.2624, 0.3656), + ("X12SA-FE-PUM3-VMTC-2020:STATUS", "FE", "gauge", "TPR", 0.2624, 0.3656), + ("X12SA-FE-VVPG-2010:PLC_STATUS", "FE", "valve", "", 0.2682, 0.5181), + ("X12SA-FE-VMMG-0010:PLC_RELAY-D", "FE", "chamber", "Pumpst.4", 0.5691, 0.5039), + ("X12SA-FE-VVFV-2010:PLC_INRUSH", "FE", "signal", "", 0.823, 0.4729), + ("ARS12-VCVS-1000:ENA", "OP", "signal", "", 0.0, 0.5297), + ("X12SA-OP-PSYS:SH-A-CLOSE", "OP", "shutter", "", 0.1027, 0.6835), + ("X12SA-OP-PSYS:SH-A-OPEN", "OP", "shutter", "", 0.1027, 0.6835), + ("X12SA-OP-PSYS:SH-A-OK", "OP", "shutter", "", 0.1077, 0.4716), + ("X12SA-OP-PSYS:SH-B-OPEN", "OP", "shutter", "", 0.1733, 0.6202), + ("X12SA-OP-PSYS:SH-B-CLOSE", "OP", "shutter", "", 0.1733, 0.6202), + ("X12SA-OP-PSYS:SH-B-OK", "OP", "shutter", "", 0.177, 0.4716), + ("X12SA-OP-VMMG-0010:PLC_RELAY-B", "OP", "relay", "", 0.2844, 0.5297), + ("X12SA-OP-PUM1-VPIG-1010:PRESS-DISP", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403), + ("X12SA-OP-PUM1-VPIG-1010:VOLTAGE", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403), + ("X12SA-OP-PUM1-VPIG-1010:HV", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403), + ("X12SA-OP-PUM1-VPIG-1010:ERROR", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403), + ("X12SA-OP-PUM1-VMCC-1010:PRESSURE", "OP", "gauge", "IKR", 0.2975, 0.4134), + ("X12SA-OP-PUM1-VMCC-1010:STATUS", "OP", "gauge", "IKR", 0.2975, 0.4134), + ("X12SA-OP-PUM1-VMCP-1010:PRESSURE", "OP", "gauge", "PCR", 0.2975, 0.3656), + ("X12SA-OP-PUM1-VMCP-1010:STATUS", "OP", "gauge", "PCR", 0.2975, 0.3656), + ("X12SA-OP-VMMG-0010:PLC_RELAY-C", "OP", "relay", "", 0.3357, 0.5297), + ("X12SA-OP-VVPG-1010:PLC_STATUS", "OP", "valve", "", 0.3377, 0.5181), + ("X12SA-OP-PUM2-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403), + ("X12SA-OP-PUM2-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403), + ("X12SA-OP-PUM2-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403), + ("X12SA-OP-PUM2-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403), + ("X12SA-OP-PUM2-VMFR-2010:PRESSURE", "OP", "gauge", "Pumpstand2", 0.3391, 0.4134), + ("X12SA-OP-PUM2-VMFR-2010:STATUS", "OP", "gauge", "Pumpstand2", 0.3391, 0.4134), + ("X12SA-OP-SL1-ETTC-2020:TEMP", "OP", "temperature", "", 0.3563, 0.6305), + ("X12SA-OP-SL2-ETTC-2010:TEMP", "OP", "temperature", "", 0.3563, 0.6641), + ("X12SA-OP-SL1-ETTC-2010:TEMP", "OP", "temperature", "", 0.3563, 0.5969), + ("X12SA-OP-SL2-ETTC-2020:TEMP", "OP", "temperature", "", 0.3563, 0.6977), + ("X12SA-OP-VMMG-0010:PLC_RELAY-D", "OP", "chamber", "Pumpst.3", 0.3739, 0.5039), + ("X12SA-OP-PUM3-VMFR-2010:PRESSURE", "OP", "gauge", "Pumpstand3", 0.3779, 0.4134), + ("X12SA-OP-PUM3-VMFR-2010:STATUS", "OP", "gauge", "Pumpstand3", 0.3779, 0.4134), + ("X12SA-OP-PUM3-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403), + ("X12SA-OP-PUM3-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403), + ("X12SA-OP-PUM3-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403), + ("X12SA-OP-PUM3-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403), + ("X12SA-OP-PUM3-ETTC-2010:TEMP", "OP", "temperature", "", 0.3806, 0.5969), + ("X12SA-OP-AP-EPTG-0010:PRESSURE", "OP", "gauge", "", 0.381, 0.177), + ("X12SA-OP-VVPG-2010:PLC_STATUS", "OP", "valve", "", 0.4016, 0.5168), + ("X12SA-OP-VMMG-0010:PLC_RELAY-E", "OP", "chamber", "DMM", 0.4232, 0.5039), + ("X12SA-OP-DMM-VMFR-3010:PRESSURE", "OP", "gauge", "DMM", 0.433, 0.4134), + ("X12SA-OP-DMM-VMFR-3010:STATUS", "OP", "gauge", "DMM", 0.433, 0.4134), + ("X12SA-OP-DMM-VPIG-3010:PRESS-DISP", "OP", "ion_pump", "DMM", 0.433, 0.2403), + ("X12SA-OP-DMM-VPIG-3010:VOLTAGE", "OP", "ion_pump", "DMM", 0.433, 0.2403), + ("X12SA-OP-DMM-VPIG-3010:HV", "OP", "ion_pump", "DMM", 0.433, 0.2403), + ("X12SA-OP-DMM-VPIG-3010:ERROR", "OP", "ion_pump", "DMM", 0.433, 0.2403), + ("X12SA-OP-DMM-ETTC-3030:TEMP", "OP", "temperature", "", 0.4336, 0.6641), + ("X12SA-OP-DMM-ETTC-3040:TEMP", "OP", "temperature", "", 0.4336, 0.6977), + ("X12SA-OP-DMM-ETTC-3020:TEMP", "OP", "temperature", "", 0.4336, 0.6305), + ("X12SA-OP-DMM-ETTC-3010:TEMP", "OP", "temperature", "", 0.4336, 0.5969), + ("X12SA-OP-VVPG-3010:PLC_STATUS", "OP", "valve", "", 0.463, 0.5168), + ("X12SA-OP-CC-ECCN-0010:ALARM.DESC", "OP", "signal", "", 0.4661, 0.8152), + ("X12SA-OP-CC-ECCN-0010:RUN.DESC", "OP", "signal", "", 0.4661, 0.854), + ("X12SA-OP-VVPG-3020:PLC_STATUS", "OP", "valve", "", 0.4877, 0.5168), + ("X12SA-OP-CC-ECCN-0010:ALARM", "OP", "signal", "", 0.5035, 0.814), + ("X12SA-OP-CC-ECCN-0010:RUN", "OP", "signal", "", 0.5035, 0.8527), + ("X12SA-OP-VMMG-0010:PLC_RELAY-F", "OP", "chamber", "CCM", 0.5073, 0.5039), + ("X12SA-OP-CCM-VMFR-4010:PRESSURE", "OP", "gauge", "CCM", 0.5164, 0.4134), + ("X12SA-OP-CCM-VMFR-4010:STATUS", "OP", "gauge", "CCM", 0.5164, 0.4134), + ("X12SA-OP-CCM-VPIG-4010:PRESS-DISP", "OP", "ion_pump", "CCM", 0.5164, 0.2403), + ("X12SA-OP-CCM-VPIG-4010:VOLTAGE", "OP", "ion_pump", "CCM", 0.5164, 0.2403), + ("X12SA-OP-CCM-VPIG-4010:HV", "OP", "ion_pump", "CCM", 0.5164, 0.2403), + ("X12SA-OP-CCM-VPIG-4010:ERROR", "OP", "ion_pump", "CCM", 0.5164, 0.2403), + ("X12SA-OP-CCM-ETTC-4020:TEMP", "OP", "temperature", "", 0.5184, 0.6305), + ("X12SA-OP-CCM-ETTC-4010:TEMP", "OP", "temperature", "", 0.5184, 0.5969), + ("X12SA-OP-VVPG-4010:PLC_STATUS", "OP", "valve", "", 0.5468, 0.5168), + ("X12SA-OP-EB1-VMCP-5010:PRESSURE", "OP", "gauge", "PrePump", 0.5518, 0.8941), + ("X12SA-OP-EB1-VMCP-5010:STATUS", "OP", "gauge", "PrePump", 0.5518, 0.8941), + ("X12SA-OP-EB1-VPTM-5010:PLC_TURBO-RBV", "OP", "turbo", "", 0.5589, 0.6899), + ("X12SA-OP-EB1-VPRO-5010:PLC_RUN", "OP", "signal", "", 0.5623, 0.8295), + ("X12SA-OP-PUM4-VMFR-5010:PRESSURE", "OP", "gauge", "Pumpstand4", 0.5721, 0.4134), + ("X12SA-OP-PUM4-VMFR-5010:STATUS", "OP", "gauge", "Pumpstand4", 0.5721, 0.4134), + ("X12SA-OP-PUM4-VPIG-5010:PRESS-DISP", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239), + ("X12SA-OP-PUM4-VPIG-5010:VOLTAGE", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239), + ("X12SA-OP-PUM4-VPIG-5010:HV", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239), + ("X12SA-OP-PUM4-VPIG-5010:ERROR", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239), + ("X12SA-OP-EB1-VPRO-5010:PLC_ALARM", "OP", "signal", "", 0.5731, 0.938), + ("X12SA-OP-PUM4-ETTC-5010:TEMP", "OP", "temperature", "", 0.5741, 0.5969), + ("X12SA-OP-VMMG-0020:PLC_RELAY-F", "OP", "relay", "", 0.5748, 0.8527), + ("X12SA-OP-EB1-VVPP-5010:PLC_STATUS", "OP", "valve", "", 0.5812, 0.8385), + ("X12SA-OP-EB1-VVPG-5010:PLC_STATUS", "OP", "valve", "", 0.5947, 0.6124), + ("X12SA-OP-VMMG-0020:PLC_RELAY-A", "OP", "chamber", "Exp-Box1", 0.5968, 0.5039), + ("X12SA-OP-EB1-VPIG-5010:PRESS-DISP", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239), + ("X12SA-OP-EB1-VPIG-5010:VOLTAGE", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239), + ("X12SA-OP-EB1-VPIG-5010:HV", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239), + ("X12SA-OP-EB1-VPIG-5010:ERROR", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239), + ("X12SA-OP-EB1-VMFR-5020:PRESSURE", "OP", "gauge", "Exp-Box1", 0.6079, 0.4134), + ("X12SA-OP-EB1-VMFR-5020:STATUS", "OP", "gauge", "Exp-Box1", 0.6079, 0.4134), + ("X12SA-OP-EB1-VPTM-5010:PLC_SPEED-SP", "OP", "signal", "", 0.6079, 0.6434), + ("X12SA-OP-VMMG-0020:PLC_RELAY-C", "OP", "relay", "", 0.6322, 0.5297), + ("X12SA-OP-EB1-VPIG-5020:PRESS-DISP", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239), + ("X12SA-OP-EB1-VPIG-5020:VOLTAGE", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239), + ("X12SA-OP-EB1-VPIG-5020:HV", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239), + ("X12SA-OP-EB1-VPIG-5020:ERROR", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239), + ("X12SA-OP-SL3-ETTC-5010:TEMP", "OP", "temperature", "", 0.6484, 0.5969), + ("X12SA-OP-SL3-ETTC-5020:TEMP", "OP", "temperature", "", 0.6484, 0.6305), + ("X12SA-OP-SL4-ETTC-5020:TEMP", "OP", "temperature", "", 0.6484, 0.6977), + ("X12SA-OP-SL4-ETTC-5010:TEMP", "OP", "temperature", "", 0.6484, 0.6641), + ("X12SA-OP-SL1-EFSW-2010:FLOW", "OP", "flow", "", 0.6494, 0.8398), + ("X12SA-OP-PUM5-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239), + ("X12SA-OP-PUM5-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239), + ("X12SA-OP-PUM5-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239), + ("X12SA-OP-PUM5-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239), + ("X12SA-OP-PUM5-VMFR-5010:PRESSURE", "OP", "gauge", "Pumpstand5", 0.6663, 0.4134), + ("X12SA-OP-PUM5-VMFR-5010:STATUS", "OP", "gauge", "Pumpstand5", 0.6663, 0.4134), + ("X12SA-OP-SL2-EFSW-2010:FLOW", "OP", "flow", "", 0.667, 0.8398), + ("X12SA-OP-CS-ECVW-0020:PLC_OPEN", "OP", "signal", "", 0.6727, 0.863), + ("X12SA-OP-CS-ECVW-0010:PLC_OPEN", "OP", "signal", "", 0.6727, 0.8036), + ("X12SA-OP-EB1-EFSW-5010:FLOW", "OP", "flow", "", 0.6846, 0.8398), + ("X12SA-OP-VVPG-5010:PLC_STATUS", "OP", "valve", "", 0.6852, 0.5168), + ("X12SA-OP-KB-VPIG-6010:PRESS-DISP", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239), + ("X12SA-OP-KB-VPIG-6010:VOLTAGE", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239), + ("X12SA-OP-KB-VPIG-6010:HV", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239), + ("X12SA-OP-KB-VPIG-6010:ERROR", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239), + ("X12SA-OP-VMMG-0020:PLC_RELAY-D", "OP", "chamber", "KB", 0.7058, 0.5039), + ("X12SA-OP-KB-VMFR-6010:PRESSURE", "OP", "gauge", "KB-mirror", 0.7146, 0.4134), + ("X12SA-OP-KB-VMFR-6010:STATUS", "OP", "gauge", "KB-mirror", 0.7146, 0.4134), + ("X12SA-OP-SL3-EFSW-5010:FLOW", "OP", "flow", "", 0.7197, 0.8398), + ("X12SA-OP-KB-VPIG-6020:PRESS-DISP", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239), + ("X12SA-OP-KB-VPIG-6020:VOLTAGE", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239), + ("X12SA-OP-KB-VPIG-6020:HV", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239), + ("X12SA-OP-KB-VPIG-6020:ERROR", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239), + ("X12SA-OP-KB-EFSW-6010:FLOW", "OP", "flow", "", 0.7548, 0.8398), + ("X12SA-OP-VVFV-6010:PLC_CLOSE", "OP", "fast_valve", "", 0.7622, 0.5207), + ("X12SA-OP-VVPG-6010:PLC_STATUS", "OP", "valve", "", 0.7707, 0.5168), + ("X12SA-OP-VMMG-0020:PLC_RELAY-E", "OP", "relay", "", 0.7876, 0.5297), + ("X12SA-OP-PSH1-EFSW-7010:FLOW", "OP", "shutter", "", 0.7899, 0.8398), + ("X12SA-OP-PSH1-VPIG-7010:PRESS-DISP", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239), + ("X12SA-OP-PSH1-VPIG-7010:VOLTAGE", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239), + ("X12SA-OP-PSH1-VPIG-7010:HV", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239), + ("X12SA-OP-PSH1-VPIG-7010:ERROR", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239), + ("X12SA-OP-PSH1-VMFR-6010:PRESSURE", "OP", "gauge", "Ph.ShutterOP", 0.797, 0.4134), + ("X12SA-OP-PSH1-VMFR-6010:STATUS", "OP", "gauge", "Ph.ShutterOP", 0.797, 0.4134), + ("X12SA-OP-PSH1-EMLS:STATE", "OP", "shutter", "", 0.7974, 0.5181), + ("X12SA-OP-PSH1-ETTC-7010:TEMP", "OP", "temperature", "", 0.7994, 0.5969), + ("X12SA-OP-PSH1-ETTC-7020:TEMP", "OP", "temperature", "", 0.7994, 0.6305), + ("X12SA-OP-VVPG-7010:PLC_STATUS", "OP", "valve", "", 0.824, 0.5168), + ("X12SA-OP-CS-ECVW-0010:PLC_STATUS", "OP", "valve", "", 0.8457, 0.7868), + ("X12SA-OP-CS-ECVW-0020:PLC_STATUS", "OP", "valve", "", 0.8457, 0.8824), + ("X12SA-OP-VVFV-6010:PLC_INRUSH", "OP", "signal", "", 0.8869, 0.4729), + ("X12SA-ES-EB2-EFSW-1010:FLOW", "ES", "flow", "", 0.8173, 0.8398), + ("X12SA-ES-VMMG-0010:PLC_RELAY-A", "ES", "chamber", "Exp-Box2", 0.868, 0.5039), + ("X12SA-ES-EB2-VPIG-1010:PRESS-DISP", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239), + ("X12SA-ES-EB2-VPIG-1010:VOLTAGE", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239), + ("X12SA-ES-EB2-VPIG-1010:HV", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239), + ("X12SA-ES-EB2-VPIG-1010:ERROR", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239), + ("X12SA-ES-EB2-VMFR-1010:PRESSURE", "ES", "gauge", "Exp-Box2", 0.8767, 0.4134), + ("X12SA-ES-EB2-VMFR-1010:STATUS", "ES", "gauge", "Exp-Box2", 0.8767, 0.4134), + ("X12SA-ES-VVPG-1010:PLC_STATUS", "ES", "valve", "", 0.9085, 0.5168), + ("X12SA-ES-EB3-VMCP-2010:PRESSURE", "ES", "gauge", "EB3prevac", 0.9149, 0.814), + ("X12SA-ES-EB3-VMCP-2010:STATUS", "ES", "gauge", "EB3prevac", 0.9149, 0.814), + ("X12SA-ES-EB3-VPTM-2010:PLC_TURBO-RBV", "ES", "turbo", "", 0.9169, 0.6899), + ("X12SA-ES-EB3-VVPG-2010:PLC_STATUS", "ES", "valve", "", 0.9321, 0.6124), + ("X12SA-ES-VMMG-0010:PLC_RELAY-D", "ES", "chamber", "Exp-Box3", 0.9341, 0.5039), + ("X12SA-ES-EB3-VMFR-2010:PRESSURE", "ES", "gauge", "Exp-Box3", 0.9436, 0.4134), + ("X12SA-ES-EB3-VMFR-2010:STATUS", "ES", "gauge", "Exp-Box3", 0.9436, 0.4134), + ("X12SA-ES-EB3-VVPP-2010:PLC_STATUS", "ES", "valve", "", 0.945, 0.8385), + ("X12SA-ES-VMMG-0010:PLC_RELAY-E", "ES", "relay", "", 0.9453, 0.6434), + ("X12SA-ES-VMMG-0010:PLC_RELAY-F", "ES", "relay", "", 0.9622, 0.8527), + ("X12SA-ES-EB3-VMCP-2020:PRESSURE", "ES", "gauge", "Prepump", 0.972, 0.8941), + ("X12SA-ES-EB3-VMCP-2020:STATUS", "ES", "gauge", "Prepump", 0.972, 0.8941), + ("X12SA-ES-DET-CHIL1:DISABLE", "ES", "signal", "", 0.973, 0.1098), + ("X12SA-ES-DET-CHIL2:DISABLE", "ES", "signal", "", 0.973, 0.1486), + ("X12SA-ES-EB3-VPRO-2010:PLC_RUN", "ES", "signal", "", 0.9784, 0.8295), + ("X12SA-ES-EB3-VPRO-2010:PLC_ALARM", "ES", "signal", "", 0.9932, 0.938), + ("X12SA-ES-FT-VMFR-1010:PRESSURE", "ES", "gauge", "FlightTube", 0.9966, 0.4134), + ("X12SA-ES-FT-VMFR-1010:STATUS", "ES", "gauge", "FlightTube", 0.9966, 0.4134), + ("X12SA-ES-DET-ETTC-1020:TEMP", "ES", "temperature", "", 1.0, 0.155), + ("X12SA-ES-DET-ETTC-1010:TEMP", "ES", "temperature", "", 1.0, 0.1163), + ("X12SA-ES-DET-ECCW-1010:ALARM", "ES", "signal", "", 1.0, 0.1964), + ("X12SA-ES-DET-ECCW-1020:ALARM", "ES", "signal", "", 1.0, 0.2326), + ("X12SA-EH1-PSYS:SH-A-CLOSE", "EH1", "shutter", "", 0.8031, 0.6977), + ("X12SA-EH1-PSYS:SH-A-OPEN", "EH1", "shutter", "", 0.8031, 0.6977), + ("X12SA-EH1-PSYS:SH-A-OK", "EH1", "shutter", "", 0.8045, 0.4716), +] + +ZONES = ["FE", "OP", "ES", "EH1"] +ZONE_LABELS = {"FE": "Front End", "OP": "Optics Hutch", + "ES": "End Station", "EH1": "Experimental Hutch"} +ROLE_LABELS = { + "gauge": "Gauges", + "ion_pump": "Ion pumps", + "valve": "Valves", + "shutter": "Shutters", + "fast_valve": "Fast valves", + "temperature": "Temperatures", + "chamber": "Chambers", + "turbo": "Turbo pumps", + "permit": "Permits", + "relay": "Relays", + "flow": "Flow switches", + "signal": "Signals", +} + +def all_bulk_pvs(): + out, seen = [], set() + for pv, *_ in PANEL_ITEMS: + if pv not in seen: seen.add(pv); out.append(pv) + for pv, *_ in CRYO_PVS: + if pv not in seen: seen.add(pv); out.append(pv) + for pv, _ in MACHINE_PVS: + if pv not in seen: seen.add(pv); out.append(pv) + return out + +def all_pvs(): + out = [pv for pv, _, _ in HOT_PVS]; seen = set(out) + for pv in all_bulk_pvs(): + if pv not in seen: seen.add(pv); out.append(pv) + return out diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html new file mode 100644 index 0000000..7afdb60 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html @@ -0,0 +1,308 @@ + + + + + +cSAXS EPS & Machine Status + + + + +
+

cSAXS — EPS & Machine Status

+ loading… + → experiment status +
+ +
+ +

Machine Status

+
+ +

Operator Messages

+
+ +

Alarm Status

+
+ +

Mono Cryocooler

+
+ +

Beamline Synoptic

+ + +

Zone Detail

+
+ +

Recent History

+
+ +
+ + + + diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py new file mode 100644 index 0000000..fe04131 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py @@ -0,0 +1,1049 @@ +#!/usr/bin/env python3 +""" +EPS / SLS machine status generator for cSAXS (X12SA). + +Standalone daemon: reads EPICS PVs over Channel Access, writes +eps_status.json + eps_status.html, optionally uploads them, and serves the +output directory over local HTTP. + +No BEC imports. Runs inside a BEC session or as a plain script. + +Two tiers: + tier 1 "hot" ~15 s CA monitors (~26 PVs) - shutters, alarms, beam + tier 2 "bulk" ~5 min periodic caget (~575 PVs) - vacuum, temps, valves + +Usage: + python eps_status_generator.py # serve on :8082 + python eps_status_generator.py --verify # check every PV, exit + python eps_status_generator.py --demo # synthetic values + python eps_status_generator.py --upload-url https://omny.online/upload.php + +Embedding: + from eps_status_generator import EpsStatusGenerator + gen = EpsStatusGenerator(output_dir="~/eps_status") + gen.start() + ... + gen.stop() +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import math +import random +import shutil +import socket +import sys +import threading +import time +from pathlib import Path + +try: + # Package context (this subpackage lives at OMNY_shared/eps/). + from . import eps_pv_manifest as manifest +except ImportError: + # Standalone script use (e.g. `python eps_status_generator.py --verify`): + # no package context, so fall back to a plain same-directory import. + import eps_pv_manifest as manifest +try: + from .eps_synoptic_pvs import SYNOPTIC_PVS +except ImportError: + try: + from eps_synoptic_pvs import SYNOPTIC_PVS + except Exception: + SYNOPTIC_PVS = [] +try: + # Package context: web_common.py lives one level up, shared with the BEC + # webpage generator (see OMNY_shared/web_common.py). + from ..web_common import HttpUploader, LocalHttpServer, logger +except ImportError: + # Standalone script use (e.g. `python eps_status_generator.py --verify`): + # no package context, so fall back to sys.path. + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from web_common import HttpUploader, LocalHttpServer, logger + +try: + import epics + _EPICS_AVAILABLE = True +except Exception: + epics = None + _EPICS_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_HOT_INTERVAL_S = 15 +_BULK_INTERVAL_S = 300 +# One shared window for the whole bulk sweep to resolve CA searches, not a +# per-PV cost: channels are created in parallel and polled together. +# Unreachable PVs are unreachable immediately - a dead IOC does not answer +# slowly, it does not answer at all - so this can be short. +_CONNECT_TIMEOUT_S = 2.0 +_READ_TIMEOUT_S = 0.5 # get() on an already-connected channel is local +_CAGET_TIMEOUT_S = 2.0 # --verify only +_BULK_STALE_FACTOR = 2.5 # bulk data older than this * interval -> stale +_DEFAULT_PORT = 8082 # 8081 is _MISMATCH_LOCAL_PORT in the BEC generator + +_HISTORY_MAX = 50 # events persisted per stream +_HISTORY_SHOW = 5 # events surfaced to the page per stream +_TRACE_MAX = 288 # ring-current samples (24 h at 5 min) +_FLAP_COALESCE_S = 60 # re-alarm within this window extends the event + +_SEVERITY_NAMES = {0: "NO_ALARM", 1: "MINOR", 2: "MAJOR", 3: "INVALID"} + +_BEAMLINE_SUBNET_PREFIX = "129.129.122." # X12SA subnet, where EPS/machine PVs are reachable + + +def _on_beamline_network() -> bool: + """True if this host has an address in the X12SA subnet. + + A plain IP check, not an EPICS probe: it's instant and has no pyepics + dependency, so it can decide real-vs-demo before CaReader even tries to + talk to any PV. Checks both hostname-resolved addresses and the address + the OS would use to route outbound traffic (the latter needs no real + connectivity - connect() on a UDP socket just resolves a route, no + packet is sent - so it works even fully offline as long as a default + route exists). + """ + addrs = set() + try: + addrs.update(ai[4][0] for ai in socket.getaddrinfo(socket.gethostname(), None)) + except socket.gaierror: + pass + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + addrs.add(s.getsockname()[0]) + except OSError: + pass + return any(a.startswith(_BEAMLINE_SUBNET_PREFIX) for a in addrs) + + +def _now_iso() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + + +def _epoch() -> float: + return time.time() + + +def _sev_name(sev) -> str: + if sev is None: + return "UNKNOWN" + try: + return _SEVERITY_NAMES.get(int(sev), "UNKNOWN") + except (TypeError, ValueError): + return "UNKNOWN" + + +def _clean(value): + """Make a CA value JSON-safe. Byte arrays -> str, NaN/inf -> None.""" + if value is None: + return None + if isinstance(value, bytes): + return value.decode("latin-1", "replace").rstrip("\x00").strip() + if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): + return None + if hasattr(value, "tolist"): # numpy array/scalar + try: + value = value.tolist() + except Exception: + return str(value) + if isinstance(value, (list, tuple)): + # char waveforms come back as int lists; render as text + if value and all(isinstance(x, int) and 0 <= x < 256 for x in value): + txt = bytes(value).decode("latin-1", "replace") + return txt.rstrip("\x00").strip() + return [_clean(v) for v in value] + return value + + +# --------------------------------------------------------------------------- +# History tracking +# --------------------------------------------------------------------------- + +class HistoryTracker: + """ + Persists two event streams plus a ring-current trace. + + eps_events opened when eps_alarm_count goes 0 -> non-zero, + closed when it returns to 0. Re-alarms within + _FLAP_COALESCE_S with identical text extend the open + event instead of creating a new one. + machine_events verbatim transitions of HISTORY_PVS: no inference, + just "this PV said X, now it says Y, at this time". + """ + + def __init__(self, path: Path): + self._path = Path(path) + self.eps_events: list[dict] = [] + self.machine_events: list[dict] = [] + self.ring_trace: list[dict] = [] + self._last: dict[str, object] = {} + self._lock = threading.Lock() + self._load() + + def _load(self) -> None: + if not self._path.exists(): + return + try: + data = json.loads(self._path.read_text()) + self.eps_events = data.get("eps_events", [])[-_HISTORY_MAX:] + self.machine_events = data.get("machine_events", [])[-_HISTORY_MAX:] + self.ring_trace = data.get("ring_current_trace", [])[-_TRACE_MAX:] + logger.info(f"HistoryTracker: loaded {len(self.eps_events)} EPS + " + f"{len(self.machine_events)} machine events") + except Exception as exc: + logger.warning(f"HistoryTracker: cannot read {self._path}: {exc}") + + def save(self) -> None: + with self._lock: + payload = { + "eps_events": self.eps_events[-_HISTORY_MAX:], + "machine_events": self.machine_events[-_HISTORY_MAX:], + "ring_current_trace": self.ring_trace[-_TRACE_MAX:], + } + try: + tmp = self._path.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, indent=1, default=str)) + tmp.replace(self._path) + except Exception as exc: + logger.warning(f"HistoryTracker: cannot write {self._path}: {exc}") + + # -- EPS alarms --------------------------------------------------------- + + def note_eps_alarm(self, count, text) -> None: + if count is None: + return + try: + count = int(count) + except (TypeError, ValueError): + return + text = (text or "").strip() + now = _epoch() + + with self._lock: + open_ev = None + if self.eps_events and self.eps_events[-1].get("cleared_at") is None: + open_ev = self.eps_events[-1] + + if count > 0: + if open_ev is not None: + open_ev["count"] = max(open_ev.get("count", 0), count) + if text and text not in open_ev.get("texts", []): + open_ev.setdefault("texts", []).append(text) + return + # coalesce a rapid re-alarm with identical text + if self.eps_events: + prev = self.eps_events[-1] + if (prev.get("cleared_at") + and now - prev["cleared_at"] < _FLAP_COALESCE_S + and text in prev.get("texts", [])): + prev["cleared_at"] = None + prev["flapping"] = True + return + self.eps_events.append({ + "first_seen": now, + "first_seen_iso": _now_iso(), + "cleared_at": None, + "count": count, + "texts": [text] if text else [], + }) + else: + if open_ev is not None: + open_ev["cleared_at"] = now + open_ev["cleared_at_iso"] = _now_iso() + + # -- machine transitions ------------------------------------------------ + + def note_value(self, pv: str, label: str, value) -> None: + """Record a transition if this PV's value changed since last seen.""" + value = _clean(value) + if value is None: + return + with self._lock: + prev = self._last.get(pv, "__unset__") + if prev == "__unset__": + self._last[pv] = value + return + if prev == value: + return + self._last[pv] = value + self.machine_events.append({ + "pv": pv, + "label": label, + "from": prev, + "to": value, + "at": _epoch(), + "at_iso": _now_iso(), + }) + del self.machine_events[:-_HISTORY_MAX] + + def note_ring_current(self, value) -> None: + value = _clean(value) + if not isinstance(value, (int, float)): + return + with self._lock: + self.ring_trace.append({"t": _epoch(), "v": round(float(value), 2)}) + del self.ring_trace[:-_TRACE_MAX] + + def snapshot(self) -> dict: + with self._lock: + return { + "eps_events": list(self.eps_events[-_HISTORY_SHOW:]), + "machine_events": list(self.machine_events[-_HISTORY_SHOW:]), + "ring_current_trace": list(self.ring_trace), + } + + +# --------------------------------------------------------------------------- +# EPICS access layer +# --------------------------------------------------------------------------- + +class CaReader: + """ + Channel Access wrapper. + + Real mode uses pyepics; monitors for tier 1, caget for tier 2. + Demo mode synthesises plausible values so the frontend can be developed + off-network. Demo mode is entered explicitly (--demo), automatically when + off the X12SA subnet (demo=None, the default - see _on_beamline_network), + or when pyepics is unavailable. It is never entered just because an + individual PV fails to respond: a PV unreachable on the beamline network + itself reports disconnected, it does not silently become fake data. + """ + + def __init__(self, demo: bool | None = None): + auto = demo is None + if auto: + demo = not _on_beamline_network() + self.demo = demo or not _EPICS_AVAILABLE + if demo and not _EPICS_AVAILABLE: + logger.info("CaReader: demo mode (pyepics not available)") + elif demo and auto: + logger.info("CaReader: demo mode (auto - not on the X12SA subnet)") + elif demo: + logger.info("CaReader: demo mode (requested)") + elif auto: + logger.info("CaReader: real mode (auto - on the X12SA subnet)") + elif not _EPICS_AVAILABLE: + logger.error("CaReader: pyepics not available and --demo not given; " + "all PVs will report disconnected") + self._monitors: dict[str, object] = {} + self._hot: dict[str, dict] = {} + self._lock = threading.Lock() + self._egu: dict[str, str] = {} + self._pv_cache: dict[str, object] = {} # persistent channels for bulk + self._demo_t0 = _epoch() + self._closing = False # gate callbacks during shutdown + + # -- demo synthesis ----------------------------------------------------- + + def _demo_value(self, pv: str): + t = _epoch() - self._demo_t0 + if "AlarmCnt" in pv: + return 0 + if "AlarmList" in pv: + return "" + if "WATCHDOG" in pv or "Watchdog" in pv: + return int(t) % 100 + if "CURRENT" in pv: + return 401.0 + 0.8 * math.sin(t / 40.0) + if "LIFETIME" in pv: + return 8.2 + 0.3 * math.sin(t / 90.0) + if "LIGHT-DELIVERY" in pv: + return "Beam delivery" + if "STATUS-X12SA" in pv: + return "Beamline operational" + if pv.endswith(":SYSTEM") or ":SYSTEM" in pv: + return "OK" + if "GAP-RBV" in pv: + return 6.21 + if "PLC_OPEN" in pv or "-OPEN" in pv or ":OPEN" in pv: + return 1 + if "PLC_CLOSE" in pv or "-CLOSE" in pv or ":CLOSE" in pv: + return 0 + if "-OK" in pv or ":OK" in pv or "ENABLE" in pv or "OPERATE" in pv: + return 1 + if "PRESS" in pv or "PRESSURE" in pv: + return 10 ** (-9 + 0.4 * math.sin(t / 30.0 + len(pv))) + if "TEMP" in pv or ":TC" in pv: + return 24.0 + 2.0 * math.sin(t / 50.0 + len(pv) % 7) + if "VOLTAGE" in pv: + return 5200.0 + 30 * math.sin(t / 25.0) + if "FLOW" in pv: + return 1 + if "PLC_STATUS" in pv: + return random.choice([1, 1, 1, 1, 0]) + if "ERROR" in pv: + return 0 + if "SPEED" in pv: + return 1000.0 + return 1 + + # -- tier 1: monitors --------------------------------------------------- + + def start_monitors(self, pvnames, on_change=None) -> None: + """Subscribe to PVs. on_change(pv, value, severity) fires per update.""" + if self.demo: + return + if not _EPICS_AVAILABLE: + return + + def _make_cb(pvname): + want_str = pvname in manifest.STRING_PVS + + def _cb(pvname=None, value=None, severity=None, char_value=None, **kw): + # Ignore events that arrive while we are tearing down: the + # generator's history/JSON objects may already be gone. + if self._closing: + return + # Enum/char-waveform PVs: prefer the string form pyepics + # supplies with the callback, so no blocking .get() is needed. + if want_str and char_value is not None: + val = _clean(char_value) + else: + val = _clean(value) + with self._lock: + self._hot[pvname] = { + "value": val, + "severity": _sev_name(severity), + "at": _epoch(), + "connected": True, + } + if on_change: + try: + on_change(pvname, val, severity) + except Exception as exc: + logger.warning(f"monitor callback error for {pvname}: {exc}") + return _cb + + # Creating the PV objects only issues the CA searches; it does not + # wait for them. Do NOT call .get() here - that blocks per PV for the + # connection timeout and makes start() take timeout * len(pvnames) + # when the IOCs are unreachable. auto_monitor delivers the first + # value through the callback as soon as the channel connects. + for pv in pvnames: + if pv in self._monitors: + continue + try: + self._monitors[pv] = epics.PV( + pv, callback=_make_cb(pv), + form="native", auto_monitor=True, + connection_timeout=_CONNECT_TIMEOUT_S, + ) + except Exception as exc: + logger.warning(f"cannot monitor {pv}: {exc}") + + def hot_snapshot(self, pvnames) -> dict: + """Current value of every tier-1 PV.""" + out = {} + if self.demo: + for pv in pvnames: + out[pv] = {"value": _clean(self._demo_value(pv)), + "severity": "NO_ALARM", "connected": True, + "at": _epoch(), "demo": True} + return out + with self._lock: + for pv in pvnames: + mon = self._monitors.get(pv) + if pv in self._hot: + out[pv] = dict(self._hot[pv]) + if mon is not None and not mon.connected: + out[pv]["connected"] = False + else: + out[pv] = {"value": None, "severity": "UNKNOWN", + "connected": False, "at": None} + return out + + # -- tier 2: bulk caget ------------------------------------------------- + + def bulk_read(self, pvnames) -> dict: + out = {} + if self.demo: + for pv in pvnames: + out[pv] = {"value": _clean(self._demo_value(pv)), + "severity": "NO_ALARM", "connected": True, + "egu": self._egu.get(pv), "demo": True} + return out + if not _EPICS_AVAILABLE: + for pv in pvnames: + out[pv] = {"value": None, "severity": "UNKNOWN", + "connected": False, "egu": None} + return out + + # Channel Access is inherently asynchronous: creating all the PV + # objects issues all the search requests in parallel, and the CA + # library handles the responses on its own thread. So the cost of an + # unreachable PV is paid ONCE for the whole batch, not once per PV. + # + # Doing this serially (create -> get -> block for timeout -> next) + # costs len(pvnames) * timeout when the IOCs are unreachable, which + # for ~576 PVs is tens of minutes and stalls the write loop entirely. + t0 = _epoch() + + # 1. Create/reuse every channel first, without waiting for any of them. + chans = {} + for pv in pvnames: + chan = self._pv_cache.get(pv) + if chan is None: + try: + chan = epics.PV(pv, auto_monitor=False, + connection_timeout=_CONNECT_TIMEOUT_S) + self._pv_cache[pv] = chan + except Exception as exc: + logger.debug(f"cannot create channel {pv}: {exc}") + chan = None + chans[pv] = chan + + # 2. Give the searches one shared window to resolve, then poll CA so + # responses are processed. Already-connected channels (cached from + # a previous sweep) make this a no-op. + deadline = _epoch() + _CONNECT_TIMEOUT_S + while _epoch() < deadline: + if all(c is None or c.connected for c in chans.values()): + break + try: + epics.ca.poll(evt=0.01, iot=0.01) + except Exception: + break + time.sleep(0.02) + + # 3. Read the connected ones. get() on a connected channel returns + # from the local cache and does not block. + n_dead = 0 + for pv, chan in chans.items(): + if chan is None or not chan.connected: + n_dead += 1 + out[pv] = {"value": None, "severity": "UNKNOWN", + "connected": False, "egu": None} + continue + try: + val = chan.get(timeout=_READ_TIMEOUT_S, + as_string=(pv in manifest.STRING_PVS)) + except Exception as exc: + logger.debug(f"bulk read failed for {pv}: {exc}") + val = None + if val is None: + n_dead += 1 + out[pv] = {"value": None, "severity": "UNKNOWN", + "connected": False, "egu": None} + continue + if pv not in self._egu: + try: + self._egu[pv] = chan.units or "" + except Exception: + self._egu[pv] = "" + out[pv] = { + "value": _clean(val), + "severity": _sev_name(getattr(chan, "severity", None)), + "connected": True, + "egu": self._egu.get(pv) or None, + } + + logger.info(f"bulk sweep: {len(pvnames) - n_dead}/{len(pvnames)} " + f"connected in {_epoch() - t0:.1f}s") + return out + + def stop(self) -> None: + """ + Release CA resources without crashing on exit. + + Do NOT call PV.disconnect() here. pyepics registers its own atexit + handler that runs ca.finalize_libca(); calling disconnect() manually + and then letting that handler run tears the same channels down twice. + libca's background thread can still be delivering monitor events into + structures that have already been freed, which segfaults in native + code where a Python try/except cannot help. That is exactly the + "Segmentation fault (core dumped)" seen after "generator stopped". + + Clearing the callbacks stops any further Python code from being + invoked; dropping the references lets pyepics finalize everything in + the correct order at interpreter exit. + """ + self._closing = True + for _pv, mon in list(self._monitors.items()): + try: + mon.clear_callbacks() + except Exception: + pass + self._monitors.clear() + self._pv_cache.clear() + # Give libca a moment to drain any in-flight callbacks before the + # interpreter starts tearing down. + if _EPICS_AVAILABLE and not self.demo: + try: + epics.ca.poll(evt=0.01, iot=0.01) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Generator +# --------------------------------------------------------------------------- + +class EpsStatusGenerator: + """Polls EPICS, writes eps_status.json/html, serves and optionally uploads.""" + + def __init__( + self, + output_dir: str | Path = "~/eps_status", + port: int = _DEFAULT_PORT, + hot_interval: float = _HOT_INTERVAL_S, + bulk_interval: float = _BULK_INTERVAL_S, + upload_url: str | None = None, + demo: bool | None = None, + serve: bool = True, + ): + self._output_dir = Path(output_dir).expanduser() + self._output_dir.mkdir(parents=True, exist_ok=True) + self._port = port + self._hot_interval = hot_interval + self._bulk_interval = bulk_interval + + # CaReader resolves demo=None (auto-detect) to a concrete bool; read + # the resolved value back rather than the raw (possibly None) arg, + # since self._demo is used later for the JSON payload and logging. + self._reader = CaReader(demo=demo) + self._demo = self._reader.demo + self._history = HistoryTracker(self._output_dir / "eps_alarm_history.json") + self._uploader = HttpUploader(upload_url) if upload_url else None + self._server = (LocalHttpServer(self._output_dir, port, + default_page="eps_status.html") + if serve else None) + + self._hot_pvs = [pv for pv, _, _ in manifest.HOT_PVS] + bulk = manifest.all_bulk_pvs() + _seen = set(bulk) + for _pv in SYNOPTIC_PVS: + if _pv not in _seen and _pv.split(".")[0] not in _seen: + _seen.add(_pv); bulk.append(_pv) + self._bulk_pvs = bulk + self._history_labels = dict(manifest.HISTORY_PVS) + + self._bulk_cache: dict = {} + self._bulk_at: float | None = None + self._last_trace_at: float = 0.0 + self._stop = threading.Event() + self._thread = None + self._bulk_thread = None + + # -- lifecycle ---------------------------------------------------------- + + def start(self) -> None: + self._install_frontend() + self._reader.start_monitors(self._hot_pvs, on_change=self._on_hot_change) + if self._server: + self._server.start() + self._stop.clear() + + # Write one page immediately so the browser has something valid to + # load before the first bulk sweep completes (which may take seconds, + # or never complete if the EPICS network is unreachable). + try: + self._cycle() + except Exception as exc: + logger.warning(f"initial cycle failed: {exc}") + + self._thread = threading.Thread(target=self._run, name="EpsStatusGen", + daemon=True) + self._thread.start() + self._bulk_thread = threading.Thread(target=self._run_bulk, + name="EpsStatusBulk", daemon=True) + self._bulk_thread.start() + logger.info(f"EPS status generator started, output {self._output_dir}") + if self._server: + logger.info(f" serving {self._server.url}") + if self._demo: + logger.warning(" DEMO MODE - all values are synthetic") + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=5) + if self._bulk_thread: + self._bulk_thread.join(timeout=_CONNECT_TIMEOUT_S + 3) + if self._server: + self._server.stop() + self._reader.stop() + self._history.save() + logger.info("EPS status generator stopped") + + def _install_frontend(self) -> None: + """Copy eps_status.html next to the JSON it reads.""" + src = Path(__file__).parent / "eps_status.html" + dst = self._output_dir / "eps_status.html" + try: + if src.exists(): + shutil.copyfile(src, dst) + except Exception as exc: + logger.warning(f"cannot install frontend: {exc}") + # synoptic page + its prebuilt SVG + for name in ("eps_synoptic.html", "eps_synoptic.svg"): + fsrc = Path(__file__).parent / name + try: + if fsrc.exists(): + shutil.copyfile(fsrc, self._output_dir / name) + except Exception as exc: + logger.warning(f"cannot install {name}: {exc}") + + # -- callbacks ---------------------------------------------------------- + + def _on_hot_change(self, pv, value, severity) -> None: + if pv in self._history_labels: + self._history.note_value(pv, self._history_labels[pv], value) + + # -- main loop ---------------------------------------------------------- + + def _run(self) -> None: + """Write loop. Never performs a bulk sweep itself, so a slow or + entirely unreachable EPICS network can never stall page updates.""" + while not self._stop.is_set(): + try: + self._cycle() + except Exception as exc: + logger.warning(f"EPS cycle error: {exc}") + self._stop.wait(self._hot_interval) + + def _run_bulk(self) -> None: + """Bulk sweep loop, on its own thread.""" + while not self._stop.is_set(): + try: + cache = self._reader.bulk_read(self._bulk_pvs) + self._bulk_cache = cache # atomic rebind + self._bulk_at = _epoch() + except Exception as exc: + logger.warning(f"EPS bulk sweep error: {exc}") + self._stop.wait(self._bulk_interval) + + def _cycle(self) -> None: + hot = self._reader.hot_snapshot(self._hot_pvs) + + # demo mode has no monitors, so transitions are recorded here instead + if self._demo: + for pv, label in manifest.HISTORY_PVS: + if pv in hot: + self._history.note_value(pv, label, hot[pv]["value"]) + + alarm_cnt = hot.get("X12SA-EPS-PLC:AlarmCnt_EPS", {}).get("value") + alarm_txt = hot.get("X12SA-EPS-PLC:AlarmList_EPS", {}).get("value") + self._history.note_eps_alarm(alarm_cnt, alarm_txt) + + # Ring current is a tier-1 PV (monitored, not in the bulk sweep), but + # the trace is only sampled at the bulk cadence so _TRACE_MAX points + # still span ~24 h rather than ~1 h. + now = _epoch() + if now - self._last_trace_at >= self._bulk_interval: + rc = hot.get("AGEBD-PARAMS:CURRENT", {}) + if rc.get("value") is not None: + self._history.note_ring_current(rc["value"]) + self._last_trace_at = now + + payload = self._build_payload(hot) + (self._output_dir / "eps_status.json").write_text( + json.dumps(payload, indent=2, default=str)) + self._history.save() + + if self._uploader: + self._uploader.upload_changed_async(self._output_dir) + + # -- payload ------------------------------------------------------------ + + def _build_payload(self, hot: dict) -> dict: + def hv(pv): + return hot.get(pv, {}).get("value") + + def entry(pv, label=None): + rec = hot.get(pv) or self._bulk_cache.get(pv) or {} + return { + "pv": pv, + "label": label, + "value": rec.get("value"), + "egu": rec.get("egu"), + "severity": rec.get("severity", "UNKNOWN"), + "connected": rec.get("connected", False), + } + + alarm_cnt = hv("X12SA-EPS-PLC:AlarmCnt_EPS") + try: + alarm_cnt_i = int(alarm_cnt) if alarm_cnt is not None else None + except (TypeError, ValueError): + alarm_cnt_i = None + + if alarm_cnt_i is None: + eps_state = "unknown" + elif alarm_cnt_i == 0: + eps_state = "ok" + else: + eps_state = "alarm" + + light = hv("AGEOP-STATUS:LIGHT-DELIVERY") + light_s = str(light).lower() if light is not None else "" + if not light_s: + machine_state = "unknown" + elif "deliver" in light_s or "beam" in light_s and "no" not in light_s: + machine_state = "beam" + elif "inject" in light_s: + machine_state = "injection" + else: + machine_state = "no_beam" + + shutters = {} + for zone, pfx in (("OP", "X12SA-OP-PSYS"), ("EH1", "X12SA-EH1-PSYS")): + shutters[zone] = { + "a_open": hv(f"{pfx}:SH-A-OPEN"), + "a_closed": hv(f"{pfx}:SH-A-CLOSE"), + "a_ok": hv(f"{pfx}:SH-A-OK"), + "collective_alarm": hv(f"{pfx}:ALARM-COLLECTIVE"), + } + shutters["OP"].update({ + "b_open": hv("X12SA-OP-PSYS:SH-B-OPEN"), + "b_closed": hv("X12SA-OP-PSYS:SH-B-CLOSE"), + "b_ok": hv("X12SA-OP-PSYS:SH-B-OK"), + }) + shutters["FE"] = { + "a_open": hv("X12SA-FE-PH1:OPEN_EPS"), + "a_closed": hv("X12SA-FE-PH1:CLOSE_EPS"), + "a_ok": None, + "collective_alarm": None, + } + + bulk_age = None if self._bulk_at is None else _epoch() - self._bulk_at + bulk_stale = (bulk_age is None + or bulk_age > self._bulk_interval * _BULK_STALE_FACTOR) + + zones = {z: [] for z in manifest.ZONES} + synoptic = [] + for pv, zone, role, desc, xf, yf in manifest.PANEL_ITEMS: + rec = {**entry(pv, desc or None), "role": role, + "xf": xf, "yf": yf, "zone": zone} + if zone in zones: + zones[zone].append(rec) + synoptic.append({ + "pv": pv, "role": role, "zone": zone, "xf": xf, "yf": yf, + "value": rec["value"], "egu": rec["egu"], + "severity": rec["severity"], "connected": rec["connected"], + "desc": desc, + }) + + cryo = {} + for pv, role, _label in manifest.CRYO_PVS: + cryo[role] = entry(pv) + + # Every PV the synoptic SVG references (valve PLC_OPEN/PLC_CLOSE, + # setpoints, cooling calc channels) must be exposed so the frontend's + # visibility rules can see them. PANEL_ITEMS alone omits the open/close + # channels, which left valves stuck showing the transition stem. + synoptic_values = {} + for pv in SYNOPTIC_PVS: + rec = hot.get(pv) or self._bulk_cache.get(pv) or {} + synoptic_values[pv] = { + "value": rec.get("value"), + "severity": rec.get("severity", "UNKNOWN"), + "connected": rec.get("connected", False), + } + + machine = {k: entry(pv) for pv, k in [ + ("AGEBD-PARAMS:CURRENT", "ring_current"), + ("AGEBD-PARAMS:LIFETIME", "lifetime"), + ("AGEBD-PARAMS:INJECTION-RATE", "injection_rate"), + ("AGEBD-PARAMS:TRANSMISSION", "transmission"), + ("ARSGE-CECL-FOFB1:B-ERR-X-RMS-UM", "orbit_rms_x"), + ("ARSGE-CECL-FOFB1:B-ERR-Y-RMS-UM", "orbit_rms_y"), + ("ARIVA-VMAVE:PRESS-MAX", "ring_pressure"), + ("ARSGE-CECL-FOFB1:FOFB-OPERATE", "fofb"), + ]} + machine["light_delivery"] = entry("AGEOP-STATUS:LIGHT-DELIVERY") + machine["bl_status"] = entry("AGEOP-BL:STATUS-X12SA") + machine["undulator_gap"] = entry(manifest.UNDULATOR_GAP_PV) + machine["shift_type"] = entry("AGEOP-STATUS:DUO-SHIFT-TYPE") + + messages = [] + for msg_pv, date_pv in manifest.OPMSG_PVS: + txt = (self._bulk_cache.get(msg_pv) or {}).get("value") + dat = (self._bulk_cache.get(date_pv) or {}).get("value") + if txt and str(txt).strip(): + messages.append({"date": dat, "text": str(txt).strip()}) + + all_recs = list(hot.values()) + list(self._bulk_cache.values()) + unreachable = sum(1 for r in all_recs if not r.get("connected")) + + return { + "generated_at": _now_iso(), + "generated_at_epoch": _epoch(), + "demo": self._demo, + "eps_status": eps_state, + "machine_status": machine_state, + "hot": { + "updated_at_epoch": _epoch(), + "eps_alarm_count": alarm_cnt_i, + "eps_alarm_list": hv("X12SA-EPS-PLC:AlarmList_EPS"), + "fe_alarm_count": hv("ARS00-MIS-PLC-01:AlarmCnt_Frontends"), + "fe_alarm_list": hv("ARS00-MIS-PLC-01:AlarmList_Frontends"), + "eps_permit": hv("X12SA-EPS-PH:SYSTEM"), + "shutters": shutters, + "ring_current": entry("AGEBD-PARAMS:CURRENT"), + "lifetime": entry("AGEBD-PARAMS:LIFETIME"), + "light_delivery": entry("AGEOP-STATUS:LIGHT-DELIVERY"), + "undulator_gap": entry(manifest.UNDULATOR_GAP_PV), + "bl_status": entry("AGEOP-BL:STATUS-X12SA"), + "fofb": entry("ARSGE-CECL-FOFB1:FOFB-OPERATE"), + }, + "bulk": { + "updated_at_epoch": self._bulk_at, + "age_s": None if bulk_age is None else round(bulk_age, 1), + "stale": bulk_stale, + "interval_s": self._bulk_interval, + "zones": zones, + "synoptic": synoptic, + "synoptic_values": synoptic_values, + "cryo": cryo, + "machine": machine, + "messages": messages, + }, + "history": self._history.snapshot(), + "generator": { + "host": __import__("socket").gethostname(), + "hot_interval_s": self._hot_interval, + "bulk_interval_s": self._bulk_interval, + "pv_total": len(self._hot_pvs) + len(self._bulk_pvs), + "pv_unreachable": unreachable, + "epics_available": _EPICS_AVAILABLE, + }, + "zone_labels": manifest.ZONE_LABELS, + "role_labels": manifest.ROLE_LABELS, + } + + +# --------------------------------------------------------------------------- +# --verify +# --------------------------------------------------------------------------- + +def verify(timeout: float = _CAGET_TIMEOUT_S) -> int: + """Read every PV once and report reachability. Returns the failure count.""" + if not _EPICS_AVAILABLE: + print("pyepics not available - cannot verify.", file=sys.stderr) + return -1 + + groups = [ + ("TIER 1 hot", [pv for pv, _, _ in manifest.HOT_PVS]), + ("PANEL (EPS graphic curated channels)", + [p for p, _, _, _, _, _ in manifest.PANEL_ITEMS]), + ("CRYO (XDS cryocooler)", [p for p, _, _ in manifest.CRYO_PVS]), + ("MACHINE", [p for p, _ in manifest.MACHINE_PVS]), + ] + + total_bad = 0 + seen: set[str] = set() + for title, pvs in groups: + pvs = [p for p in pvs if not (p in seen or seen.add(p))] + print(f"\n=== {title}: {len(pvs)} PVs ===") + bad = [] + # Same concurrent pattern as bulk_read: create all channels, let the + # searches resolve in one shared window, then read. Serial creation + # would cost len(pvs) * timeout when IOCs are unreachable. + chans = {} + for pv in pvs: + try: + chans[pv] = epics.PV(pv, auto_monitor=False, + connection_timeout=timeout) + except Exception: + chans[pv] = None + deadline = _epoch() + timeout + while _epoch() < deadline: + if all(c is None or c.connected for c in chans.values()): + break + try: + epics.ca.poll(evt=0.01, iot=0.01) + except Exception: + break + time.sleep(0.02) + for pv, chan in chans.items(): + val = None + if chan is not None and chan.connected: + try: + val = chan.get(timeout=_READ_TIMEOUT_S, + as_string=(pv in manifest.STRING_PVS)) + except Exception: + val = None + if val is None: + bad.append(pv) + else: + shown = str(val)[:44].replace("\n", " ") + print(f" ok {pv:46s} = {shown}") + chans.clear() + for pv in bad: + print(f" FAIL {pv}") + print(f" --> {len(pvs) - len(bad)}/{len(pvs)} reachable") + total_bad += len(bad) + + print(f"\n{'=' * 60}") + print(f"TOTAL UNREACHABLE: {total_bad}") + if total_bad: + print("Unreachable PVs are either wrong names or genuinely offline IOCs.") + print("Unreachable PVs are either offline IOCs or wrong names. During") + print("a machine shutdown, FE/photon-shutter PVs are expected offline.") + print("Chamber labels are no longer emitted as PVs (fixed in iter 8).") + return total_bad + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> int: + ap = argparse.ArgumentParser( + description="EPS / SLS machine status page generator (cSAXS X12SA)") + ap.add_argument("--output-dir", default="~/eps_status") + ap.add_argument("--port", type=int, default=_DEFAULT_PORT, + help=f"local HTTP port (default {_DEFAULT_PORT}; " + "8081 is reserved by the BEC generator)") + ap.add_argument("--hot-interval", type=float, default=_HOT_INTERVAL_S) + ap.add_argument("--bulk-interval", type=float, default=_BULK_INTERVAL_S) + ap.add_argument("--upload-url", default=None, + help="upload.php endpoint; uploads are off unless given") + ap.add_argument("--demo", action="store_true", + help="synthetic values, forced on (default: auto-detected " + "from whether this host is on the X12SA subnet)") + ap.add_argument("--no-serve", action="store_true") + ap.add_argument("--verify", action="store_true", + help="read every PV once, report reachability, exit") + args = ap.parse_args() + + if args.verify: + rc = verify() + return 0 if rc == 0 else 1 + + gen = EpsStatusGenerator( + output_dir=args.output_dir, + port=args.port, + hot_interval=args.hot_interval, + bulk_interval=args.bulk_interval, + upload_url=args.upload_url, + demo=(True if args.demo else None), + serve=not args.no_serve, + ) + gen.start() + print(f"\nEPS status generator running. Ctrl-C to stop.") + if not args.no_serve: + print(f" http://localhost:{args.port}/eps_status.html") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nstopping...") + try: + gen.stop() + except KeyboardInterrupt: + # A second Ctrl-C during shutdown must not leave CA half torn + # down; swallow it and let atexit finalize cleanly. + print("forced stop") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.html b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.html new file mode 100644 index 0000000..4d1cd22 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.html @@ -0,0 +1,219 @@ + + + + + +cSAXS EPS Synoptic + + + +
+

cSAXS EPS Synoptic

+ loading… + ← status overview +
+ +
+
+
+
drag to pan · scroll or pinch to zoom · hover a component for its PV
+ + + + diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.svg b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.svg new file mode 100644 index 0000000..a928723 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic.svg @@ -0,0 +1,1442 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Front-End +Air Pressure: +FE-VG0 +OP-VG1 +PSH +XBPM1 +Diaphragm +PSH Pressure: +PSH +FV +FE-VG1 +BST Pressure: +BST +Slits +FE-VG2 +Optics +Air Pressure: +Slits +W Blocker 1 +OP-VG2 +OP-VG3 +OP-VG4 +W Blocker 2 +Slits +OP-VG5 +FV +OP-VG6 +PSH +OP-VG7 +End-Station +ES-VG1 +Supply +Return +-SL1- +-SL1- +-EB1- +-SL2- +-KB- +-PSH- +-EB2- +Temp. 1 +Temp. 2 +Alarm Chiller 1 +Alarm Chiller 2 +Pump Alarm +Pump Alarm +-XBPM- +-PSH- +-SL1- +-DIA- +-SL1- +PSH Pressure: +PSYS Switch: +CLOSED +OPEN +OPEN +CLOSED +PSYS Switch: +PSYS Switch +CLOSED +OPEN +FV Sensor +FV Sensor +FV Sensor +OP-VG3-M +FV Sensor +IKR +IKR +Pumpstand3 +Setpoint +Pumpstand3 +RingAbsorber2 +Setpoint +RingAbsorber2 +IKR +IKR +TPR +TPR +Pumpstand1 +Setpoint +Pumpstand1 +TPR +TPR +PhotonShutterFE +Setpoint +PhotonShutterFE +TPR +TPR +IKR +IKR +BeamStopper +Setpoint +BeamStopper +TPR +TPR +IKR +IKR +Pumpstand2 +Setpoint +Pumpstand2 +Pumpstand1 +Setpoint +Pumpstand1 +IKR +IKR +PCR +PCR +Pumpstand2 +Setpoint +Pumpstand2 +Pumpstand2 +Pumpstand2 +Pumpstand3 +Pumpstand3 +Pumpstand3 +Setpoint +Pumpstand3 +DMM +DMM +DMM +DMM +Setpoint +DMM +CCM +CCM +CCM +Setpoint +CCM +CCM +Pumpstand4 +Pumpstand4 +Pumpstand4 +Setpoint +Pumpstand4 +Exp-Box1 +Exp-Box1 +Setpoint +Exp-Box1 +Exp-Box1 +Setpoint +Exp-Box1 +80% +Turbo Pump +TP +X12SA-OP-EB1-VPTM-5010 +Pump Station +Turbo Pump +PrePump +PrePump +Pumpstand5 +Setpoint +Pumpstand5 +Pumpstand5 +Pumpstand5 +KB +KB-mirror +Setpoint +KB-mirror +KB-mirror +KB-mirror +KB-mirror +Setpoint +KB-mirror +PhotonShutterOP +Setpoint +PhotonShutterOP +Ph.ShutterOP +Ph.ShutterOP +Exp-Box2 +Setpoint +Exp-Box2 +Exp-Box2 +Exp-Box2 +Exp-Box2 +80% +Turbo Pump +TP +X12SA-ES-EB3-VPTM-2010 +Pump Station +Turbo Pump +Exp-Box3 +Exp-Box3 +Exp-Box3 +Prepump +Prepump +EB3prevac +EB3prevac +FlightTube +FlightTube +Exp-Box1 +Exp-Box1 +Pumpst.3 +Pumpst.4 +FastValve Closed +FastValve Closed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + +- + + + \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_pvs.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_pvs.py new file mode 100644 index 0000000..cce95c2 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_pvs.py @@ -0,0 +1,346 @@ +"""PVs referenced by the synoptic SVG (auto-generated).""" + +SYNOPTIC_PVS = [ + "ARS12-VCVS-1000:ENA", + "X12SA-EH1-PSYS:SH-A-CLOSE", + "X12SA-EH1-PSYS:SH-A-OK", + "X12SA-EH1-PSYS:SH-A-OPEN", + "X12SA-ES-DET-CHIL1:DISABLE", + "X12SA-ES-DET-CHIL2:DISABLE", + "X12SA-ES-DET-ECCW-1010:ALARM", + "X12SA-ES-DET-ECCW-1020:ALARM", + "X12SA-ES-DET-ETTC-1010:TEMP", + "X12SA-ES-DET-ETTC-1020:TEMP", + "X12SA-ES-EB2-VMFR-1010:PRESSURE", + "X12SA-ES-EB2-VMFR-1010:STATUS", + "X12SA-ES-EB2-VPIG-1010:ERROR", + "X12SA-ES-EB2-VPIG-1010:HV", + "X12SA-ES-EB2-VPIG-1010:PRESS-DISP", + "X12SA-ES-EB2-VPIG-1010:SETPOINT", + "X12SA-ES-EB2-VPIG-1010:SETPOINT-STATUS", + "X12SA-ES-EB2-VPIG-1010:VOLTAGE", + "X12SA-ES-EB3-VMCP-2010:PRESSURE", + "X12SA-ES-EB3-VMCP-2010:STATUS", + "X12SA-ES-EB3-VMCP-2020:PRESSURE", + "X12SA-ES-EB3-VMCP-2020:STATUS", + "X12SA-ES-EB3-VMFR-2010:PRESSURE", + "X12SA-ES-EB3-VMFR-2010:STATUS", + "X12SA-ES-EB3-VPRO-2010:PLC_RUN", + "X12SA-ES-EB3-VPTM-2010:PLC_PUMP-RBV", + "X12SA-ES-EB3-VPTM-2010:PLC_TURBO-RBV", + "X12SA-ES-EB3-VVPG-2010:PLC_CLOSE", + "X12SA-ES-EB3-VVPG-2010:PLC_ERROR", + "X12SA-ES-EB3-VVPG-2010:PLC_OPEN", + "X12SA-ES-EB3-VVPG-2010:PLC_STATUS", + "X12SA-ES-EB3-VVPP-2010:PLC_CLOSE", + "X12SA-ES-EB3-VVPP-2010:PLC_ERROR", + "X12SA-ES-EB3-VVPP-2010:PLC_OPEN", + "X12SA-ES-EB3-VVPP-2010:PLC_STATUS", + "X12SA-ES-FT-VMFR-1010:PRESSURE", + "X12SA-ES-FT-VMFR-1010:STATUS", + "X12SA-ES-VMMG-0010:PLC_RELAY-A", + "X12SA-ES-VMMG-0010:PLC_RELAY-D", + "X12SA-ES-VMMG-0010:PLC_RELAY-E", + "X12SA-ES-VMMG-0010:PLC_RELAY-F", + "X12SA-ES-VVPG-1010:PLC_CLOSE", + "X12SA-ES-VVPG-1010:PLC_ERROR", + "X12SA-ES-VVPG-1010:PLC_OPEN", + "X12SA-ES-VVPG-1010:PLC_STATUS", + "X12SA-FE-ABFE-VPIG-1010:ERROR", + "X12SA-FE-ABFE-VPIG-1010:HV", + "X12SA-FE-ABFE-VPIG-1010:PRESS-DISP", + "X12SA-FE-ABFE-VPIG-1010:SETPOINT", + "X12SA-FE-ABFE-VPIG-1010:SETPOINT-STATUS", + "X12SA-FE-ABFE-VPIG-1010:VOLTAGE", + "X12SA-FE-AP-EPSG-0010:OK", + "X12SA-FE-CS-ECVW-0010:PLC_CLOSE", + "X12SA-FE-CS-ECVW-0010:PLC_ERROR", + "X12SA-FE-CS-ECVW-0010:PLC_OPEN", + "X12SA-FE-CS-ECVW-0010:PLC_STATUS", + "X12SA-FE-CS-ECVW-0020:PLC_CLOSE", + "X12SA-FE-CS-ECVW-0020:PLC_ERROR", + "X12SA-FE-CS-ECVW-0020:PLC_OPEN", + "X12SA-FE-CS-ECVW-0020:PLC_STATUS", + "X12SA-FE-CS-ECVW-0030:PLC_CLOSE", + "X12SA-FE-CS-ECVW-0030:PLC_ERROR", + "X12SA-FE-CS-ECVW-0030:PLC_OPEN", + "X12SA-FE-CS-ECVW-0030:PLC_STATUS", + "X12SA-FE-CS-ECVW-0040:PLC_CLOSE", + "X12SA-FE-CS-ECVW-0040:PLC_ERROR", + "X12SA-FE-CS-ECVW-0040:PLC_OPEN", + "X12SA-FE-CS-ECVW-0040:PLC_STATUS", + "X12SA-FE-DIA1-ETTC-0010:TEMP", + "X12SA-FE-DIA1-ETTC-0020:TEMP", + "X12SA-FE-PSH1-EMLS-0010:OPEN", + "X12SA-FE-PSH1-EMLS-0020:CLOSE", + "X12SA-FE-PSH1-EMLS:STATE", + "X12SA-FE-PSH1-ETTC-0010:TEMP", + "X12SA-FE-PSH1-ETTC-0020:TEMP", + "X12SA-FE-PSH1-VMCC-1010:PRESSURE", + "X12SA-FE-PSH1-VMCC-1010:STATUS", + "X12SA-FE-PSH1-VMTC-1010:PRESSURE", + "X12SA-FE-PSH1-VMTC-1010:STATUS", + "X12SA-FE-PSH1-VPIG-1030:ERROR", + "X12SA-FE-PSH1-VPIG-1030:HV", + "X12SA-FE-PSH1-VPIG-1030:PRESS-DISP", + "X12SA-FE-PSH1-VPIG-1030:SETPOINT", + "X12SA-FE-PSH1-VPIG-1030:SETPOINT-STATUS", + "X12SA-FE-PSH1-VPIG-1030:VOLTAGE", + "X12SA-FE-PUM1-VPIG-1020:ERROR", + "X12SA-FE-PUM1-VPIG-1020:HV", + "X12SA-FE-PUM1-VPIG-1020:PRESS-DISP", + "X12SA-FE-PUM1-VPIG-1020:SETPOINT", + "X12SA-FE-PUM1-VPIG-1020:SETPOINT-STATUS", + "X12SA-FE-PUM1-VPIG-1020:VOLTAGE", + "X12SA-FE-PUM2-VPIG-2020:ERROR", + "X12SA-FE-PUM2-VPIG-2020:HV", + "X12SA-FE-PUM2-VPIG-2020:PRESS-DISP", + "X12SA-FE-PUM2-VPIG-2020:SETPOINT", + "X12SA-FE-PUM2-VPIG-2020:SETPOINT-STATUS", + "X12SA-FE-PUM2-VPIG-2020:VOLTAGE", + "X12SA-FE-PUM3-VMCC-2020:PRESSURE", + "X12SA-FE-PUM3-VMCC-2020:STATUS", + "X12SA-FE-PUM3-VMTC-2020:PRESSURE", + "X12SA-FE-PUM3-VMTC-2020:STATUS", + "X12SA-FE-PUM3-VPIG-2030:ERROR", + "X12SA-FE-PUM3-VPIG-2030:HV", + "X12SA-FE-PUM3-VPIG-2030:PRESS-DISP", + "X12SA-FE-PUM3-VPIG-2030:SETPOINT", + "X12SA-FE-PUM3-VPIG-2030:SETPOINT-STATUS", + "X12SA-FE-PUM3-VPIG-2030:VOLTAGE", + "X12SA-FE-SL1-ETTC-0010:TEMP", + "X12SA-FE-SL1-ETTC-0020:TEMP", + "X12SA-FE-SL2-ETTC-0010:TEMP", + "X12SA-FE-SL2-ETTC-0020:TEMP", + "X12SA-FE-STO1-EMLS-0010:OPEN", + "X12SA-FE-STO1-EMLS-0020:CLOSE", + "X12SA-FE-STO1-EMLS:STATE", + "X12SA-FE-STO1-VMCC-2010:PRESSURE", + "X12SA-FE-STO1-VMCC-2010:STATUS", + "X12SA-FE-STO1-VMTC-2010:PRESSURE", + "X12SA-FE-STO1-VMTC-2010:STATUS", + "X12SA-FE-STO1-VPIG-2010:ERROR", + "X12SA-FE-STO1-VPIG-2010:HV", + "X12SA-FE-STO1-VPIG-2010:PRESS-DISP", + "X12SA-FE-STO1-VPIG-2010:SETPOINT", + "X12SA-FE-STO1-VPIG-2010:SETPOINT-STATUS", + "X12SA-FE-STO1-VPIG-2010:VOLTAGE", + "X12SA-FE-VCVS-0000:ENA", + "X12SA-FE-VMCC-0000:PRESSURE", + "X12SA-FE-VMCC-0000:STATUS", + "X12SA-FE-VMMG-0010:PLC_RELAY-B", + "X12SA-FE-VMMG-0010:PLC_RELAY-D", + "X12SA-FE-VMMG-0010:PLC_RELAY-F", + "X12SA-FE-VMTC-0000:PRESSURE", + "X12SA-FE-VMTC-0000:STATUS", + "X12SA-FE-VVFV-2010:PLC_CLOSE", + "X12SA-FE-VVFV-2010:PLC_INRUSH", + "X12SA-FE-VVFV-2010:PLC_OPEN", + "X12SA-FE-VVPG-0000:PLC_CLOSE", + "X12SA-FE-VVPG-0000:PLC_ERROR", + "X12SA-FE-VVPG-0000:PLC_OPEN", + "X12SA-FE-VVPG-0000:PLC_STATUS", + "X12SA-FE-VVPG-1010:PLC_CLOSE", + "X12SA-FE-VVPG-1010:PLC_ERROR", + "X12SA-FE-VVPG-1010:PLC_OPEN", + "X12SA-FE-VVPG-1010:PLC_STATUS", + "X12SA-FE-VVPG-2010:PLC_CLOSE", + "X12SA-FE-VVPG-2010:PLC_ERROR", + "X12SA-FE-VVPG-2010:PLC_OPEN", + "X12SA-FE-VVPG-2010:PLC_STATUS", + "X12SA-FE-XBPM1-ETTC-0010:TEMP", + "X12SA-FE-XBPM1-ETTC-0020:TEMP", + "X12SA-OP-AP-EPTG-0010:PRESSURE", + "X12SA-OP-CC-ECCN-0010:ALARM", + "X12SA-OP-CC-ECCN-0010:ALARM.DESC", + "X12SA-OP-CC-ECCN-0010:RUN", + "X12SA-OP-CC-ECCN-0010:RUN.DESC", + "X12SA-OP-CCM-ETTC-4010:TEMP", + "X12SA-OP-CCM-ETTC-4020:TEMP", + "X12SA-OP-CCM-VMFR-4010:PRESSURE", + "X12SA-OP-CCM-VMFR-4010:STATUS", + "X12SA-OP-CCM-VPIG-4010:ERROR", + "X12SA-OP-CCM-VPIG-4010:HV", + "X12SA-OP-CCM-VPIG-4010:PRESS-DISP", + "X12SA-OP-CCM-VPIG-4010:SETPOINT", + "X12SA-OP-CCM-VPIG-4010:SETPOINT-STATUS", + "X12SA-OP-CCM-VPIG-4010:VOLTAGE", + "X12SA-OP-CS-ECVW-0010:PLC_CLOSE", + "X12SA-OP-CS-ECVW-0010:PLC_ERROR", + "X12SA-OP-CS-ECVW-0010:PLC_OPEN", + "X12SA-OP-CS-ECVW-0010:PLC_STATUS", + "X12SA-OP-CS-ECVW-0020:PLC_CLOSE", + "X12SA-OP-CS-ECVW-0020:PLC_ERROR", + "X12SA-OP-CS-ECVW-0020:PLC_OPEN", + "X12SA-OP-CS-ECVW-0020:PLC_STATUS", + "X12SA-OP-DMM-ETTC-3010:TEMP", + "X12SA-OP-DMM-ETTC-3020:TEMP", + "X12SA-OP-DMM-ETTC-3030:TEMP", + "X12SA-OP-DMM-ETTC-3040:TEMP", + "X12SA-OP-DMM-VMFR-3010:PRESSURE", + "X12SA-OP-DMM-VMFR-3010:STATUS", + "X12SA-OP-DMM-VPIG-3010:ERROR", + "X12SA-OP-DMM-VPIG-3010:HV", + "X12SA-OP-DMM-VPIG-3010:PRESS-DISP", + "X12SA-OP-DMM-VPIG-3010:SETPOINT", + "X12SA-OP-DMM-VPIG-3010:SETPOINT-STATUS", + "X12SA-OP-DMM-VPIG-3010:VOLTAGE", + "X12SA-OP-EB1-VMCP-5010:PRESSURE", + "X12SA-OP-EB1-VMCP-5010:STATUS", + "X12SA-OP-EB1-VMFR-5020:PRESSURE", + "X12SA-OP-EB1-VMFR-5020:STATUS", + "X12SA-OP-EB1-VPIG-5010:ERROR", + "X12SA-OP-EB1-VPIG-5010:HV", + "X12SA-OP-EB1-VPIG-5010:PRESS-DISP", + "X12SA-OP-EB1-VPIG-5010:SETPOINT", + "X12SA-OP-EB1-VPIG-5010:SETPOINT-STATUS", + "X12SA-OP-EB1-VPIG-5010:VOLTAGE", + "X12SA-OP-EB1-VPIG-5020:ERROR", + "X12SA-OP-EB1-VPIG-5020:HV", + "X12SA-OP-EB1-VPIG-5020:PRESS-DISP", + "X12SA-OP-EB1-VPIG-5020:SETPOINT", + "X12SA-OP-EB1-VPIG-5020:SETPOINT-STATUS", + "X12SA-OP-EB1-VPIG-5020:VOLTAGE", + "X12SA-OP-EB1-VPRO-5010:PLC_RUN", + "X12SA-OP-EB1-VPTM-5010:PLC_PUMP-RBV", + "X12SA-OP-EB1-VPTM-5010:PLC_SPEED-SP", + "X12SA-OP-EB1-VPTM-5010:PLC_TURBO-RBV", + "X12SA-OP-EB1-VVPG-5010:PLC_CLOSE", + "X12SA-OP-EB1-VVPG-5010:PLC_ERROR", + "X12SA-OP-EB1-VVPG-5010:PLC_OPEN", + "X12SA-OP-EB1-VVPG-5010:PLC_STATUS", + "X12SA-OP-EB1-VVPP-5010:PLC_CLOSE", + "X12SA-OP-EB1-VVPP-5010:PLC_ERROR", + "X12SA-OP-EB1-VVPP-5010:PLC_OPEN", + "X12SA-OP-EB1-VVPP-5010:PLC_STATUS", + "X12SA-OP-KB-VMFR-6010:PRESSURE", + "X12SA-OP-KB-VMFR-6010:STATUS", + "X12SA-OP-KB-VPIG-6010:ERROR", + "X12SA-OP-KB-VPIG-6010:HV", + "X12SA-OP-KB-VPIG-6010:PRESS-DISP", + "X12SA-OP-KB-VPIG-6010:SETPOINT", + "X12SA-OP-KB-VPIG-6010:SETPOINT-STATUS", + "X12SA-OP-KB-VPIG-6010:VOLTAGE", + "X12SA-OP-KB-VPIG-6020:ERROR", + "X12SA-OP-KB-VPIG-6020:HV", + "X12SA-OP-KB-VPIG-6020:PRESS-DISP", + "X12SA-OP-KB-VPIG-6020:SETPOINT", + "X12SA-OP-KB-VPIG-6020:SETPOINT-STATUS", + "X12SA-OP-KB-VPIG-6020:VOLTAGE", + "X12SA-OP-PSH1-EMLS-7010:OPEN", + "X12SA-OP-PSH1-EMLS-7020:CLOSE", + "X12SA-OP-PSH1-EMLS:STATE", + "X12SA-OP-PSH1-ETTC-7010:TEMP", + "X12SA-OP-PSH1-ETTC-7020:TEMP", + "X12SA-OP-PSH1-VMFR-6010:PRESSURE", + "X12SA-OP-PSH1-VMFR-6010:STATUS", + "X12SA-OP-PSH1-VPIG-7010:ERROR", + "X12SA-OP-PSH1-VPIG-7010:HV", + "X12SA-OP-PSH1-VPIG-7010:PRESS-DISP", + "X12SA-OP-PSH1-VPIG-7010:SETPOINT", + "X12SA-OP-PSH1-VPIG-7010:SETPOINT-STATUS", + "X12SA-OP-PSH1-VPIG-7010:VOLTAGE", + "X12SA-OP-PSYS:SH-A-CLOSE", + "X12SA-OP-PSYS:SH-A-OK", + "X12SA-OP-PSYS:SH-A-OPEN", + "X12SA-OP-PSYS:SH-B-CLOSE", + "X12SA-OP-PSYS:SH-B-OK", + "X12SA-OP-PSYS:SH-B-OPEN", + "X12SA-OP-PUM1-VMCC-1010:PRESSURE", + "X12SA-OP-PUM1-VMCC-1010:STATUS", + "X12SA-OP-PUM1-VMCP-1010:PRESSURE", + "X12SA-OP-PUM1-VMCP-1010:STATUS", + "X12SA-OP-PUM1-VPIG-1010:ERROR", + "X12SA-OP-PUM1-VPIG-1010:HV", + "X12SA-OP-PUM1-VPIG-1010:PRESS-DISP", + "X12SA-OP-PUM1-VPIG-1010:SETPOINT", + "X12SA-OP-PUM1-VPIG-1010:SETPOINT-STATUS", + "X12SA-OP-PUM1-VPIG-1010:VOLTAGE", + "X12SA-OP-PUM2-VMFR-2010:PRESSURE", + "X12SA-OP-PUM2-VMFR-2010:STATUS", + "X12SA-OP-PUM2-VPIG-2010:ERROR", + "X12SA-OP-PUM2-VPIG-2010:HV", + "X12SA-OP-PUM2-VPIG-2010:PRESS-DISP", + "X12SA-OP-PUM2-VPIG-2010:SETPOINT", + "X12SA-OP-PUM2-VPIG-2010:SETPOINT-STATUS", + "X12SA-OP-PUM2-VPIG-2010:VOLTAGE", + "X12SA-OP-PUM3-ETTC-2010:TEMP", + "X12SA-OP-PUM3-VMFR-2010:PRESSURE", + "X12SA-OP-PUM3-VMFR-2010:STATUS", + "X12SA-OP-PUM3-VPIG-2010:ERROR", + "X12SA-OP-PUM3-VPIG-2010:HV", + "X12SA-OP-PUM3-VPIG-2010:PRESS-DISP", + "X12SA-OP-PUM3-VPIG-2010:SETPOINT", + "X12SA-OP-PUM3-VPIG-2010:SETPOINT-STATUS", + "X12SA-OP-PUM3-VPIG-2010:VOLTAGE", + "X12SA-OP-PUM4-ETTC-5010:TEMP", + "X12SA-OP-PUM4-VMFR-5010:PRESSURE", + "X12SA-OP-PUM4-VMFR-5010:STATUS", + "X12SA-OP-PUM4-VPIG-5010:ERROR", + "X12SA-OP-PUM4-VPIG-5010:HV", + "X12SA-OP-PUM4-VPIG-5010:PRESS-DISP", + "X12SA-OP-PUM4-VPIG-5010:SETPOINT", + "X12SA-OP-PUM4-VPIG-5010:SETPOINT-STATUS", + "X12SA-OP-PUM4-VPIG-5010:VOLTAGE", + "X12SA-OP-PUM5-VMFR-5010:PRESSURE", + "X12SA-OP-PUM5-VMFR-5010:STATUS", + "X12SA-OP-PUM5-VPIG-2010:ERROR", + "X12SA-OP-PUM5-VPIG-2010:HV", + "X12SA-OP-PUM5-VPIG-2010:PRESS-DISP", + "X12SA-OP-PUM5-VPIG-2010:SETPOINT", + "X12SA-OP-PUM5-VPIG-2010:SETPOINT-STATUS", + "X12SA-OP-PUM5-VPIG-2010:VOLTAGE", + "X12SA-OP-SL1-ETTC-2010:TEMP", + "X12SA-OP-SL1-ETTC-2020:TEMP", + "X12SA-OP-SL2-ETTC-2010:TEMP", + "X12SA-OP-SL2-ETTC-2020:TEMP", + "X12SA-OP-SL3-ETTC-5010:TEMP", + "X12SA-OP-SL3-ETTC-5020:TEMP", + "X12SA-OP-SL4-ETTC-5010:TEMP", + "X12SA-OP-SL4-ETTC-5020:TEMP", + "X12SA-OP-VMMG-0010:PLC_RELAY-B", + "X12SA-OP-VMMG-0010:PLC_RELAY-C", + "X12SA-OP-VMMG-0010:PLC_RELAY-D", + "X12SA-OP-VMMG-0010:PLC_RELAY-E", + "X12SA-OP-VMMG-0010:PLC_RELAY-F", + "X12SA-OP-VMMG-0020:PLC_RELAY-A", + "X12SA-OP-VMMG-0020:PLC_RELAY-C", + "X12SA-OP-VMMG-0020:PLC_RELAY-D", + "X12SA-OP-VMMG-0020:PLC_RELAY-E", + "X12SA-OP-VMMG-0020:PLC_RELAY-F", + "X12SA-OP-VVFV-6010:PLC_CLOSE", + "X12SA-OP-VVFV-6010:PLC_INRUSH", + "X12SA-OP-VVFV-6010:PLC_OPEN", + "X12SA-OP-VVPG-1010:PLC_CLOSE", + "X12SA-OP-VVPG-1010:PLC_ERROR", + "X12SA-OP-VVPG-1010:PLC_OPEN", + "X12SA-OP-VVPG-1010:PLC_STATUS", + "X12SA-OP-VVPG-2010:PLC_CLOSE", + "X12SA-OP-VVPG-2010:PLC_ERROR", + "X12SA-OP-VVPG-2010:PLC_OPEN", + "X12SA-OP-VVPG-2010:PLC_STATUS", + "X12SA-OP-VVPG-3010:PLC_CLOSE", + "X12SA-OP-VVPG-3010:PLC_ERROR", + "X12SA-OP-VVPG-3010:PLC_OPEN", + "X12SA-OP-VVPG-3010:PLC_STATUS", + "X12SA-OP-VVPG-3020:PLC_CLOSE", + "X12SA-OP-VVPG-3020:PLC_ERROR", + "X12SA-OP-VVPG-3020:PLC_OPEN", + "X12SA-OP-VVPG-3020:PLC_STATUS", + "X12SA-OP-VVPG-4010:PLC_CLOSE", + "X12SA-OP-VVPG-4010:PLC_ERROR", + "X12SA-OP-VVPG-4010:PLC_OPEN", + "X12SA-OP-VVPG-4010:PLC_STATUS", + "X12SA-OP-VVPG-5010:PLC_CLOSE", + "X12SA-OP-VVPG-5010:PLC_ERROR", + "X12SA-OP-VVPG-5010:PLC_OPEN", + "X12SA-OP-VVPG-5010:PLC_STATUS", + "X12SA-OP-VVPG-6010:PLC_CLOSE", + "X12SA-OP-VVPG-6010:PLC_ERROR", + "X12SA-OP-VVPG-6010:PLC_OPEN", + "X12SA-OP-VVPG-6010:PLC_STATUS", + "X12SA-OP-VVPG-7010:PLC_CLOSE", + "X12SA-OP-VVPG-7010:PLC_ERROR", + "X12SA-OP-VVPG-7010:PLC_OPEN", + "X12SA-OP-VVPG-7010:PLC_STATUS", +] diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_svg.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_svg.py new file mode 100644 index 0000000..ff3c416 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_synoptic_svg.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +""" +Transpile the caQtDM EPS synoptic (X_EPS_X12SA-Graphic_new.ui) into an SVG +template that mirrors the panel faithfully. + +Key fidelity features (fixes over the first pass): + * VISIBILITY RULES. caQtDM elements carry visibility=IfZero/IfNotZero/Calc + bound to a channel. A valve is a green OPEN bowtie + a red CLOSED bowtie, + each shown only when its channel says so. The first pass drew all of them + at once (overlapping red+green+stem = mush). Now every conditional element + is emitted with data-vis / data-vis-pv / data-vis-calc, and the frontend + shows/hides it from live values, exactly like caQtDM. + * DEVICE LABELS. The $(DESC) macro on each include is drawn under the glyph. + * STRAIGHT PIPES. Beam-line polylines are snapped to horizontal where they + are nearly horizontal, so pipes read as clean lines, not slightly skewed. + * READABLE VALUES. Value fields use a larger font with a subtle backing box, + matching the panel's readouts. + +Layers in the output SVG: + #syn-static fixed structure (pipes, chambers, outlines) + device labels + #syn-dynamic shapes recolored / shown-hidden live (data-pv, data-vis*) + #syn-text live value fields (data-pv, data-alarm) + +Run standalone: + python eps_synoptic_svg.py X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg +""" + +from __future__ import annotations + +import os +import sys +import xml.etree.ElementTree as ET + +CANVAS_W = 3071 +CANVAS_H = 774 +PIPE_GREY = "#7a828f" +LABEL_COL = "#9aa6c0" +SNAP_TOL = 3 # px: snap near-horizontal/vertical pipe segments + +_GLYPH_TEMPLATES = { + "EPS_Valve.ui", "EPS_Valve_inv.ui", "EPS_Gauge.ui", "EPS_IonPump.ui", + "EPS_Temp.ui", "EPS_BST.ui", "EPS_FastValve.ui", "EPS_FastValve_Alarm.ui", + "EPS_ChamberBig.ui", "EPS_ChamberSmall.ui", "VTP.ui", + "EPS_CoolingValves.ui", "EPS_CoolingValvesSmall.ui", +} + + +def _rgb(c): + if c is None: + return None + try: + return f'rgb({c.find("red").text},{c.find("green").text},{c.find("blue").text})' + except Exception: + return None + + +def _props(w): + out = {} + for p in w.findall("property"): + name = p.get("name") + color = p.find("color") + if color is not None: + out[name] = _rgb(color) + continue + rect = p.find("rect") + if rect is not None: + out[name] = tuple(int(rect.find(k).text) + for k in ("x", "y", "width", "height")) + continue + for tag in ("string", "enum", "bool", "number"): + e = p.find(tag) + if e is not None: + out[name] = e.text + break + return out + + +def _abs_geom(w, pmap): + x = y = ww = hh = 0 + first = True + cur = w + while cur is not None: + if cur.tag == "widget": + g = _props(cur).get("geometry") + if isinstance(g, tuple): + x += g[0] + y += g[1] + if first: + ww, hh = g[2], g[3] + first = False + cur = pmap.get(cur) + return x, y, ww, hh + + +def _channel(props, key="channel"): + v = props.get(key) + return v.replace("$(BML)", "X12SA") if v else None + + +def _parse_macro(m): + d = {} + for part in (m or "").split(","): + if "=" in part: + k, v = part.split("=", 1) + d[k.strip()] = v.strip() + return d + + +def _subst(s, macros): + if s is None: + return None + s = s.replace("$(BML)", "X12SA") + for k, v in macros.items(): + s = s.replace(f"$({k})", v) + return s.replace("$(BML)", "X12SA") + + +def _esc(s): + return (str(s).replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def _vis_attrs(props, macros=None): + """Return SVG data-* attributes encoding a caQtDM visibility rule.""" + mode = props.get("visibility", "") or "" + if "IfZero" not in mode and "IfNotZero" not in mode and "Calc" not in mode: + return "" + ch = _channel(props) + chb = _channel(props, "channelB") + chc = _channel(props, "channelC") + chd = _channel(props, "channelD") + if macros: + ch = _subst(ch, macros) if ch else None + chb = _subst(chb, macros) if chb else None + chc = _subst(chc, macros) if chc else None + chd = _subst(chd, macros) if chd else None + kind = ("ifzero" if "IfZero" in mode else + "ifnotzero" if "IfNotZero" in mode else "calc") + a = f' data-vis="{kind}"' + if ch: + a += f' data-vis-pv="{_esc(ch)}"' + if chb: + a += f' data-vis-pvb="{_esc(chb)}"' + if chc and chc != "None": + a += f' data-vis-pvc="{_esc(chc)}"' + if chd and chd != "None": + a += f' data-vis-pvd="{_esc(chd)}"' + if kind == "calc": + calc = props.get("visibilityCalc", "") + a += f' data-vis-calc="{_esc(calc)}"' + return a + + +def _snap_pipe(coords): + """Snap near-horizontal / near-vertical polyline segments to straight.""" + if len(coords) < 2: + return coords + out = [list(coords[0])] + for i in range(1, len(coords)): + px, py = out[-1] + cx, cy = coords[i] + if abs(cy - py) <= SNAP_TOL: + cy = py + elif abs(cx - px) <= SNAP_TOL: + cx = px + out.append([cx, cy]) + return [tuple(p) for p in out] + + +def _shape_from(props, cls, x, y, ww, hh, fg, macros=None): + """Build a shape dict from a caGraphics/caPolyLine/caFrame widget.""" + if cls == "caPolyLine": + pts = props.get("xyPairs") or "" + coords = [] + for pair in pts.split(";"): + if "," in pair: + a, b = pair.split(",")[:2] + coords.append((x + int(a), y + int(b))) + if not coords: + return None + filled = "Filled" in (props.get("fillstyle") or "") or \ + "Polygon" in (props.get("polystyle") or "") + coords = _snap_pipe(coords) + return {"kind": "polygon" if filled else "polyline", + "points": coords, "stroke": fg, + "fill": fg if filled else "none", + "width": int(props.get("lineSize") or 2)} + form = props.get("form", "") or "" + if "Line" in form: + # A caQtDM caGraphics Line is drawn axis-aligned through the CENTRE of + # its bounding box - horizontal when the box is wider than tall, + # vertical otherwise. Drawing it corner-to-corner (as the first pass + # did) gave every beam-pipe segment a slope equal to the box height, + # which is the skew seen on the pipes. + if ww >= hh: + cy = y + hh / 2 + return {"kind": "line", "x1": x, "y1": cy, "x2": x + ww, "y2": cy, + "stroke": fg, "width": max(int(props.get("lineSize") or 2), hh)} + cx = x + ww / 2 + return {"kind": "line", "x1": cx, "y1": y, "x2": cx, "y2": y + hh, + "stroke": fg, "width": max(int(props.get("lineSize") or 2), ww)} + if "Circle" in form: + return {"kind": "ellipse", "cx": x + ww / 2, "cy": y + hh / 2, + "rx": ww / 2, "ry": hh / 2, "fill": fg} + return {"kind": "rect", "x": x, "y": y, "w": ww, "h": hh, + "fill": fg, "stroke": (fg if cls == "caFrame" else None)} + + +def transpile(ui_path): + tree = ET.parse(ui_path) + root = tree.getroot() + pmap = {c: p for p in root.iter() for c in p} + ui_dir = os.path.dirname(os.path.abspath(ui_path)) + + static, dynamic, texts, labels = [], [], [], [] + + def handle(w, macros, ox, oy, sub_pmap): + cls = w.get("class") + if cls not in ("caGraphics", "caPolyLine", "caFrame", + "caLineEdit", "caLabel", "caLed"): + return + p = _props(w) + x, y, ww, hh = _abs_geom(w, sub_pmap) + x += ox + y += oy + mode = p.get("colorMode", "") or "" + is_alarm = "Alarm" in mode + pv = _channel(p) + if macros and pv: + pv = _subst(pv, macros) + fg = p.get("foreground") or p.get("lineColor") or PIPE_GREY + vis = _vis_attrs(p, macros) + + # value fields + if cls in ("caLineEdit",) and pv: + texts.append({"pv": pv, "x": x, "y": y, "w": ww, "h": hh, + "alarm": is_alarm, "vis": vis}) + return + # labels + if cls == "caLabel": + txt = p.get("text") + if macros: + txt = _subst(txt, macros) + if pv and not txt: + texts.append({"pv": pv, "x": x, "y": y, "w": ww, "h": hh, + "alarm": is_alarm, "vis": vis}) + return + if txt and "html" not in txt.lower(): + labels.append({"x": x, "y": y, "w": ww, "h": hh, + "text": txt.strip(), "vis": vis}) + elif txt and "html" in txt.lower(): + import re + inner = re.sub(r"<[^>]+>", "", txt).strip() + if inner: + labels.append({"x": x, "y": y, "w": ww, "h": hh, + "text": inner, "vis": vis}) + return + + shape = _shape_from(p, cls, x, y, ww, hh, fg, macros) + if not shape: + return + if vis: + shape["vis"] = vis + if pv: + shape["pv"] = pv + dynamic.append(shape) + elif is_alarm and pv: + shape["pv"] = pv + dynamic.append(shape) + else: + static.append(shape) + + # top level + for w in root.iter("widget"): + if w.get("class") == "caInclude": + continue + handle(w, None, 0, 0, pmap) + + # includes -> device glyphs + cache = {} + for w in root.iter("widget"): + if w.get("class") != "caInclude": + continue + p = _props(w) + fn = p.get("filename") + if fn not in _GLYPH_TEMPLATES: + continue + macros = _parse_macro(p.get("macro")) + ox, oy, gw, gh = _abs_geom(w, pmap) + if fn not in cache: + try: + cache[fn] = ET.parse(os.path.join(ui_dir, fn)).getroot() + except Exception: + cache[fn] = None + sub = cache[fn] + if sub is None: + continue + sub_pmap = {c: pp for pp in sub.iter() for c in pp} + for sw in sub.iter("widget"): + if sw.get("class") in ("QMainWindow", "QWidget", "caRelatedDisplay", + "caMessageButton"): + continue + handle(sw, macros, ox, oy, sub_pmap) + # device label from DESC under the glyph + desc = macros.get("DESC", "") + if desc: + labels.append({"x": ox, "y": oy + gh, "w": gw, "h": 10, + "text": desc, "vis": ""}) + + return static, dynamic, texts, labels + + +def _svg_shape(s): + pv = f' data-pv="{_esc(s["pv"])}"' if s.get("pv") else "" + vis = s.get("vis", "") + k = s["kind"] + if k == "line": + return (f'') + if k in ("polyline", "polygon"): + pts = " ".join(f"{a},{b}" for a, b in s["points"]) + fill = s.get("fill", "none") + return (f'<{k} points="{pts}" fill="{fill}" stroke="{s["stroke"]}" ' + f'stroke-width="{s["width"]}" stroke-linejoin="round"{pv}{vis}/>') + if k == "ellipse": + return (f'') + if k == "rect": + if s.get("stroke"): + body = f' fill="none" stroke="{s["stroke"]}"' + else: + body = f' fill="{s.get("fill", PIPE_GREY)}"' + return (f'') + return "" + + +def build_svg(ui_path): + static, dynamic, texts, labels = transpile(ui_path) + out = [f''] + + out.append('') + out.extend(_svg_shape(s) for s in static) + # device + section labels + for lb in labels: + vis = lb.get("vis", "") + cx = lb["x"] + lb["w"] / 2 + cy = lb["y"] + 8 + out.append(f'{_esc(lb["text"])}') + out.append('') + + out.append('') + out.extend(_svg_shape(s) for s in dynamic) + out.append('') + + # value fields: backing box + text, larger for readability + out.append('') + for t in texts: + bx, by, bw, bh = t["x"], t["y"], max(t["w"], 34), max(t["h"], 14) + cx = bx + bw / 2 + cy = by + bh - 3 + vis = t.get("vis", "") + out.append(f'') + out.append(f'') + out.append(f'-') + out.append('') + out.append('') + + out.append('') + return "\n".join(out), (len(static), len(dynamic), len(texts), len(labels)) + + +def main(): + ui = sys.argv[1] if len(sys.argv) > 1 else "X_EPS_X12SA-Graphic_new.ui" + dest = sys.argv[2] if len(sys.argv) > 2 else "eps_synoptic.svg" + svg, (ns, nd, nt, nl) = build_svg(ui) + with open(dest, "w") as fh: + fh.write(svg) + print(f"{dest}: {ns} static, {nd} dynamic, {nt} values, {nl} labels") + + +if __name__ == "__main__": + main() diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/web_common.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/web_common.py new file mode 100644 index 0000000..0b5ae4f --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/web_common.py @@ -0,0 +1,255 @@ +""" +Shared web infrastructure for the flOMNI status pages. + +Contains HttpUploader and LocalHttpServer, lifted from +flomni_webpage_generator.py so the experiment-status generator and the +EPS/machine-status generator share one implementation. + +Deliberate differences from the original: + * No bec_lib import. Falls back to the stdlib logger when bec_lib is + absent, so this module works outside a BEC session. + * allow_redirects=False on every POST (SSRF hardening: a compromised + public server must not be able to redirect an internal process to an + arbitrary internal address). + * LocalHttpServer takes a default_page so the printed URL is right for + whichever page the caller serves. +""" + +from __future__ import annotations + +import functools +import http.server +import logging +import socket +import threading +import time +from pathlib import Path + +try: # inside BEC + from bec_lib import bec_logger + logger = bec_logger.logger +except Exception: # standalone + logger = logging.getLogger("web_common") + if not logger.handlers: + _h = logging.StreamHandler() + _h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + logger.addHandler(_h) + logger.setLevel(logging.INFO) + + +def _epoch() -> float: + return time.time() + + +# --------------------------------------------------------------------------- +# HTTP uploader (non-blocking, fire-and-forget) +# --------------------------------------------------------------------------- + +class HttpUploader: + """ + Uploads files from a local directory to a remote server via HTTP POST. + + Uploads run in a daemon thread so they never block the caller's cycle. + If an upload is already in progress when the next cycle fires, the new + request is dropped (logged at DEBUG) rather than queuing up. + """ + + _UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt", ".svg"} + _WARN_COOLDOWN_S = 600 + + def __init__(self, url: str, timeout: float = 20.0): + self._url = url + self._timeout = timeout + self._uploaded: dict[str, float] = {} + self._lock = threading.Lock() + self._busy = False + self._warn_at: dict[str, float] = {} + + def _warn(self, key: str, msg: str) -> None: + """Log a warning at most once per _WARN_COOLDOWN_S for a given key.""" + now = _epoch() + if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S: + self._warn_at[key] = now + logger.warning(msg) + + # -- public API --------------------------------------------------------- + + def upload_dir_async(self, directory: Path) -> None: + """Upload ALL eligible files in directory, in a background thread.""" + self._dispatch(self._upload_files, self._eligible_files(directory), True) + + def upload_changed_async(self, directory: Path) -> None: + """Upload only files whose mtime changed since the last upload.""" + files = self._changed_files(directory) + if files: + self._dispatch(self._upload_files, files, False) + + def upload_file_async(self, path: Path) -> None: + """Upload one specific file, bypassing suffix whitelist and mtime check.""" + self._dispatch(self._upload_files, [Path(path)], True) + + def cleanup_ptycho_images_async(self) -> None: + """Ask the server to delete all S*_*.png / S*_*.jpg files (background).""" + self._dispatch(self._do_cleanup) + + # -- internals ---------------------------------------------------------- + + def _dispatch(self, fn, *args) -> None: + with self._lock: + if self._busy: + logger.debug("HttpUploader: previous upload still running, skipping") + return + self._busy = True + + def _run(): + try: + fn(*args) + finally: + with self._lock: + self._busy = False + + threading.Thread(target=_run, name="HttpUploader", daemon=True).start() + + def _eligible_files(self, directory: Path) -> list: + try: + return [p for p in Path(directory).iterdir() + if p.is_file() and p.suffix in self._UPLOAD_SUFFIXES] + except OSError: + return [] + + def _changed_files(self, directory: Path) -> list: + out = [] + for path in self._eligible_files(directory): + try: + mtime = path.stat().st_mtime + except OSError: + continue + if self._uploaded.get(str(path)) != mtime: + out.append(path) + return out + + def _upload_files(self, files: list, force: bool = False) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + self._warn("no_requests", "HttpUploader: 'requests' library not installed") + return + + for path in files: + try: + mtime = path.stat().st_mtime + except OSError: + continue + if not force and self._uploaded.get(str(path)) == mtime: + continue + try: + with open(path, "rb") as fh: + r = _requests.post( + self._url, + files={"file": (path.name, fh)}, + timeout=self._timeout, + verify=False, # accept self-signed certs + allow_redirects=False, # SSRF hardening + ) + if r.status_code == 200: + self._uploaded[str(path)] = mtime + self._warn_at.pop(f"upload_{path.name}", None) + logger.debug(f"HttpUploader: OK {path.name}") + else: + self._warn( + f"upload_{path.name}", + f"HttpUploader: {path.name} -> HTTP {r.status_code}: " + f"{r.text[:120]}", + ) + except Exception as exc: + self._warn(f"upload_{path.name}", + f"HttpUploader: {path.name} failed: {exc}") + + def _do_cleanup(self) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + return + try: + r = _requests.post( + self._url, + data={"action": "cleanup"}, + timeout=self._timeout, + verify=False, # accept self-signed certs + allow_redirects=False, # SSRF hardening + ) + logger.info(f"HttpUploader cleanup: {r.text[:120]}") + self._warn_at.pop("cleanup", None) + # Forget mtime records for ptycho files so they get re-uploaded + with self._lock: + to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k] + for k in to_remove: + self._uploaded.pop(k, None) + except Exception as exc: + self._warn("cleanup", f"HttpUploader cleanup failed: {exc}") + + +# --------------------------------------------------------------------------- +# Local HTTP server +# --------------------------------------------------------------------------- + +class LocalHttpServer: + """ + Serves a directory over plain HTTP in a daemon thread. + + Uses the stdlib http.server; no extra dependencies. Request logging is + suppressed so the console stays clean. + """ + + def __init__(self, directory: Path, port: int = 8082, + default_page: str = "index.html"): + self._directory = Path(directory) + self._port = port + self._default_page = default_page + self._server = None + self._thread = None + + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + class _QuietHTTPServer(http.server.HTTPServer): + # NOTE: handle_error must live on the *server*, not the handler. + # Overriding it on the handler class does not suppress + # BrokenPipeError, which clients trigger constantly by navigating + # away mid-response. + def handle_error(self, request, client_address): + pass + + def start(self) -> None: + handler = functools.partial(self._QuietHandler, + directory=str(self._directory)) + try: + self._server = self._QuietHTTPServer(("", self._port), handler) + except OSError as exc: + raise RuntimeError( + f"LocalHttpServer: cannot bind port {self._port}: {exc}" + ) from exc + self._thread = threading.Thread(target=self._server.serve_forever, + name="LocalHttpServer", daemon=True) + self._thread.start() + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server = None + + def is_alive(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def port(self) -> int: + return self._port + + @property + def url(self) -> str: + return f"http://{socket.gethostname()}:{self._port}/{self._default_page}" diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 8e694af..272c3a0 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -83,8 +83,6 @@ changes. Look for "[local-only: account mismatch]" in the startup log / status() """ import datetime -import functools -import http.server import json import os import shutil @@ -101,6 +99,14 @@ except ImportError: from bec_lib import bec_logger +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.web_common import ( + HttpUploader, + LocalHttpServer, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.eps.eps_status_generator import ( + EpsStatusGenerator, +) + logger = bec_logger.logger # --------------------------------------------------------------------------- @@ -361,246 +367,12 @@ def _derive_status( # --------------------------------------------------------------------------- -# HTTP Uploader (non-blocking, fire-and-forget) +# HttpUploader / LocalHttpServer now live in OMNY_shared/web_common.py, +# shared with the EPS/machine-status generator (OMNY_shared/eps/) -- see the +# import at the top of this file. That copy also has an SSRF hardening +# (allow_redirects=False on upload POSTs) this file didn't have before. # --------------------------------------------------------------------------- -class HttpUploader: - """ - Uploads files from a local directory to a remote server via HTTP POST. - - Uploads run in a daemon thread so they never block the generator cycle. - If an upload is already in progress when the next cycle fires, the new - upload request is dropped (logged at DEBUG level) rather than queuing up. - - File tracking: - - Tracks mtime of each file; only re-uploads files that have changed. - - upload_dir() -- uploads ALL eligible files (called once at start). - - upload_changed() -- uploads only files whose mtime has changed. - - Scan change cleanup: - - cleanup_ptycho_images() -- sends action=cleanup POST to the server, - asking it to delete all S*_*.png and S*_*.jpg files. - Called automatically by the generator when the ptycho scan ID changes. - - The server-side upload.php must: - - Accept POST with multipart file upload (field name 'file'). - - Accept POST with action=cleanup to delete ptycho image files. - - Enforce IP-based access control (129.129.122.x). - - Validate filename with regex to block path traversal. - """ - - _UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt"} - - def __init__(self, url: str, timeout: float = 20.0): - self._url = url - self._timeout = timeout - self._uploaded: dict[str, float] = {} # abs path -> mtime at last upload - self._lock = threading.Lock() - self._busy = False # True while an upload thread is running - self._warn_at: dict[str, float] = {} # key -> epoch of last warning - - _WARN_COOLDOWN_S = 600 # only repeat the same warning once per minute - - def _warn(self, key: str, msg: str) -> None: - """Log a warning at most once per _WARN_COOLDOWN_S for a given key.""" - now = _epoch() - if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S: - self._warn_at[key] = now - logger.warning(msg) - - # ── Public API ────────────────────────────────────────────────────────── - - def upload_dir_async(self, directory: Path) -> None: - """Upload ALL eligible files in directory, in a background thread.""" - files = self._eligible_files(directory) - self._dispatch(self._upload_files, files, True) - - def upload_changed_async(self, directory: Path) -> None: - """Upload only changed files in directory, in a background thread.""" - files = self._changed_files(directory) - if files: - self._dispatch(self._upload_files, files, False) - - def upload_file_async(self, path: Path) -> None: - """Upload a single specific file, bypassing suffix whitelist and mtime check.""" - self._dispatch(self._upload_files, [Path(path)], True) - - def cleanup_ptycho_images_async(self) -> None: - """Ask the server to delete all S*_*.png / S*_*.jpg files (background).""" - self._dispatch(self._do_cleanup) - - # ── Internal ──────────────────────────────────────────────────────────── - - def _dispatch(self, fn, *args) -> None: - """Run fn(*args) in a daemon thread. Drops the request if already busy.""" - with self._lock: - if self._busy: - logger.debug("HttpUploader: previous upload still running, skipping") - return - self._busy = True - - def _run(): - try: - fn(*args) - finally: - with self._lock: - self._busy = False - - t = threading.Thread(target=_run, name="HttpUploader", daemon=True) - t.start() - - def _eligible_files(self, directory: Path) -> list: - result = [] - for path in Path(directory).iterdir(): - if path.is_file() and path.suffix in self._UPLOAD_SUFFIXES: - result.append(path) - return result - - def _changed_files(self, directory: Path) -> list: - result = [] - for path in self._eligible_files(directory): - try: - mtime = path.stat().st_mtime - except OSError: - continue - if self._uploaded.get(str(path)) != mtime: - result.append(path) - return result - - def _upload_files(self, files: list, force: bool = False) -> None: - try: - import requests as _requests - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - except ImportError: - self._warn("no_requests", "HttpUploader: 'requests' library not installed") - return - - for path in files: - try: - mtime = path.stat().st_mtime - except OSError: - continue - # Double-check mtime unless forced (upload_dir uses force=True) - if not force and self._uploaded.get(str(path)) == mtime: - continue - try: - with open(path, "rb") as f: - r = _requests.post( - self._url, - files={"file": (path.name, f)}, - timeout=self._timeout, - verify=False, # accept self-signed / untrusted certs - ) - if r.status_code == 200: - self._uploaded[str(path)] = mtime - self._warn_at.pop(f"upload_{path.name}", None) # clear on success - logger.debug(f"HttpUploader: OK {path.name}") - else: - self._warn( - f"upload_{path.name}", - f"HttpUploader: {path.name} -> HTTP {r.status_code}: " - f"{r.text[:120]}" - ) - except Exception as exc: - self._warn(f"upload_{path.name}", f"HttpUploader: {path.name} failed: {exc}") - - def _do_cleanup(self) -> None: - try: - import requests as _requests - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - except ImportError: - return - try: - r = _requests.post( - self._url, - data={"action": "cleanup"}, - timeout=self._timeout, - verify=False, # accept self-signed / untrusted certs - ) - logger.info(f"HttpUploader cleanup: {r.text[:120]}") - self._warn_at.pop("cleanup", None) # clear on success - # Forget mtime records for ptycho files so they get re-uploaded - with self._lock: - to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k] - for k in to_remove: - self._uploaded.pop(k, None) - except Exception as exc: - self._warn("cleanup", f"HttpUploader cleanup failed: {exc}") - - -# --------------------------------------------------------------------------- -# Local HTTP server (serves output_dir over http://hostname:port/) -# --------------------------------------------------------------------------- - -class LocalHttpServer: - """ - Serves the generator's output directory over plain HTTP in a daemon thread. - - Uses Python's built-in http.server — no extra dependencies. - Request logging is suppressed so the BEC console stays clean. - The server survives stop()/start() cycles: _launch() creates a fresh - instance each time start() is called. - - Usage: - srv = LocalHttpServer(output_dir, port=8080) - srv.start() - print(srv.url) # http://hostname:8080/status.html - srv.stop() - """ - - def __init__(self, directory: Path, port: int = 8080): - self._directory = Path(directory) - self._port = port - self._server = None - self._thread = None - - # ── silence the per-request log lines in the iPython console ────────── - class _QuietHandler(http.server.SimpleHTTPRequestHandler): - def log_message(self, *args): - pass - # handle_error here does nothing — wrong class - - class _QuietHTTPServer(http.server.HTTPServer): - def handle_error(self, request, client_address): - pass # suppress BrokenPipeError and all other per-connection noise - - def start(self) -> None: - Handler = functools.partial( - self._QuietHandler, - directory=str(self._directory), - ) - try: - self._server = self._QuietHTTPServer(("", self._port), Handler) - except OSError as exc: - raise RuntimeError( - f"LocalHttpServer: cannot bind port {self._port}: {exc}" - ) from exc - self._thread = threading.Thread( - target=self._server.serve_forever, - name="LocalHttpServer", - daemon=True, - ) - self._thread.start() - - def stop(self) -> None: - if self._server is not None: - self._server.shutdown() # blocks until serve_forever() returns - self._server = None - - def is_alive(self) -> bool: - return self._thread is not None and self._thread.is_alive() - - @property - def port(self) -> int: - return self._port - - @property - def url(self) -> str: - """Best-guess URL for printing. Uses the machine's hostname.""" - return f"http://{socket.gethostname()}:{self._port}/status.html" - # --------------------------------------------------------------------------- # Base generator @@ -640,9 +412,24 @@ class WebpageGeneratorBase: subclasses whose global-var names differ (e.g. lamni's tomo_circfov / lamni_stitch_x/y instead of flomni's fovx/fovy / stitch_x/y). + HAS_EPS_STATUS -- whether to also start the beamline EPS/machine + status pages (OMNY_shared/eps/) alongside the + experiment page, serving from the same output dir + and port. Unlike HAS_TOMO_QUEUE this is beamline- + wide, not per-instrument, so it's expected to stay + True for every setup -- kept as a class attribute + for symmetry/override-ability, not because any + current subclass needs it False. The EPS + generator decides real-vs-demo data itself (see + eps_status_generator._on_beamline_network), + independent of the account-mismatch local_only + fallback below. It never uploads on its own -- + this generator's own uploader already covers the + whole shared output dir, EPS files included. """ HAS_TOMO_QUEUE = True + HAS_EPS_STATUS = True TOMO_TYPES = { 1: {"kind": "equally_spaced_grid", "n_subtomos": 8}, 2: {"kind": "golden_capped"}, @@ -682,6 +469,7 @@ class WebpageGeneratorBase: self._mismatch_local_port = mismatch_local_port self._local_server = None # created fresh each _launch() self._local_only = False # True while running the account-mismatch fallback + self._eps = None # EpsStatusGenerator, constructed in _launch() # Derive companion URLs from upload_url if upload_url is not None: @@ -761,6 +549,7 @@ class WebpageGeneratorBase: self.TOMO_TYPES, self.TQ_PARAM_DISPLAY, logo_filename, + has_eps_status=self.HAS_EPS_STATUS, ) ) @@ -775,7 +564,7 @@ class WebpageGeneratorBase: port = self._mismatch_local_port if local_only else self._local_port if self._local_server is not None and self._local_server.is_alive(): self._local_server.stop() - self._local_server = LocalHttpServer(self._output_dir, port) + self._local_server = LocalHttpServer(self._output_dir, port, default_page="status.html") try: self._local_server.start() local_url_msg = f" local={self._local_server.url}" @@ -783,6 +572,36 @@ class WebpageGeneratorBase: local_url_msg = f" local=ERROR({exc})" self._log(VERBOSITY_NORMAL, str(exc), level="warning") + # EPS/machine-status pages (OMNY_shared/eps/): best-effort add-on, + # sharing this output dir and LocalHttpServer (serve=False) so both + # page families come up on the same port. upload_url is always None + # here -- this generator's own upload_changed_async(self._output_dir) + # a few lines below already covers the whole shared directory, + # including the eps_* files, every cycle; giving EpsStatusGenerator + # its own upload_url as well would spin up a second HttpUploader + # re-scanning and re-POSTing the same directory redundantly. Real-vs- + # demo data is decided independently by the EPS generator itself + # (see eps_status_generator._on_beamline_network), not by local_only, + # so a mismatched account on the real beamline network still gets + # real EPS data, just served locally. + if self.HAS_EPS_STATUS: + if self._eps is not None: + try: + self._eps.stop() + except Exception: + pass + self._eps = EpsStatusGenerator( + output_dir=self._output_dir, + serve=False, + upload_url=None, + ) + try: + self._eps.start() + except Exception as exc: + self._log(VERBOSITY_NORMAL, + f"EPS status generator failed to start: {exc}", level="warning") + self._eps = None + # Upload static files (html + logo) once at startup if not local_only and self._uploader is not None: self._uploader.upload_dir_async(self._output_dir) @@ -811,6 +630,12 @@ class WebpageGeneratorBase: if self._local_server is not None: self._local_server.stop() self._local_server = None + if self._eps is not None: + try: + self._eps.stop() + except Exception: + pass + self._eps = None self._release_lock() self._local_only = False self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.") @@ -1572,12 +1397,23 @@ def _render_html( tomo_types: dict = None, tq_param_display: list = None, logo_filename: str | None = None, + has_eps_status: bool = True, ) -> str: # Falls back to a filename that will 404 (triggering the # text-logo fallback already in the template) when a setup has no logo -- # e.g. WebpageGeneratorBase's own default _logo_path() returning None. logo_src = logo_filename or "logo.png" + # Forward link to the EPS/machine-status page (OMNY_shared/eps/), which + # already links back to this page in its own header -- see + # eps_status.html. Gated by HAS_EPS_STATUS the same way the tomo-queue + # card is gated by HAS_TOMO_QUEUE, just below. + eps_link_html = ( + 'EPS' + if has_eps_status else "" + ) + phones_html = "\n".join( f'
' f'{label}' @@ -2138,6 +1974,7 @@ def _render_html(
+ {eps_link_html}
Theme