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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
- 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.
_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>
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>
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>
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>