Feat/omny eps #267

Merged
holler merged 2 commits from feat/omny_eps into main 2026-07-21 20:40:39 +02:00
12 changed files with 5160 additions and 241 deletions
@@ -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 AD)
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.
@@ -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
<line> 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 `<details open>` 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.
@@ -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
@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cSAXS EPS &amp; Machine Status</title>
<style>
:root {
--mono: 'Space Mono', 'Courier New', Courier, monospace;
--sans: 'DM Sans', 'Helvetica Neue', Arial, sans-serif;
--c-ok:#a6e3a1; --c-minor:#f9e2af; --c-major:#f38ba8;
--c-invalid:#9399b2; --c-info:#89dceb; --c-demo:#cba6f7;
--bg:#0d0f14; --surface:#161a23; --surface2:#1c2130;
--border:#2a3045; --text:#cdd6f4; --text-dim:#6c7a9c;
}
@media (prefers-color-scheme: light) {
:root {
--bg:#f0f3f9; --surface:#fff; --surface2:#e8ecf5;
--border:#c8cedf; --text:#1a1d2e; --text-dim:#5a6480;
--c-ok:#40a02b; --c-minor:#df8e1d; --c-major:#d20f39;
--c-invalid:#8c8fa1; --c-info:#179299; --c-demo:#8839ef;
}
}
* { box-sizing: border-box; }
body { margin:0; padding:16px 20px 60px; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:15px; line-height:1.45; }
h1 { font-size:1.15rem; font-weight:600; margin:0; }
h2 { font-size:0.82rem; font-weight:700; text-transform:uppercase; letter-spacing:0.09em;
color:var(--text-dim); margin:20px 0 10px; padding-bottom:6px; border-bottom:1px solid var(--border); }
a { color:var(--c-info); }
header { display:flex; align-items:baseline; gap:16px; flex-wrap:wrap; margin-bottom:14px; }
header .meta { font-family:var(--mono); font-size:0.78rem; color:var(--text-dim); }
.sev-NO_ALARM{color:var(--c-ok);} .sev-MINOR{color:var(--c-minor);}
.sev-MAJOR{color:var(--c-major);} .sev-INVALID,.sev-UNKNOWN,.disconnected{color:var(--c-invalid);}
.strip { display:grid; gap:10px; margin-bottom:6px;
grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); }
.tile { background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:12px 14px; }
.tile .k { font-size:0.66rem; text-transform:uppercase; letter-spacing:0.07em; color:var(--text-dim); font-weight:700; }
.tile .v { font-family:var(--mono); font-size:1.45rem; font-weight:700; margin-top:3px; line-height:1.15; word-break:break-word; }
.tile .u { font-size:0.78rem; color:var(--text-dim); font-weight:400; }
.panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:13px 15px; }
.hero { border-radius:10px; padding:16px 20px; border:1px solid var(--border);
background:var(--surface); border-left:6px solid var(--hero-c,var(--c-invalid)); }
.hero.ok{--hero-c:var(--c-ok);} .hero.unknown{--hero-c:var(--c-invalid);}
.hero.alarm{--hero-c:var(--c-major); background:color-mix(in srgb,var(--c-major) 10%,var(--surface));}
.hero-title{font-size:1.4rem; font-weight:700; color:var(--hero-c);}
.hero-sub{font-family:var(--mono); font-size:0.82rem; color:var(--text-dim); margin-top:4px;}
.hero-text{font-family:var(--mono); font-size:0.92rem; margin-top:10px; white-space:pre-wrap; word-break:break-word;}
.ev { display:flex; gap:10px; font-family:var(--mono); font-size:0.78rem; padding:3px 0; border-bottom:1px solid var(--border); }
.ev .t{ color:var(--text-dim); white-space:nowrap; }
.ev.eps .d{ color:var(--c-major); } .ev.mach .d{ color:var(--c-info); }
.cryo { display:grid; gap:10px; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
.syn-wrap { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; overflow-x:auto; }
svg.syn { display:block; width:100%; min-width:680px; height:auto; }
.syn-legend { display:flex; gap:14px; flex-wrap:wrap; font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); margin-top:8px; }
.syn-legend span::before { content:"\25CF "; }
.lg-ok::before{color:var(--c-ok);} .lg-minor::before{color:var(--c-minor);}
.lg-major::before{color:var(--c-major);} .lg-dead::before{color:var(--c-invalid);}
.zones { display:grid; gap:12px; grid-template-columns:repeat(4,1fr); }
@media (max-width:1500px){ .zones{ grid-template-columns:repeat(3,1fr);} }
@media (max-width:1100px){ .zones{ grid-template-columns:repeat(2,1fr);} }
@media (max-width:700px){ .zones{ grid-template-columns:1fr;} }
.zone { background:var(--surface); border:1px solid var(--border); border-radius:10px;
padding:13px 15px; border-top:4px solid var(--zone-c,var(--c-invalid)); }
.zone-head { display:flex; justify-content:space-between; align-items:baseline; }
.zone-head .n{ font-weight:700; font-size:1rem; } .zone-head .c{ font-family:var(--mono); font-size:0.75rem; color:var(--text-dim); }
.pill { display:inline-block; font-family:var(--mono); font-size:0.72rem; padding:2px 7px;
border-radius:4px; margin:2px 3px 2px 0; background:var(--surface2); border:1px solid var(--border); }
.pill.ok{color:var(--c-ok);} .pill.minor{color:var(--c-minor);} .pill.major{color:var(--c-major);} .pill.dead{color:var(--c-invalid); opacity:.65;}
details{ margin-top:9px; } details>summary{ cursor:pointer; font-size:0.82rem; color:var(--text-dim); font-family:var(--mono); padding:3px 0; }
details>summary:hover{ color:var(--text); }
table{ width:100%; border-collapse:collapse; font-size:0.8rem; margin-top:6px; }
th,td{ text-align:left; padding:3px 6px 3px 0; border-bottom:1px solid var(--border); vertical-align:top; }
th{ color:var(--text-dim); font-weight:600; font-size:0.72rem; text-transform:uppercase; }
td.pv{ font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); word-break:break-all; }
td.val{ font-family:var(--mono); text-align:right; white-space:nowrap; }
.banner { border-radius:8px; padding:9px 14px; margin-bottom:12px; font-family:var(--mono); font-size:0.83rem; font-weight:700; }
.banner.demo{ background:color-mix(in srgb,var(--c-demo) 18%,var(--surface)); color:var(--c-demo); border:1px solid var(--c-demo); }
.banner.stale{ background:color-mix(in srgb,var(--c-minor) 15%,var(--surface)); color:var(--c-minor); border:1px solid var(--c-minor); }
.banner.err{ background:color-mix(in srgb,var(--c-major) 15%,var(--surface)); color:var(--c-major); border:1px solid var(--c-major); }
footer{ margin-top:22px; font-family:var(--mono); font-size:0.73rem; color:var(--text-dim); }
.empty{ color:var(--text-dim); font-style:italic; font-size:0.85rem; }
</style>
</head>
<body>
<header>
<h1>cSAXS &mdash; EPS &amp; Machine Status</h1>
<span class="meta" id="stamp">loading&hellip;</span>
<span class="meta"><a href="status.html">&rarr; experiment status</a></span>
</header>
<div id="banners"></div>
<h2>Machine Status</h2>
<div class="strip" id="machine-strip"></div>
<h2>Operator Messages</h2>
<div class="panel"><div id="messages"></div></div>
<h2>Alarm Status</h2>
<div id="hero" class="hero unknown"><div class="hero-title">&mdash;</div></div>
<h2>Mono Cryocooler</h2>
<div class="panel"><div class="cryo" id="cryo"></div></div>
<h2>Beamline Synoptic</h2>
<div class="panel">
<a href="eps_synoptic.html" style="text-decoration:none; color:inherit; display:flex; align-items:center; gap:14px;">
<div style="font-size:1.6rem;">&#128506;</div>
<div>
<div style="font-weight:700; color:var(--c-info);">Open full beamline synoptic &rarr;</div>
<div class="empty" style="margin-top:2px;">Faithful EPS panel replica &mdash; pipes, valves, gauges, live values. Pan &amp; zoom.</div>
</div>
</a>
</div>
<h2>Zone Detail</h2>
<div class="zones" id="zones"></div>
<h2>Recent History</h2>
<div class="panel" id="history"></div>
<footer id="footer"></footer>
<script>
"use strict";
const SEV_RANK={NO_ALARM:0,MINOR:1,MAJOR:2,INVALID:3,UNKNOWN:3};
const SEV_VAR={0:"var(--c-ok)",1:"var(--c-minor)",2:"var(--c-major)",3:"var(--c-invalid)"};
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));}
function fmtNum(v,egu){
if(v===null||v===undefined) return "&mdash;";
if(typeof v==="number"){
let s; const a=Math.abs(v);
if(a!==0&&(a<1e-3||a>=1e5)) s=v.toExponential(2);
else s=(Math.round(v*100)/100).toString();
return s+(egu?` <span class="u">${esc(egu)}</span>`:"");
}
return esc(String(v));
}
function ago(e){ if(!e) return "never"; const d=Date.now()/1000-e;
if(d<60) return Math.round(d)+"s ago"; if(d<3600) return Math.round(d/60)+"m ago"; return Math.round(d/3600)+"h ago"; }
function tstamp(e){ if(!e) return ""; return new Date(e*1000).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}); }
function truthy(v){ if(v===null||v===undefined) return null;
if(typeof v==="number") return v!==0; if(typeof v==="boolean") return v;
const s=String(v).trim().toLowerCase();
if(["1","true","on","open","ok","yes","enabled","run","running"].includes(s)) return true;
if(["0","false","off","closed","no","disabled"].includes(s)) return false; return null; }
const OPEN_KEY="eps.openZones"; let openZones=new Set();
try{ const sv=sessionStorage.getItem(OPEN_KEY); if(sv) openZones=new Set(JSON.parse(sv)); }catch(e){}
function persistOpen(){ try{ sessionStorage.setItem(OPEN_KEY,JSON.stringify([...openZones])); }catch(e){} }
function onToggle(ev){ const k=ev.target.dataset.zone; if(!k) return;
if(ev.target.open) openZones.add(k); else openZones.delete(k); persistOpen(); }
function withScroll(fn){ const y=window.scrollY; fn(); if(Math.abs(window.scrollY-y)>1) window.scrollTo(0,y); }
function render(d){
const b=[];
if(d.demo) b.push('<div class="banner demo">DEMO MODE &mdash; synthetic values, not real beamline data</div>');
if(!d.generator.epics_available&&!d.demo) b.push('<div class="banner err">pyepics not available &mdash; no PVs can be read</div>');
if(d.bulk.stale&&!d.demo) b.push(`<div class="banner stale">Detail readings stale &mdash; last sweep ${ago(d.bulk.updated_at_epoch)}</div>`);
const un=d.generator.pv_unreachable||0;
if(un>0&&!d.demo) b.push(`<div class="banner stale">${un} of ${d.generator.pv_total} PVs unreachable</div>`);
document.getElementById("banners").innerHTML=b.join("");
/* 1. machine */
const M=d.bulk.machine||{}, H=d.hot||{};
const ms=[];
const mtile=(k,rec)=>{
const cls=rec&&rec.connected?"sev-"+(rec.severity||"UNKNOWN"):"disconnected";
const v=rec&&rec.connected?fmtNum(rec.value,rec.egu):"&mdash;";
ms.push(`<div class="tile"><div class="k">${k}</div><div class="v ${cls}">${v}</div></div>`);
};
mtile("Ring current",H.ring_current);
mtile("Lifetime",H.lifetime);
mtile("Light delivery",H.light_delivery);
mtile("Undulator gap",H.undulator_gap);
mtile("Beamline",H.bl_status);
mtile("Orbit feedback",M.fofb);
mtile("Injection rate",M.injection_rate);
mtile("Ring vacuum",M.ring_pressure);
document.getElementById("machine-strip").innerHTML=ms.join("");
/* 2. operator messages */
const msgs=d.bulk.messages||[];
document.getElementById("messages").innerHTML = msgs.length
? msgs.map(m=>`<div class="ev"><span class="t">${esc(m.date||"")}</span><span>${esc(m.text)}</span></div>`).join("")
: '<div class="empty">no operator messages</div>';
/* 3. alarm hero */
const hero=document.getElementById("hero");
const cnt=(H.eps_alarm_count!==undefined)?H.eps_alarm_count:null;
hero.className="hero "+d.eps_status;
let title,sub="";
if(d.eps_status==="ok"){ title="EPS OK"; sub="no active alarms"; }
else if(d.eps_status==="alarm"){ title=`EPS ALARM &mdash; ${cnt}`; sub="equipment protection system reporting active alarms"; }
else { title="EPS UNKNOWN"; sub="cannot read alarm count"; }
let hh=`<div class="hero-title">${title}</div><div class="hero-sub">${sub}</div>`;
if(H.eps_alarm_list&&String(H.eps_alarm_list).trim()&&d.eps_status==="alarm")
hh+=`<div class="hero-text">${esc(H.eps_alarm_list)}</div>`;
if(H.fe_alarm_count&&Number(H.fe_alarm_count)>0)
hh+=`<div class="hero-text">Front-end alarms: ${esc(H.fe_alarm_count)} &mdash; ${esc(H.fe_alarm_list||"")}</div>`;
if(d.eps_status==="ok"&&d.history&&d.history.eps_events){
const past=d.history.eps_events.filter(e=>e.cleared_at);
if(past.length){ const last=past[past.length-1];
hh+=`<div class="hero-sub" style="margin-top:8px">last alarm ${ago(last.cleared_at)}: ${esc((last.texts||[]).join("; ")||"(no text)")}</div>`; }
}
hero.innerHTML=hh;
/* 4. cryocooler */
const C=d.bulk.cryo||{};
const cc=[];
const runT=truthy(C.run&&C.run.value), almT=truthy(C.alarm&&C.alarm.value);
const inhT=truthy(C.inhibit&&C.inhibit.value), disT=truthy(C.disable&&C.disable.value);
cc.push(`<div class="tile"><div class="k">Run</div><div class="v ${runT?'sev-NO_ALARM':(runT===false?'sev-MINOR':'disconnected')}">${runT===null?'&mdash;':(runT?'RUNNING':'STOPPED')}</div></div>`);
cc.push(`<div class="tile"><div class="k">Alarm</div><div class="v ${almT?'sev-MAJOR':(almT===false?'sev-NO_ALARM':'disconnected')}">${almT===null?'&mdash;':(almT?'ALARM':'clear')}</div></div>`);
if(C.alarm_desc&&C.alarm_desc.value&&String(C.alarm_desc.value).trim())
cc.push(`<div class="tile" style="grid-column:span 2"><div class="k">Alarm detail</div><div class="v" style="font-size:0.95rem">${esc(C.alarm_desc.value)}</div></div>`);
cc.push(`<div class="tile"><div class="k">Inhibit</div><div class="v ${inhT?'sev-MINOR':'sev-NO_ALARM'}">${inhT===null?'&mdash;':(inhT?'INHIBIT':'no')}</div></div>`);
cc.push(`<div class="tile"><div class="k">Interlock</div><div class="v ${disT?'sev-MINOR':'sev-NO_ALARM'}">${disT===null?'&mdash;':(disT?'DISABLED':'enabled')}</div></div>`);
const hb=C.heartbeat&&C.heartbeat.connected;
cc.push(`<div class="tile"><div class="k">Heartbeat</div><div class="v ${hb?'sev-NO_ALARM':'disconnected'}">${hb?fmtNum(C.heartbeat.value):'&mdash;'}</div></div>`);
document.getElementById("cryo").innerHTML=cc.join("");
/* 6. zones */
renderZones(d);
/* 7. history */
const evs=[];
for(const e of (d.history.eps_events||[]))
evs.push({t:e.first_seen,cls:"eps",d:`EPS alarm (${e.count})${e.cleared_at?" &mdash; cleared":" &mdash; ACTIVE"}`,x:(e.texts||[]).join("; ")});
for(const e of (d.history.machine_events||[]))
evs.push({t:e.at,cls:"mach",d:e.label,x:`${e.from} \u2192 ${e.to}`});
evs.sort((a,b)=>b.t-a.t);
document.getElementById("history").innerHTML = evs.length
? evs.slice(0,5).map(e=>`<div class="ev ${e.cls}"><span class="t">${tstamp(e.t)}</span><span class="d">${e.d}</span><span>${esc(e.x||"")}</span></div>`).join("")
: '<div class="empty">no events recorded yet</div>';
document.getElementById("stamp").textContent=`updated ${ago(d.generated_at_epoch)} \u00b7 ${d.generated_at}`;
const g=d.generator;
document.getElementById("footer").textContent=
`${g.host} \u00b7 hot ${g.hot_interval_s}s / bulk ${g.bulk_interval_s}s \u00b7 ${g.pv_total} PVs, ${g.pv_unreachable} unreachable \u00b7 detail ${ago(d.bulk.updated_at_epoch)}`;
}
function renderZones(d){
const zc=[], labels=d.zone_labels||{};
for(const [zone,items] of Object.entries(d.bulk.zones||{})){
if(!items.length) continue;
let worst=0,dead=0;
for(const it of items){ if(!it.connected){dead++;continue;} worst=Math.max(worst,SEV_RANK[it.severity]??3); }
const wc=SEV_VAR[worst], live=items.length-dead;
const byRole={};
for(const it of items){ const r=it.role||"other"; (byRole[r]=byRole[r]||[]).push(it); }
const pills=Object.entries(byRole).map(([role,list])=>{
let w=0,dd=0; for(const it of list){ if(!it.connected){dd++;continue;} w=Math.max(w,SEV_RANK[it.severity]??3); }
const cl=dd===list.length?"dead":["ok","minor","major","dead"][w];
const lbl=(d.role_labels&&d.role_labels[role])||role;
return `<span class="pill ${cl}">${esc(lbl)} ${list.length-dd}/${list.length}</span>`;
}).join("");
const rows=items.map(it=>{
const cls=it.connected?"sev-"+(it.severity||"UNKNOWN"):"disconnected";
const v=it.connected?fmtNum(it.value,it.egu):"no conn";
return `<tr><td>${esc(it.label||it.role||"")}</td><td class="pv">${esc(it.pv)}</td><td class="val ${cls}">${v}</td></tr>`;
}).join("");
zc.push(`<div class="zone" style="--zone-c:${wc}">
<div class="zone-head"><span class="n">${esc(labels[zone]||zone)}</span><span class="c">${live}/${items.length}</span></div>
<div style="margin-top:8px">${pills}</div>
<details data-zone="${esc(zone)}"${openZones.has(zone)?" open":""}>
<summary>${items.length} channels</summary>
<table><thead><tr><th>Signal</th><th>PV</th><th style="text-align:right">Value</th></tr></thead><tbody>${rows}</tbody></table>
</details></div>`);
}
document.getElementById("zones").innerHTML=zc.join("")||'<div class="zone"><span class="empty">no detail data yet</span></div>';
document.querySelectorAll("#zones details[data-zone]").forEach(el=>el.addEventListener("toggle",onToggle));
}
let seeded=sessionStorage.getItem(OPEN_KEY)!==null;
async function tick(){
try{
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
if(!r.ok) throw new Error("HTTP "+r.status);
const data=await r.json();
if(!seeded){
for(const [zone,items] of Object.entries(data.bulk.zones||{}))
if(items.length&&items.length<=40) openZones.add(zone);
seeded=true; persistOpen();
}
withScroll(()=>render(data));
}catch(e){
document.getElementById("banners").innerHTML=`<div class="banner err">cannot load eps_status.json &mdash; ${esc(e.message)}</div>`;
}
}
tick();
setInterval(tick,10000);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cSAXS EPS Synoptic</title>
<style>
:root{
--mono:'Space Mono','Courier New',monospace;
--sans:'DM Sans','Helvetica Neue',Arial,sans-serif;
--c-ok:#52da3b; --c-minor:#f9e2af; --c-major:#f38ba8; --c-invalid:#6c7a9c;
--bg:#0d0f14; --surface:#161a23; --border:#2a3045; --text:#cdd6f4; --text-dim:#6c7a9c;
}
@media (prefers-color-scheme: light){
:root{ --bg:#e9edf5; --surface:#fff; --border:#c8cedf; --text:#1a1d2e; --text-dim:#5a6480;
--c-ok:#40a02b; --c-minor:#df8e1d; --c-major:#d20f39; --c-invalid:#8c8fa1; }
}
*{box-sizing:border-box;}
body{margin:0; background:var(--bg); color:var(--text); font-family:var(--sans); overflow:hidden;}
header{ display:flex; align-items:center; gap:14px; padding:8px 14px; background:var(--surface);
border-bottom:1px solid var(--border); flex-wrap:wrap; }
header h1{ font-size:1rem; margin:0; font-weight:600; }
header .meta{ font-family:var(--mono); font-size:0.74rem; color:var(--text-dim); }
header a{ color:var(--c-ok); text-decoration:none; font-size:0.8rem; }
.controls{ margin-left:auto; display:flex; gap:6px; }
button{ font-family:var(--mono); font-size:0.8rem; background:var(--surface); color:var(--text);
border:1px solid var(--border); border-radius:6px; padding:4px 10px; cursor:pointer; }
button:hover{ border-color:var(--c-ok); }
#stage{ position:absolute; top:47px; left:0; right:0; bottom:0; overflow:hidden; cursor:grab; touch-action:none; }
#stage.grabbing{ cursor:grabbing; }
#vp{ transform-origin:0 0; }
svg{ display:block; }
.banner{ position:absolute; top:52px; left:50%; transform:translateX(-50%); z-index:5;
font-family:var(--mono); font-size:0.8rem; padding:6px 14px; border-radius:6px; font-weight:700; }
.banner.demo{ background:#cba6f7; color:#000; } .banner.err{ background:var(--c-major); color:#fff; }
.hint{ position:absolute; bottom:10px; left:50%; transform:translateX(-50%); z-index:5;
font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); background:var(--surface);
padding:4px 10px; border-radius:6px; border:1px solid var(--border); opacity:0.85; }
</style>
</head>
<body>
<header>
<h1>cSAXS EPS Synoptic</h1>
<span class="meta" id="stamp">loading&hellip;</span>
<a href="eps_status.html">&larr; status overview</a>
<div class="controls">
<button id="zin">+</button><button id="zout">&minus;</button><button id="zfit">fit</button>
</div>
</header>
<div id="stage"><div id="vp"></div></div>
<div class="hint">drag to pan &middot; scroll or pinch to zoom &middot; hover a component for its PV</div>
<script>
"use strict";
const SEV_RANK={NO_ALARM:0,MINOR:1,MAJOR:2,INVALID:3,UNKNOWN:3};
const SEV_COL={0:"var(--c-ok)",1:"var(--c-minor)",2:"var(--c-major)",3:"var(--c-invalid)"};
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));}
function fmtNum(v){
if(v===null||v===undefined) return "-";
if(typeof v==="number"){ const a=Math.abs(v);
if(a!==0&&(a<1e-3||a>=1e5)) return v.toExponential(1);
return (Math.round(v*100)/100).toString(); }
return String(v);
}
function ago(e){ if(!e) return "never"; const d=Date.now()/1000-e;
if(d<60) return Math.round(d)+"s ago"; if(d<3600) return Math.round(d/60)+"m ago"; return Math.round(d/3600)+"h ago"; }
let svgEl=null, byPv=new Map();
async function loadSvg(){
const r=await fetch("eps_synoptic.svg?t="+Date.now(),{cache:"no-store"});
const txt=await r.text();
document.getElementById("vp").innerHTML=txt;
svgEl=document.getElementById("eps-synoptic-svg");
// index nodes by pv
byPv.clear();
svgEl.querySelectorAll("[data-pv]").forEach(n=>{
const pv=n.getAttribute("data-pv");
if(!byPv.has(pv)) byPv.set(pv,[]);
byPv.get(pv).push(n);
// tooltip
const tt=document.createElementNS("http://www.w3.org/2000/svg","title");
tt.textContent=pv; n.appendChild(tt);
});
fit();
}
function applyData(d){
if(!svgEl) return;
const map={};
const collect=rec=>{ if(rec&&rec.pv) map[rec.pv]=rec; };
const bulk=d.bulk||{};
(bulk.synoptic||[]).forEach(collect);
for(const z of Object.values(bulk.zones||{})) z.forEach(collect);
Object.values(bulk.machine||{}).forEach(collect);
Object.values(bulk.cryo||{}).forEach(collect);
// synoptic-only channels (valve PLC_OPEN/PLC_CLOSE, setpoints, cooling calc)
// keyed by PV name; these drive the visibility rules
const sv=bulk.synoptic_values||{};
for(const pv in sv){ if(!map[pv]) map[pv]={pv, ...sv[pv]}; }
const hot=d.hot||{};
// numeric value of a PV from whatever slice has it
const pvVal=pv=>{
if(!pv) return null;
const rec=map[pv]||map[pv.split(".")[0]];
return rec?rec.value:null;
};
// 1. visibility rules (valves open/closed, error flags, cooling locks, etc.)
const truth=v=>{ if(v===null||v===undefined) return null;
if(typeof v==="number") return v!==0;
const s=String(v).trim().toLowerCase();
return !["0","false","off","closed","no",""].includes(s); };
// evaluate a caQtDM visibilityCalc like "(A=0)||(B=0)||(C=0)" or "!(A=1&&B=1)".
// A..D map to the numeric values of data-vis-pv..pvd. Returns bool (default show).
const evalCalc=(expr,vals)=>{
if(!expr) return true;
let js=expr;
// map channel letters to numeric literals (A=vals[0] ...)
js=js.replace(/\b([A-D])\b/g,(m,L)=>{ const v=vals[L.charCodeAt(0)-65];
return (typeof v==="number")?String(v):(truth(v)?"1":"0"); });
// caQtDM operators -> JS
js=js.replace(/([^<>!=])=([^=])/g,"$1==$2") // single = to ==
.replace(/&&/g,"&&").replace(/\|\|/g,"||");
// allow only safe characters
if(!/^[\d\s()!=<>&|.+\-*/]+$/.test(js)) return true;
try{ return !!Function('"use strict";return ('+js+')')(); }
catch(e){ return true; }
};
svgEl.querySelectorAll("[data-vis]").forEach(n=>{
const kind=n.getAttribute("data-vis");
const a=pvVal(n.getAttribute("data-vis-pv"));
let show=true;
if(kind==="ifzero"){ const t=truth(a); show=(t===false); }
else if(kind==="ifnotzero"){ const t=truth(a); show=(t===true); }
else if(kind==="calc"){
const vals=[a, pvVal(n.getAttribute("data-vis-pvb")),
pvVal(n.getAttribute("data-vis-pvc")),
pvVal(n.getAttribute("data-vis-pvd"))];
// if the rule needs channels we have no data for, leave visible
show=evalCalc(n.getAttribute("data-vis-calc"), vals);
}
n.style.display = show ? "" : "none";
});
// 2. recolor alarm shapes + fill value text
// Skip shapes that carry a visibility rule: their colour is intrinsic
// (green=open, red=closed) and must not be overridden by severity.
byPv.forEach((nodes,pv)=>{
const base=pv.split(".")[0];
const rec=map[pv]||map[base];
for(const n of nodes){
if(n.tagName==="text"){
let val="-",conn=false,sev="UNKNOWN";
if(rec){ val=fmtNum(rec.value); conn=rec.connected; sev=rec.severity; }
n.textContent=val;
if(n.getAttribute("data-alarm")==="1")
n.setAttribute("fill", conn?SEV_COL[SEV_RANK[sev]??3]:"var(--c-invalid)");
} else if(!n.hasAttribute("data-vis")){
if(rec) n.setAttribute("fill", SEV_COL[rec.connected?(SEV_RANK[rec.severity]??3):3]);
else n.setAttribute("fill","var(--c-invalid)");
}
}
});
const banners=[];
if(d.demo) banners.push('<div class="banner demo">DEMO &mdash; synthetic values</div>');
if(d.bulk.stale&&!d.demo) banners.push('<div class="banner err">detail readings stale</div>');
document.querySelectorAll(".banner").forEach(b=>b.remove());
banners.forEach(h=>document.body.insertAdjacentHTML("afterbegin",h));
document.getElementById("stamp").textContent=`updated ${ago(d.generated_at_epoch)} \u00b7 ${d.generator.pv_unreachable} unreachable`;
}
/* ---- pan / zoom ---- */
let scale=1, tx=0, ty=0;
const vp=document.getElementById("vp"), stage=document.getElementById("stage");
function apply(){ vp.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`; }
function fit(){
if(!svgEl) return;
const vb=svgEl.viewBox.baseVal, sr=stage.getBoundingClientRect();
scale=Math.min(sr.width/vb.width, sr.height/vb.height)*0.98;
tx=(sr.width-vb.width*scale)/2; ty=(sr.height-vb.height*scale)/2;
svgEl.setAttribute("width",vb.width); svgEl.setAttribute("height",vb.height);
apply();
}
function zoomAt(cx,cy,factor){
const nx=cx-(cx-tx)*factor, ny=cy-(cy-ty)*factor;
scale*=factor; tx=nx; ty=ny; apply();
}
stage.addEventListener("wheel",e=>{ e.preventDefault();
const r=stage.getBoundingClientRect();
zoomAt(e.clientX-r.left,e.clientY-r.top, e.deltaY<0?1.12:0.89); },{passive:false});
let drag=null;
stage.addEventListener("pointerdown",e=>{ drag={x:e.clientX-tx,y:e.clientY-ty}; stage.classList.add("grabbing"); stage.setPointerCapture(e.pointerId); });
stage.addEventListener("pointermove",e=>{ if(!drag) return; tx=e.clientX-drag.x; ty=e.clientY-drag.y; apply(); });
stage.addEventListener("pointerup",e=>{ drag=null; stage.classList.remove("grabbing"); });
// pinch
let pts=new Map(), pd=0;
stage.addEventListener("pointerdown",e=>pts.set(e.pointerId,e));
stage.addEventListener("pointermove",e=>{
if(!pts.has(e.pointerId)) return; pts.set(e.pointerId,e);
if(pts.size===2){ const[a,b]=[...pts.values()]; const nd=Math.hypot(a.clientX-b.clientX,a.clientY-b.clientY);
if(pd){ const r=stage.getBoundingClientRect(); zoomAt((a.clientX+b.clientX)/2-r.left,(a.clientY+b.clientY)/2-r.top, nd/pd); } pd=nd; drag=null; }
});
stage.addEventListener("pointerup",e=>{ pts.delete(e.pointerId); if(pts.size<2) pd=0; });
document.getElementById("zin").onclick=()=>{ const r=stage.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,1.2); };
document.getElementById("zout").onclick=()=>{ const r=stage.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,0.83); };
document.getElementById("zfit").onclick=fit;
window.addEventListener("resize",fit);
async function tick(){
try{
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
if(r.ok) applyData(await r.json());
}catch(e){}
}
(async()=>{ await loadSvg(); await tick(); setInterval(tick,10000); })();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 160 KiB

@@ -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",
]
@@ -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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
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'<line x1="{s["x1"]}" y1="{s["y1"]}" x2="{s["x2"]}" '
f'y2="{s["y2"]}" stroke="{s["stroke"]}" stroke-width="{s["width"]}"'
f'{pv}{vis}/>')
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'<ellipse cx="{s["cx"]:.1f}" cy="{s["cy"]:.1f}" rx="{s["rx"]:.1f}" '
f'ry="{s["ry"]:.1f}" fill="{s["fill"]}"{pv}{vis}/>')
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'<rect x="{s["x"]}" y="{s["y"]}" width="{s["w"]}" '
f'height="{s["h"]}"{body}{pv}{vis}/>')
return ""
def build_svg(ui_path):
static, dynamic, texts, labels = transpile(ui_path)
out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {CANVAS_W} '
f'{CANVAS_H}" preserveAspectRatio="xMidYMid meet" id="eps-synoptic-svg">']
out.append('<g id="syn-static">')
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'<text x="{cx:.0f}" y="{cy:.0f}" text-anchor="middle" '
f'font-family="var(--syn-font,monospace)" font-size="11" '
f'fill="{LABEL_COL}"{vis}>{_esc(lb["text"])}</text>')
out.append('</g>')
out.append('<g id="syn-dynamic">')
out.extend(_svg_shape(s) for s in dynamic)
out.append('</g>')
# value fields: backing box + text, larger for readability
out.append('<g id="syn-text">')
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'<g{vis}>')
out.append(f'<rect x="{bx}" y="{by}" width="{bw}" height="{bh}" '
f'rx="2" fill="#000" opacity="0.55"/>')
out.append(f'<text x="{cx:.0f}" y="{cy:.0f}" text-anchor="middle" '
f'font-family="var(--syn-font,monospace)" font-size="12" '
f'font-weight="700" data-pv="{_esc(t["pv"])}" '
f'data-alarm="{"1" if t["alarm"] else "0"}" fill="#cdd6f4">-</text>')
out.append('</g>')
out.append('</g>')
out.append('</svg>')
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()
@@ -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}"
@@ -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 <img onerror>
# 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 = (
'<a class="theme-btn" href="eps_status.html" '
'title="Beamline / machine status">EPS</a>'
if has_eps_status else ""
)
phones_html = "\n".join(
f' <div class="phone-row">'
f'<span class="phone-label">{label}</span>'
@@ -2138,6 +1974,7 @@ def _render_html(
<span class="logo-account" id="active-account"></span>
</div>
<div class="header-right">
{eps_link_html}
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
<div class="theme-switcher">
<span class="ts-label">Theme</span>