Commit Graph
881 Commits
Author SHA1 Message Date
lemke_handClaude Sonnet 5 35c4e6bbd5 Add eco.ipymagic: opt-in inline component picker triggered by ...
from eco import bernina
    from eco import ipymagic
    ipymagic.start(bernina)

    In [1]: (1 / ...).plot()
    (1 / bernina.mono.energy).plot()
    <plot appears>

    In [2]: cen = (left := ...) - (right := ...)
    cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission)

A bare `...` (the builtin Ellipsis literal -- not an IPython special form,
so this works anywhere IPython's own ast_transformers hook runs: terminal,
notebook/JupyterLab, qtconsole) is replaced, before execution, by the
picked component's real dotted path -- the expanded code is printed, then
run, so what actually executed stays visible and re-typeable. Multiple
`...` in one statement resolve left to right. A `...` used as a numpy
subscript (`arr[...]`) is left alone. Reuses eco.widgets.component_selector
for the actual tree/fold-state/Recent-list machinery -- nothing new there.

New eco.widgets.component_selector_qt.pick_component_modal(): the existing
ComponentSelectorQtWindow is built for embedding/async display (start()
hands off to IPython's own Qt pumping, or run() blocks the whole process
forever); an AST transform needs an actual answer before it can even
return control to IPython, so this drives the same window with its own
nested QEventLoop that quits the instant a pick (or a close) happens.

Also fixed a real bug found while testing this live: IPython's
ast_transformers calls `.visit(node)` on each registered entry (expects
an ast.NodeTransformer-shaped object) -- a bare function registered there
throws 'function' object has no attribute 'visit' once, and IPython
*silently* unregisters it with only a warning, no visible error. Verified
end-to-end against a real Qt window on a real X11 display (Xvfb) plus a
real IPython shell: spawned the picker, drove a real selection via
QTimer, confirmed the printed/executed code and result were both correct.

