feat(lamni,flomni): finish lamni webpage generator, share base via OMNY_shared

Extracts WebpageGeneratorBase, HttpUploader, LocalHttpServer, and
make_webpage_generator() out of flomni_webpage_generator.py into
OMNY_shared/webpage_generator_base.py, mirroring the tomo_queue_mixin
precedent, then finishes LamNI_webpage_generator.py (fixes its broken
import, corrects TOMO_TYPES to match lamni's real 3-mode tomo_scan(),
enables HAS_TOMO_QUEUE now that the queue mixin backs it, adds lamni's
own TQ_PARAM_DISPLAY, implements _collect_setup_data()) and wires it
into LamNI.__init__.

Also fixes a real bug surfaced by running two setups against the same
shared status-page directory: each setup's logo is now copied to its
own filename (e.g. flOMNI.png / LamNI.png) instead of a shared
logo.png, since a fixed filename let a browser serve a stale, cached
logo from the other instrument's prior session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-16 15:36:39 +02:00
co-authored by Claude Sonnet 5
parent 6744908983
commit 940d1c5d75
6 changed files with 3172 additions and 3005 deletions
@@ -1,11 +1,11 @@
"""
LamNI/webpage_generator.py
===========================
LamNI/LamNI_webpage_generator.py
==================================
LamNI-specific webpage generator subclass.
Integration (inside the LamNI __init__ / startup):
---------------------------------------------------
from csaxs_bec.bec_ipython_client.plugins.LamNI.webpage_generator import (
from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import (
LamniWebpageGenerator,
)
self._webpage_gen = LamniWebpageGenerator(
@@ -16,7 +16,7 @@ Integration (inside the LamNI __init__ / startup):
Or use the factory (auto-selects by session name "lamni"):
----------------------------------------------------------
from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import (
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import (
make_webpage_generator,
)
self._webpage_gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/")
@@ -32,11 +32,8 @@ Interactive helpers:
from pathlib import Path
from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import (
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import (
WebpageGeneratorBase,
_safe_get,
_safe_float,
_gvar,
)
@@ -49,57 +46,92 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
sample name, and measurement settings.
"""
# LamNI has no persisted parameter-snapshot job queue (unlike flomni
# and, later, omny) — the "Tomo queue" card and its global-var read
# are skipped entirely for this setup.
HAS_TOMO_QUEUE = False
# LamNI's tomo-queue jobs (added via TomoQueueMixin, see
# OMNY_shared/tomo_queue_mixin.py) are persisted under the same
# "tomo_queue" global var key flomni uses, so the shared "Tomo queue"
# UI card and its global-var read work unmodified for this setup too.
HAS_TOMO_QUEUE = True
# LamNI's tomo scans come in two flavours: a 2-subtomo and an
# 8-subtomo variant, both using the same equally-spaced-grid formula
# as flomni's type 1 (just with a different sub-tomo count).
# TODO: replace the placeholder keys (1, 2) with whatever values
# progress["tomo_type"] actually takes for LamNI once the producer
# side (LamNI equivalent of flomni.py) defines them.
# LamNI's tomo_scan() (lamni.py) implements the same 3-mode scheme as
# flomni: type 1 = equally-spaced grid, always 8 sub-tomograms
# (`for ii in range(subtomo_start, 9)`), types 2 and 3 = golden-ratio
# variants capped by golden_max_number_of_projections. Identical to
# FlomniWebpageGenerator's TOMO_TYPES -- confirmed against lamni.py's
# actual tomo_scan()/sub_tomo_scan() implementation, not a guess.
TOMO_TYPES = {
1: {"kind": "equally_spaced_grid", "n_subtomos": 2},
2: {"kind": "equally_spaced_grid", "n_subtomos": 8},
1: {"kind": "equally_spaced_grid", "n_subtomos": 8},
2: {"kind": "golden_capped"},
3: {"kind": "golden_capped"},
}
# TODO: fill in LamNI-specific device paths
# LamNI's own names for the tomo-queue job / "Current measurement" param
# table, mirroring LamNI._TOMO_SCAN_PARAM_NAMES (lamni.py) rather than
# flomni's fovx/fovy, stitch_x/y (lamni is laminography: a single
# circular FOV, no 180 mode, no single-point acquisition).
TQ_PARAM_DISPLAY = [
("tomo_type", "Type"),
("tomo_countingtime", "Exposure (s)"),
("tomo_circfov", "Circular FOV (µm)"),
("tomo_angle_stepsize", "Angle step (°)"),
("tomo_shellstep", "Shell step (µm)"),
("lamni_stitch_x", "Stitch x"),
("lamni_stitch_y", "Stitch y"),
("tomo_stitch_overlap", "Stitch overlap"),
("corridor_size", "Corridor size"),
("frames_per_trigger", "Frames/trigger"),
("golden_ratio_bunch_size", "Golden bunch size"),
("golden_max_number_of_projections", "Golden max projections"),
("golden_projections_at_0_deg_for_damage_estimation", "0° damage-check shots"),
("ptycho_reconstruct_foldername", "Recon folder"),
]
# Global-var names of the tomo parameters to read live for the
# 'Current measurement' section -- the TQ_PARAM_DISPLAY keys plus
# at_each_angle_hook (rendered specially, only when set; see
# buildParamRows() in the shared HTML/JS template). Any name that is
# not actually a global var is skipped silently and just does not
# appear as a row.
_CURRENT_PARAM_KEYS = [key for key, _label in TQ_PARAM_DISPLAY] + ["at_each_angle_hook"]
# LamNI has no temperature/humidity monitoring device configured
# (unlike flomni's flomni_temphum.*) -- leave empty until one exists.
# label -> dotpath under device_manager.devices
_TEMP_MAP = {
# "Sample": "lamni_temphum.temperature_sample",
# "OSA": "lamni_temphum.temperature_osa",
}
_TEMP_MAP = {}
def _logo_path(self):
return Path(__file__).parent / "LamNI.png"
def _collect_setup_data(self) -> dict:
# ── LamNI-specific data goes here ─────────────────────────────
# Uncomment and adapt when device names are known:
#
# dm = self._bec.device_manager
# sample_name = _safe_get(dm, "lamni_samples.sample_names.sample0") or "N/A"
# temperatures = {
# label: _safe_float(_safe_get(dm, path))
# for label, path in self._TEMP_MAP.items()
# }
# settings = {
# "Sample name": sample_name,
# "FOV x / y": ...,
# "Exposure time": _gvar(self._bec, "tomo_countingtime", ".3f", " s"),
# "Angle step": _gvar(self._bec, "tomo_angle_stepsize", ".2f", "\u00b0"),
# }
# return {
# "type": "lamni",
# "sample_name": sample_name,
# "temperatures": temperatures,
# "settings": settings,
# }
g = self._bec
# ── Sample name ───────────────────────────────────────────────
# LamNI has no automatic sample changer/device (unlike flomni's
# gripper-backed flomni_samples); the active sample is tracked
# manually via the sample_name global var (see LamNI.sample_name
# property in lamni.py), same default as that property uses.
sample_name = g.get_global_var("sample_name") or "bec_test_sample"
# ── Temperatures ──────────────────────────────────────────────
# No lamni temp/humidity device exists yet -- always empty.
temperatures = {}
# ── Current measurement parameters (read live) ────────────────
# Same keys the tomo queue stores in job.params, so the 'Current
# measurement' section on the page renders through the identical
# param-table path (buildParamRows -> fmtTqParam / calcProjections)
# and always matches the queue's level of detail.
current_params = {}
for key in self._CURRENT_PARAM_KEYS:
try:
val = g.get_global_var(key)
except Exception:
val = None
if val is not None:
current_params[key] = val
# Placeholder — returns minimal info until implemented
return {
"type": "lamni",
# LamNI-specific data here
}
"type": "lamni",
"sample_name": sample_name,
"temperatures": temperatures,
"current_params": current_params,
}
@@ -218,6 +218,22 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self._progress_proxy = _ProgressProxy(self.client)
self._init_tomo_queue()
from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import (
LamniWebpageGenerator,
)
self._webpage_gen = LamniWebpageGenerator(
bec_client=client,
output_dir="~/data/raw/webpage/",
upload_url="https://v1p0zyg2w9n2k9c1.myfritz.net/upload.php",
local_port=8080,
)
self._webpage_gen.start()
def set_web_password(self, password: str) -> None:
"""Set the web password for the current BEC account."""
self._webpage_gen.set_web_password(password)
# ------------------------------------------------------------------
# Special angles
# ------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,10 @@ which flomni features (GUI, status webpage, tomo-parameter widget, angle
distribution) are worth porting to lamni. This is a comparison only — no
migration had been implemented yet as of when it was originally written.
**Status update (2026-07-16): items 24 of §5's recommended order are now
done.** Only **item 1, the webpage generator (§4), remains open** — the rest
of this document (originally a forward-looking comparison/plan) is now a
historical snapshot of the *starting* gap, not the current state. See:
**Status update (2026-07-16): all of §5's recommended order (items 14) is
now done**, including the webpage generator (§4) — the rest of this document
(originally a forward-looking comparison/plan) is now a historical snapshot
of the *starting* gap, not the current state. See:
- `csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md`
tomo-queue/job-list backend ported to lamni via a shared `TomoQueueMixin`
@@ -19,11 +19,27 @@ historical snapshot of the *starting* gap, not the current state. See:
button), plus several real `lamni.py` bugs found and fixed along the way
(two `sub_tomo_scan()` angle bugs, three `tomo_scan()`/`write_pdf_report()`
crashes, progress-GUI-auto-show + stale-busy-heartbeat).
- **Remaining**: `LamNI_webpage_generator.py` is still the stub described in
§4 below — `_collect_setup_data()` commented out, `_TEMP_MAP` empty,
`HAS_TOMO_QUEUE = False` (now incorrect — a queue backend exists, see
above), `TOMO_TYPES` placeholders, and never wired into
`lamni.py.__init__`. §4's 5-step plan is otherwise still accurate.
- **Webpage generator (§4) — done.** The shared base
(`WebpageGeneratorBase`, `HttpUploader`, `LocalHttpServer`,
`make_webpage_generator()`, the HTML/JS template) was extracted out of
`flomni/flomni_webpage_generator.py` into
`OMNY_shared/webpage_generator_base.py`, mirroring the tomo-queue-mixin
precedent — `flomni_webpage_generator.py` now only holds
`FlomniWebpageGenerator`. `LamNI_webpage_generator.py`'s broken import
(`flomni.webpage_generator`, a module that never existed) is fixed to
import from the shared module. `TOMO_TYPES` confirmed identical to
flomni's 3-mode dict by reading `lamni.py`'s actual `tomo_scan()`.
`HAS_TOMO_QUEUE` is now `True` (the tomo-queue mixin backs it). The HTML/JS
template's tomo-queue param-display table (`TQ_PARAM_DISPLAY`), previously
hardcoded to flomni's param names, is now a per-subclass override the same
way `TOMO_TYPES` already was, so lamni's page shows its own param names
(`tomo_circfov`, `lamni_stitch_x/y`, ...). Sample name comes from lamni's
`sample_name` global var (no automatic sample changer exists on lamni, so
no device-backed lookup); temperatures stay empty (no lamni temp/humidity
device configured yet). Wired into `LamNI.__init__` the same way
`FlomniWebpageGenerator` is wired into `Flomni.__init__`. omny was left
untouched (`omny_webpage_generator.py` remains dead/unwired duplicate
code — separate future task if omny ever needs a real one).
- Also still explicitly deferred (confirmed with Mirko, separate future
tasks): `zero_deg_reference_at_each_subtomo` for lamni (not implemented at
all yet), and sharing angle-calculation code between flomni/lamni
@@ -32,14 +32,6 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError
from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc
# from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import (
# FlomniWebpageGenerator,
# VERBOSITY_SILENT, # 0 — no output
# VERBOSITY_NORMAL, # 1 — startup / stop messages only (default)
# VERBOSITY_VERBOSE, # 2 — one-line summary per cycle
# VERBOSITY_DEBUG, # 3 — full JSON payload per cycle
# )
logger = bec_logger.logger
if builtins.__dict__.get("bec") is not None:
File diff suppressed because it is too large Load Diff