Deliberately namespace-tree-only for now -- picking from prior *commands*
(eco.logs' kernel history) is a planned second mode, not built here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:34:41 +02:00
lemke_handClaude Sonnet 5 77a40edd15 Add eco.globallogger: opt-in command logging for library use
For "from eco import bernina" with no startup script involved at all --
the same per-session JSONL activity log every interactive entry point
already gets (eco.widgets.kernel_registry.install_shell_logger), exposed
as one explicit opt-in call instead of only happening as a side effect of
going through a startup script:

    from eco import bernina
    from eco import globallogger
    globallogger.start()

Deliberately opt-in, not automatic on `import eco` -- a plain library
import (a short script, a notebook cell) shouldn't get a
~/.eco/kernel_logs/*.jsonl file appearing on disk just from importing.
Defaults the session label to the caller's resolved identity (eco.
elements.access.current_identity) rather than a generic tag, so two
people on a shared account still end up with distinguishable logs -- the
same "global user based" reasoning eco.elements.recent already applies to
the per-user Recent-components list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:34:25 +02:00
lemke_handClaude Sonnet 5 12d70469cd Collapse the duplicate startup_inline.py into eco/startup_inline.py
There were two full copies of the interactive startup script: eco/
startup_inline.py (the package copy, run via `eco_cli.py`'s `run -m eco.
startup_inline`, used by both the eco console-script and scripts/eco-dev)
and a second copy at the repo root, execed by startup_inline_new.py -- the
back-compat shim the real /sf/bernina/bin/{eco,eco_new,eco-in,eco_from}
wrappers hardcode by absolute path (root-owned, not editable here).

The two had already drifted: the root copy was silently missing the
kernel_registry.install_shell_logger() call the package copy had gained,
so every session started the normal way at a console (the actual daily
entry point) was never logged at all, despite eco.logs' viewer assuming
every console session has a log. Repointed the shim to exec eco/
startup_inline.py directly instead of the sibling root copy, and deleted
the now-fully-redundant root copy -- one real script left to maintain, so
this class of drift can't recur.

Verified live: ran a real ipython session through startup_inline_new.py
exactly as the /sf/bernina/bin wrappers do (`run startup_inline_new.py -l
-s bernina`), confirmed a fresh ~/.eco/kernel_logs/*.jsonl appeared with
the probe command actually logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:24:09 +02:00
lemke_handClaude Sonnet 5 34f5a431c4 Make every escape.stream.Stream .scans-able directly; auto-plot for 1-D BsStreamCounter scans
install_stream_scans() monkey-patches escape.stream.Stream with the
same lazy .scans property bs_scannable already gives DetectorBsStream
(factored the shared body into _make_scans_property()) -- so any
Stream instance anywhere in eco, built through DetectorBsStream,
directly via escape.stream.Stream(...), or produced by Stream
arithmetic (a / b, .element(0), ...), can be scanned with
some_stream.scans.ascan(...)/.meshscan(...) immediately, no wrapping
needed. Called once, idempotently, from
detectors_psi._ensure_bs_event_worker() -- the earliest reliable
"this session touches bs streams" signal, since essentially every real
Bernina device constructs a DetectorBsStream well before any
interactive command -- and from this module's own import.

Also fixes the actual reason a 1-D BsStreamCounter scan never showed a
live plot: it never had any plotting wired in at all (CounterValue, the
older EPICS-monitor-based counter, always did). BsStreamCounter now
has live_plot=True by default: for a single-channel, non-grid counter,
_on_scan_start() calls plot_med() on the same already-accumulating
per-step Stream it uses internally (escape.stream.Stream.plot_med(),
median + percentile bands + peak overlay) -- costs nothing beyond
drawing, since the data is already flowing either way. _on_scan_end()
does one final replot() then stops the redraw timer (not
accumulate(False) -- this counter never tears down its channel
subscription mid-session, see _build_bins()'s docstring), so the
scan's true final result stays visible.

Also fixed a small bug this surfaced: _StepIndexSource (the synthetic
Scan parameter BsStreamCounter's binning is built on) was missing a
.unit attribute that Plot.plot()'s axis auto-labeling assumes any scan
parameter has.

Verified against a real scalar bs channel (SAROP21-PBPS133:INTENSITY)
with a real DummyAdjustable-driven ascan: both a bare
escape.stream.Stream never touched by any eco wrapper class, and the
existing DetectorBsTest.scans path, now build a real live Plot
automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:48:12 +02:00
lemke_handClaude Sonnet 5 929bf9ccc3 Collapse the camera viewer's Settings dock by default when embedded
Investigated the "camera panels don't dock nicely" report live: spawned
real demo viewers under a real X11 display, docked them into an actual
EcoDesktopApp via the production embed path, and screenshotted several
container sizes. The image itself always scaled correctly (Fit zoom mode)
-- the actual cost was the always-open Settings dock (up to 400px), which
in a multi-camera grid routinely ate most or all of a squeezed camera's
own share of the width, leaving the image a sliver and pushing toolbar
rows into overflow.

Defaulting Settings to collapsed only when embed=True (standalone windows
keep the previous open-by-default behavior, where the ~780px this was
tuned for is actually available) reclaims that space immediately. Verified
live: re-ran the same two-camera-grid scenarios after the change -- both
cameras now get a proper, usable image area instead of one being reduced
to a sliver.

Remaining, out of scope for this fix: toolbar rows can still overflow in
a narrow dock (QToolBar's own overflow chevron, already flagged elsewhere
in this file as unreliable) -- a follow-up would need icon-only toolbar
buttons or an explicit "More" menu, which needs real icons added for
several text-only actions first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:38:03 +02:00
lemke_handClaude Sonnet 5 d814519e0b BsStreamCounter: real N-D (grid) binning for mesh/grid scans, plus a .grid() accessor
self._step_sources is now a list (one _StepIndexSource per grid
dimension, sized from scan.grid_specs in _on_scan_start -- 1 for an
ordinary linear scan, matching the previous behavior exactly), and
_advance_step() sets each dimension's value from
grid_specs["index_plan"][step_index] instead of the flat step_index
when the scan has one. Scan() already keys bins on the tuple of however
many parameters it's given (see escape.stream.Grid, added earlier),
so this is the whole mechanism N-D binning needed -- the existing
linear step_index still indexes into DataManager._data unchanged,
since a real StepScan/meshscan visits each grid cell exactly once, in
index_plan order, so scan._values[step_index] == index_plan[step_index]
holds the same way the plain 1-D case always relied on.

New BsStreamCounter.grid(channel=None, shape=None, positions=None,
dimension_names=None) wraps one channel as a live escape.stream.Grid.
Works both while a grid scan is still running (reads self._channels)
and after it finishes (reads a new self._last_channels/_last_scan/
_last_grid_specs snapshot, taken in _on_scan_end() right before
_build_bins() replaces the just-finished scan's bins with fresh, empty
ones for standalone use) -- confirmed this snapshot is actually needed:
without it, grid() after a completed meshscan saw nothing, even though
last_value(s) (an already-extracted plain value, not a bin reference)
reported the final step fine.

Verified against real dispatcher channels with real DummyAdjustable-
driven scans: a 5-step ascan against JF01T03V01:roi_intensities;
a 3x3 meshscan against SAROP21-PBPS133:INTENSITY end to end through
.grid().mean(plot=True) (a real live heatmap, no NaNs); and a 3x3
meshscan against JF01T03V01:roi_intensities itself (array-valued, 2
ROIs) surfacing a real, separate escape.stream.Grid bug fixed alongside
this (see the escape-fel commit) rather than silently producing an
all-NaN grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:30:13 +02:00
lemke_handClaude Sonnet 5 16a0cb0faf Remove redundant det_jf; add dummy 2D test fixture; fix mesh-scan repetitions crash; add eco-escape-test
- Remove det_jf: now that det_diff was fixed to JF01T03V01 (previous
  commit), det_jf was a second device pointing at the same physical
  Jungfrau, otherwise unreferenced anywhere.

- Add bernina.dummy (DummyMexicanHat): x, y (DummyAdjustable) + imex
  (DetectorGet reading a live 2D Mexican-hat signal of their current
  values, (1-r^2)*exp(-r^2/2), r=sqrt(x^2+y^2) -- stays within [-1, 1]
  for any x, y, small Gaussian noise by default). No real hardware;
  for testing grid-scan / live 2D counter-grid machinery.

- Fix Scans.meshscan(..., repetitions=N>1): grid_specs["index_plan"] is
  built once, for a single pass over the grid, before StepScan.__init__
  repeats `values`/`pulses_per_step` by `repetitions` -- so
  do_next_step()'s gridspecs["index_plan"][self.next_step] indexed past
  the end (IndexError) the moment the scan entered its second
  repetition. Now repeats index_plan the same way.

- Add scripts/eco-escape-test: like eco-dev, but also prepends a sibling
  escape-fel checkout to PYTHONPATH (assumed to live next to this eco
  checkout), for testing local checkouts of both together instead of
  whatever's installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:30 +02:00
lemke_handClaude Sonnet 5 e3eb28a146 Fix det_diff's Jungfrau id: JF07T32V01 -> JF01T03V01 (stale vs. live config)
bernina_config.json (the live, persisted config_bernina store) has both
xrd_config.jfs and robot_config.jf_id/jf_name saying det_diff is
JF01T03V01, but bernina.py had it hardcoded to JF07T32V01. Confirmed
JF01T03V01:roi_intensities is currently live on the dispatcher while
JF07T32V01's is not -- the physical detector assignment moved and this
hardcoded id never got updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 11:44:11 +02:00
lemke_handClaude Sonnet 5 5c76c6f0e2 Show a starting-up placeholder in the embedded viewer window
EmbeddedProcessWindow's body stayed completely blank while the spawned
viewer subprocess was still starting up (eco's own first import alone is
~10-15s) -- indistinguishable from a hang. Now shows a note explaining
what's happening, plus how to skip the wait (separate_process=False) and
what that costs (the viewer then freezes during any blocking call in this
session). Cleared automatically by whichever of embed()/
show_floating_note()/show_error() resolves next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:09:51 +02:00
lemke_handClaude Sonnet 5 2222eb5572 Camera viewers no longer dock by default
CameraBasler/CameraPCO._default_dock_in was True, so the plain
default-widget path (e.g. starting eco and opening a camera viewer) always
docked into a shared EcoDesktopApp workbench, auto-creating one if none
was open -- surprising for anyone not asking for that. Reverted to None
(standalone, matching Assembly's own base default); widget(dock_in=True)
still opts in explicitly and still auto-creates the workbench container
if none exists yet, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:59:23 +02:00
lemke_handClaude Sonnet 5 243aeeb962 Fix two test bugs found while verifying today's work
test_channel_lists.py: test_compare_reports_missing_and_exceeding asserted
set(result) == set(CHANNEL_LIST_CHANNELTYPES) (expecting channels_BS/
channels_BSCAM present) right next to asserting those same two keys are
*not* in result -- self-contradictory. compare_channel_lists() only ever
reports a list name that was actually present in `recorded`, matching the
module's own docstring; fixed the assertion to match.

test_cameras_swissfel.py: test_camera_basler_viewer_separate_process_passes_
name_and_cam_class called CameraBasler._widget_viewer as an unbound method
against a bare fake `self` that didn't carry _CAM_CLASS_PATH, so
getattr(self, "_CAM_CLASS_PATH", None) read None instead of the real
class's value. Added _CAM_CLASS_PATH to the fake to match what a real
CameraBasler instance would have.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:34:09 +02:00
lemke_h 49b42f3259 Merge remote-tracking branch 'origin/master' 2026-09-11 18:28:59 +02:00
Henrik Lemke c5d6392bbb gui stuff 2026-09-11 18:26:05 +02:00
lemke_h b99c4573d6 intermediate 2026-09-11 18:25:50 +02:00
Henrik Lemke 296597d15a testing 2026-09-11 17:59:48 +02:00
Henrik Lemke e001165931 add bs channel for jungfrau. 2026-09-11 17:54:12 +02:00
Henrik Lemke 442e08e223 more 2026-09-11 16:11:36 +02:00
Henrik Lemke ca626672e0 sc fix 2026-09-11 16:04:34 +02:00
Henrik Lemke 80e359f4ec fixes mpod 2026-09-11 15:29:43 +02:00
lemke_handClaude Sonnet 5 d41fb10a7d StatusServer: richer status(), and recording_mode as a validated choice
status() now shows the failed component names (not just the count), a
red/yellow/green state indicator, and a summary of recent request activity
(count, errors, last kind/age/duration) from /stats - previously only
reachable via separate failures()/stats() calls.

recording_mode is now an _AdjustableFSChoice (enum_strs=("all", "throttle",
"sample"), the exact set RecordingSession.__init__ accepts) instead of a
freeform AdjustableFS: rejects an invalid value at set_target_value time,
and exposes enum_strs so it structurally satisfies
eco.elements.protocols.AdjustableEnum for widget-layer dropdown display.
Deliberately not eco.elements.adjustable.AdjustableEnum itself, which
stores an IntEnum's integer rather than the string - storage stays the
plain string the server already parses, no server-side change needed.

Also documents the max_value_elements/max_points_per_channel distinction
inline (per-value-width filter vs. per-channel-length cap over time) -
not obvious from the settings' names alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:34:22 +02:00
lemke_h 45be1f3b72 gui fixes 2026-09-10 19:31:54 +02:00
lemke_handClaude Sonnet 5 bf0223a23c StatusServer.__repr__ shows the recording settings, not just health
The settings (recording_mode, recording_min_interval, ...) were already
registered for display -- Assembly._append's own is_display defaults to
True and nothing here overrode it. What was missing is that StatusServer
replaces __repr__ outright with a one-line health summary, which never
called Assembly.get_display_str() (the thing that actually renders
is_display=True components into a table), so the settings never showed up
in repr(bernina.status_server) despite being display-eligible all along.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:25:44 +02:00
lemke_handClaude Sonnet 5 8637d8096c Don't crash Assembly construction when an account can't create its shared memory directory
A brand new namespace object's memory/presets directory doesn't exist yet,
and accounts differ in whether they're in the group the shared memory tree
(eco_cnf_bernina/memory/) is setgid to -- gac-bernina's default group isn't
unx-sf_bernina_bs and that can't easily be changed. ensure_dir() already
warns-and-continues on a permission failure, but AdjustableFS's write then
turned that into an uncaught FileNotFoundError, taking the whole Assembly
(and anything wrapping it, e.g. Namespace.append_obj) down -- hit live when
constructing the new bernina.status_server object.

Memory.setup_path() now falls back to a private, per-account directory under
home (the same pattern AdjustableFS's own group_writable=False already uses
for RecentComponents/ComponentBookmarks) when the shared one can't be
created, so an account like gac-bernina still gets working memory/presets,
just not cross-account-shared for objects it's the first to touch.
Assembly.__init__ additionally degrades to self.memory = None on any other
unexpected failure there, rather than ever letting it take the object down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:51:24 +02:00
lemke_handClaude Sonnet 5 e876c1af4f FS-based status_server settings, per-channel size projection, required-filter for monitors
Three follow-ups to the status_server namespace component and
recording feature:

1. StatusServer's recording settings are now AdjustableFS, not
   AdjustableMemory - each a small JSON file under the same shared
   eco_cnf_bernina/configuration tree channels_JF/config_JFs already
   use. The point: a setting changed in one process is visible to any
   other process reading the same filesystem (another session, or the
   status server itself) with no REST call or other new communication
   - confirmed with a test constructing two independent StatusServer
   instances against the same config_dir.

2. RecordingSession.size_report(): per-channel projected bandwidth -
   rate (this channel's own point count over elapsed time) times a
   sample value's size (element count * dtype itemsize, "frequency *
   data shape * bitdepth") - to judge storage/bandwidth cost from a
   short trial recording before committing to a long one. Works on a
   still-running recording. Exposed via GET /recording/<id>?size=1
   (optional &size_top_n=N) and StatusServerClient.recording(...,
   size=True).

3. failed_required for recordings: a channel that fails to attach and
   belongs to a required namespace component (namespace.required_names())
   is now reported separately in a recording's report() - the same
   "everyone else failing is expected, this failing is not" distinction
   /health's failed_required already makes for init failures. New
   client-side warn_recording_failed_required (red, but does not
   suggest reinit() the way the health one does - a demoted/
   disconnected channel isn't fixed by rebuilding the namespace),
   wired into Daq.start_scan_monitoring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:34:49 +02:00
lemke_handClaude Sonnet 5 7a0cc14847 Add bernina.status_server: namespace-level control/inspection for the status server
New eco.status_server.namespace_component.StatusServer (Assembly),
wired into bernina_daq.py as a top-level namespace object alongside
daq/scans - always built, independent of whether this session's own
Daq is using the server (ECO_STATUS_SERVER), since the server itself
runs regardless.

- Control: .restart()/.reinit(), delegating to StatusServerClient.
- Inspection: .status() (prints a one-line summary, warns in red about
  failed_required, returns the raw /health body), .stats(),
  .failures(), .monitor_policy() (new GET /monitor_policy route +
  client method, surfacing eco.epics_utils.ca_tuning's adaptive
  monitor policy from inside the server's own process - max_rate_hz,
  how many channels are monitored/known-fast, sensitive-period state;
  read-only, the threshold itself is a module constant today).
- .gui(): launches the Qt monitor as a detached subprocess, same
  window `eco-status-server gui` opens (not an embedded widget -
  noted as a separate, bigger follow-up if wanted).
- Recording settings (mode, min_interval, sample_interval,
  max_value_elements, max_points_per_channel) as real AdjustableMemory
  children with is_setting=True, so they show up the way any other
  device's configuration does. Not yet consumed by Daq.start_scan_
  monitoring (still its own mode="throttle"/min_interval=0.1
  defaults) - wiring that through is the natural next step, flagged
  explicitly in the module docstring rather than left implicit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:13:06 +02:00
lemke_handClaude Sonnet 5 d69ae87d64 Fix TypeError crash in push_status: requests.post(json=) has no datetime/numpy encoder
Hit live on a real test scan: _push_acquiring_scan_status handed
datetime.now() straight to push_status(), which POSTs via
requests.post(json=body) - that serializes with plain stdlib
json.dumps and no encoder hook at all, so it raised TypeError before
the client ever got a response to fall back from.

Two fixes: the actual datetime is now a unix timestamp
(datetime.now().timestamp()) at the source, matching every other
timestamp already flowing through this file; and
StatusServerClient._post() now serializes the body itself
(json.dumps(..., default=_json_default)) instead of using requests'
json= convenience, so any future datetime/numpy value through any
POST this client makes degrades gracefully (numpy scalars/arrays
converted, anything else str()'d) instead of crashing outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 17:07:27 +02:00
lemke_handClaude Sonnet 5 f837cde085 DetectorVirtual gets push-based monitoring, matching AdjustableVirtual
DetectorVirtual had no set_current_value_callback() at all, so a
computed Detector could never be live-monitored no matter how
monitorable its own parents were - it landed in a recording's
n_attach_failed the same as a genuinely dead PV, for a structurally
different reason. AdjustableVirtual already solved this generically
(CallbackComposedValue: subscribe to every parent's own callback,
recompute get_current_value() on any update, independent of whether a
parent is CA-backed or itself composed) - give DetectorVirtual the
same treatment via the same class, generalized to take its children
explicitly instead of reaching into AdjustableVirtual's own
._adjustables attribute.

Discovery is automatic: NamespaceMonitorStore already finds every
monitorable status detector via isinstance(ts, MonitorableValueUpdate)
(eco.elements.protocols), a pure duck-typed check - no wiring needed
beyond DetectorVirtual having the method now.

RecordingSession.start()'s recent seed-if-cheap logic (seed a channel
with its current value for free when that costs no CA traffic) now
also recognizes a CallbackComposedValue-backed monitor as free to
seed - it recomputes from already-monitored parents rather than
issuing a get of its own.

Found and fixed along the way: CallbackComposedValue.start() only
accepted add_current_value, not with_ctrlvars/auto_monitor -
RecordingSession.start() (and anything else treating a monitor
uniformly regardless of whether it is CA-backed or composed) calls
.start() with all three, so this raised TypeError for any composed
value that reached this path - previously unreachable for a
DetectorVirtual (no method at all) and apparently never exercised for
an AdjustableVirtual monitored this way either. Both kwargs are now
forwarded to every child, recursively, matching CallbackEpics.start()'s
interface exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:15:38 +02:00
lemke_handClaude Sonnet 5 f91ad41417 Seed monitored channels for free at attach; backfill the rest from status()
Two-tier fix for a namespace recording's "channel with zero updates
is simply absent from the file" gap:

1. RecordingSession.start() now seeds a channel with its current value
   (add_current_value=True) when that's actually free: pyepics'
   PV.get_with_metadata() returns the cached value with no CA traffic
   when auto_monitor is already True and the channel is connected -
   true for the large majority of channels under ca_tuning.make_pv()'s
   default policy. A demoted "fast" channel or a disconnected one
   would make this a real blocking get, so those are left unseeded, as
   before. New n_seeded counter reports how many got the free seed.

2. For the rest, RecordingSession.backfill_from_status() lets a status
   snapshot that was already being taken for another reason (a scan's
   own status_run_start/status_run_end capture) opportunistically fill
   in one value for any channel still at zero points - never a trigger
   of new CA traffic on its own. NamespaceMonitorStore gains
   pgroup/run_number on start_recording() (purely for this lookup) and
   backfill_running_recordings(), wired into both /status/capture and
   /status/snapshot right after their own snapshot() call. New
   n_backfilled counter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:59:36 +02:00
lemke_h c42033ab2c hmm moni 2026-09-09 23:39:12 +02:00
lemke_handClaude Sonnet 5 a641127ee7 Wire namespace monitoring into the daq-counter scan lifecycle
start_scan_monitoring/end_scan_monitoring are now in
callbacks_start_scan/callbacks_end_scan - server-only (no local
fallback, unchanged from when these were written), so a scan without a
status server configured simply gets no aux/namespace_monitor.h5, the
same as it already gets no server-backed aliases.json/status.json.

Placement matters: start_scan_monitoring runs after
count_run_number_up_and_attach_to_scan (needs scan.daq_run_number) and
append_start_status_to_scan (shares its per-scan server-in-use
decision); end_scan_monitoring runs before copy_scan_info_to_raw,
which is what actually writes the "monitors" scan parameter into
scan_info_rel.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:30:57 +02:00
lemke_handClaude Sonnet 5 5cbe334144 Name the per-scan monitor recording namespace_monitor.h5, register it, and document its format for escape
end_scan_monitoring() now writes to aux/namespace_monitor.h5 (instead
of the general-purpose recording API's default monitors.esc.h5) and
registers it via scan.set_scan_parameter("monitors",
"aux/namespace_monitor.h5") right after dispatching the capture job -
the same "register the path now, the async job finishes later"
pattern copy_aliases_to_scan/append_start_status_to_scan already use
for "aliases"/"status", so copy_scan_info_to_raw's scan_info_rel.json
carries it the same way.

Added eco/status_server/NAMESPACE_MONITOR_FORMAT.md: a handoff spec
for escape_fel describing the file format (one escape.ArrayTimestamps
per channel, keyed by the same dotted alias space as status.json/
aliases.json), how to load it (escape.DataSet(results_file=...)
already dict2structure()s it correctly for free - found by reading
escape_fel 0.2.7's actual source, not guessed), how it relates to and
should replace the existing pickle-based "monitor data hack" in
escape/swissfel/parse.py, and the merge-order needed for a co-loaded
bs/detector dataset to supersede the CA-monitor one for the same
component. Not yet exercised against a real load - flagged as such.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:57:25 +02:00
lemke_handClaude Sonnet 5 82090569c8 Add async recording capture; prep (unwired) per-scan monitoring methods
POST /recording/capture: stops a recording, writes it, and uploads it
to the run - all in the background, mirroring /status/capture and
/aliases/capture. Stopping (detaching callbacks) happens synchronously
before responding, so the recording is unambiguously over by the time
the caller gets a job id; only the write (scales with points recorded)
and the broker upload are backgrounded. Load-tested against the live
server first (start/get_status/stop concurrently): snapshot latency is
unaffected by a concurrent recording (~11s either way), ~38-44% of one
core combined, no thread/memory leak across repeated start/stop.

Daq gains start_scan_monitoring()/end_scan_monitoring() - start a
namespace-wide recording keyed to the run, and capture+upload it -
built to be called at a scan's start/end but deliberately NOT added to
callbacks_start_scan/callbacks_end_scan yet. No local fallback: unlike
status/aliases, a local recording would need this session's own
namespace holding a live monitor per channel for the whole scan, which
is exactly the per-session cost the status server exists to avoid.

Also fixes a latent cross-feature bug found while adding this: GET
/status/job/<id>?include_status=1 merged in any pending
scans.acquiring_scan.* push (from the just-merged push_status
mechanism) by defaulting the job's "key" to "status_run_start" when
absent - but aliases-capture jobs (and now recording-capture jobs)
carry pgroup/run_number for their own reporting without ever setting
"key". Reading such a job with include_status=1 could have silently
stolen a push meant for the real status_run_start job of the same run.
Fixed by requiring an explicit "key" on the job before merging - only
genuine status/capture jobs set one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:46:35 +02:00
Henrik Lemke e308f5e3e8 alias debug fix 2026-09-09 22:28:22 +02:00
Henrik Lemke 6bf998b550 Push scans.acquiring_scan.* status to the status server for the run table
scans.acquiring_scan.* (description, scan_command, initial_values, ...) can
never reach the status server's own snapshot: it's built from DetectorMemory
objects with no CA channel, and the server's channel registry is driven
entirely by Alias.get_all(), which only returns aliases that have one. The
run table's "Custom table" sheet already has header cells expecting these
exact column names, so they've been silently blank.

Add a small push mechanism instead of another metadata.*-style workaround:
Daq now hands the values it already resolves (the same ones metadata.* is
built from) to the status server via a new POST /status/push, which merges
them into the matching /status/capture job's status the next time it is
read with include_status=True - the natural point, since that's exactly
when daq_client asks for the run's status to fill the run table.

NamespaceMonitorStore.push_status/pop_pushed_status hold the pushed values
scoped by (pgroup, run_number, key), consumed once and dropped after 5
minutes if nothing ever reads them, so a push whose scan aborts can't
attach itself to a later, unrelated run.
2026-09-09 22:26:40 +02:00
Henrik Lemke 6b06799af6 scannable fix 2026-09-09 21:32:07 +02:00
Henrik Lemke 1fae3c3f5b Stop tracking CLAUDE.md
Contains internal PSI facility paths and infrastructure details that
shouldn't be on the now-public GitHub mirror. Kept locally for Claude Code
(read straight off disk regardless of git tracking), gitignored going
forward.
2026-09-09 17:14:38 +02:00
Henrik Lemke 0c47051feb Finish PyPI/GitHub/RTD release setup: rename to eco-suite, add CI, fix broken base import
Publish to PyPI / Build distribution (push) Failing after 10s
Publish to PyPI / Publish to PyPI (push) Skipped
- pyproject.toml: rename package eco -> eco-suite (eco was already taken on
  PyPI); move escape-fel + dask-jobqueue into core dependencies since
  eco.elements.assembly unconditionally needs them (a bare `import eco` was
  broken without the psi extra, verified in a clean venv); drop git-URL
  installs for scilog/escape-fel now that both are on public PyPI; update
  project URLs to the GitHub mirror and ReadTheDocs.
- Add LICENSE (GPLv3, matching what pyproject.toml already declared).
- Add MANIFEST.in to keep the vendored eco/elements/textual test/ subproject
  out of the sdist (it was already excluded from the wheel, but not the
  sdist, via the existing packages.find/exclude-package-data config).
- Add .github/workflows/publish.yml (PyPI Trusted Publishing on v*.*.* tags)
  and .githooks/pre-push (auto-bump a patch tag on push to master), mirroring
  the escape-fel release setup.
v0.1.0
2026-09-09 17:01:14 +02:00
lemke_h 10534dec99 Merge remote-tracking branch 'origin/master' 2026-09-09 16:00:05 +02:00
lemke_h 8ae9242511 fixes to fail recognition. 2026-09-09 15:58:41 +02:00
gac bernina 3e3de8e0bd file rems 2026-09-07 17:12:24 +02:00
lemke_h c52ce03d02 eco-box 2026-09-07 17:11:28 +02:00
lemke_h 33612ab192 fixes 2026-09-07 16:54:05 +02:00
lemke_handClaude Sonnet 5 83665caea7 Stop the GUI reinit button from blocking the window from closing
_start_reinit called client.reinit(wait=True), which keeps
_ReinitWorker running for as long as the server-side rebuild takes
(minutes) - and closeEvent refuses to close while that worker is
running, to avoid destroying a QThread mid-flight (a real crash, not
hypothetical). Together that made the window impossible to close for
the whole duration of a reinit, even though the GUI only monitors the
server and the rebuild proceeds independently of whether the window
is even open.

Switch to wait=False: the worker now only has to make the request
itself (bounded by the client's own timeout, normally under a
second), not wait for the rebuild. Progress during the rebuild is
unaffected - it was always coming from the already-running _Poller's
/health polling, not from this worker. closeEvent's guard stays as a
safety net for a slow/unreachable request, just no longer trips for
the reinit itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:32:58 +02:00
lemke_handClaude Sonnet 5 634a10a086 Serve aliases from the status server; use it by default in Daq
The server already holds a fully-initialized namespace, so its
namespace.alias.get_all() is complete and free (pure in-memory tree
walk, no CA traffic). The client side's own copy_aliases_to_scan
called self.namespace.alias.get_all() unconditionally, including in
status-server mode - against a namespace that mode deliberately never
initializes, so aux/aliases.json was silently missing everything the
local session had not happened to touch.

- namespace_server.py: GET /aliases (synchronous, for direct
  inspection) and POST /aliases/capture (async: compute, write
  aliases.json, upload to the run - mirrors /status/capture, shares
  its job-id space via GET /status/job/<id>).
- storage.py: write_aliases_file(), same group-writable-tree handling
  as write_status_snapshot().
- client.py: StatusServerClient.aliases() / .capture_aliases().
- daq_client.py: copy_aliases_to_scan now asks the same per-scan
  status-server decision append_start_status_to_scan already makes
  and caches, delegating to the server when it's in use and falling
  back to the local path (unchanged) otherwise.

Also fixes a latent bug found along the way: Daq.__init__ accepted an
`instrument` kwarg but never stored it as self.instrument, so every
f"/sf/{self.instrument or 'bernina'}/..." path in
_write_status_locally()/write_status() raised AttributeError on any
real Daq - masked because the only test reaching that path tolerates
"any exception" as "fell back correctly".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 14:30:18 +02:00
lemke_handClaude Sonnet 5 b3189149ee Require a sustained rate before demoting a monitored channel
Real incident, 2026-09-06: SLAAR21-LMOT-M521/M524:MOTOR_1.RBV (ordinary
delay-stage motor readbacks) got demoted mid-scan after a single 5s sweep
measured ~14 Hz during that step's move/settle burst. Demotion is
persisted to disk, so a channel that is idle except during moves stayed
wrongly stuck on the slow, direct-CA-get path afterwards.

_sweep_once now requires AUTO_MONITOR_MAX_RATE to be exceeded for
AUTO_MONITOR_SUSTAINED_SWEEPS consecutive sweeps (default 3, ~15s) before
demoting, tracked per-channel in _fast_streak and reset the moment a sweep
sees the channel back at a normal rate - a burst confined to one scan step
no longer counts as evidence of a genuinely fast channel. Also adds
forget_channel(pvname) to undo a single already-mismeasured entry without
wiping the whole learned list via clear_fast_channels().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:48:37 +02:00
lemke_handClaude Sonnet 5 85c91abe71 Fix CA-context bug in the monitor sweeper, decouple pulse_id timeout, add diagnostics
Root-cause investigation of a real scan abort (p19734 run 1146,
TimeoutError in Daq.get_pulse_id):

- ca_tuning._sweep_loop() never called epics.ca.use_initial_context()
  before demoting channels (pv.auto_monitor = False / remove_callback,
  which calls ca_clear_subscription()) - the same CA-context class of
  bug this codebase already fixed once for real (see
  namespace_store._ca_thread, parallel_init). Fixed, with tests.

- Daq.get_pulse_id() defaulted its wait budget to self.timeout, which
  doubles as the broker HTTP request timeout (2s) - a transient ~3.4s
  CA gap had no room to resolve. Split into a dedicated
  pulse_id_timeout (5s default) so the two can be tuned independently.

- get_pulse_id()'s TimeoutError was a bare "timeout hit" with no
  evidence of why. Added ca_tuning.describe_channel() (factored out of
  report_none_read's existing connected/history/CA-cache logic) and
  wired it into both TimeoutError sites, so a future occurrence
  self-diagnoses instead of requiring log archaeology.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:23:47 +02:00
lemke_h 870b3e5749 fix tt_kb calibration 2026-09-06 21:30:55 +02:00
lemke_h 3a171efd69 fix? 2026-09-06 21:26:59 +02:00
lemke_h 954d6957a6 fixes 2026-09-06 21:05:04 +02:00
lemke_h 31167ea574 fix elog, had actually an issue 2026-09-06 16:59:53 +02:00