This commit is contained in:
2026-10-05 22:29:03 +02:00
parent f8a45aeaa2
commit 48dbc9c6fd
7 changed files with 2495 additions and 195 deletions
+92 -2
View File
@@ -47,9 +47,10 @@ df = archiver.get_data_time_range(
)
# Relative starts: a number of seconds, a timedelta, or a dict of timedelta
# kwargs — all relative to `end` (which defaults to now):
# kwargs — all counted back from `end` (which defaults to now). Negative reads
# naturally, but the sign is ignored: 1800 and {"minutes": 30} mean the same.
df = archiver.get_data_time_range(channels=[chan], start=-1800) # last 30 min
df = archiver.get_data_time_range(channels=[chan], start={"minutes": 30})
df = archiver.get_data_time_range(channels=[chan], start={"minutes": -30})
```
Useful options:
@@ -60,6 +61,82 @@ Useful options:
- `convert_timezone=True` converts the index from UTC to `Europe/Zurich`.
- `labels=[...]` sets nicer legend labels for the plot.
Each call prints the range it actually asked the archiver for (UTC and local)
and, per channel, whether data came back or why not (`verbose=False` silences
this and skips the extra requests below). A CA channel is only stored when it
changes, so a quiet one has no events in a short window. One extra request per
such channel tells you which case you are in:
```
target_stages.x.offset (SARES20-MF2:MOT_1.OFF): no events in range; last before it: 8.3875 (set 2026-10-05 13:03:37 UTC)
target_stages.x.velocity (SARES20-MF2:MOT_1.VELO): not found in sf-archiver (not archived under this name)
```
The first exists and was simply quiet (the value it had is shown); the second
is not archived at all.
### Several eco objects at once
Instead of channel names you can pass eco objects — an Assembly, an adjustable,
a detector. Every archived channel below the object's alias is retrieved.
Objects, plain channel ids and lists of both can be mixed, and a channel that
several objects share is fetched once:
```python
df = archiver.get_data_time_range(prof_kb.target_stages, mon_opt, start=-1e5)
# extra channels: plain ids are read from the DataBuffer (BS) unless you say
# otherwise, either per channel ...
df = archiver.get_data_time_range(
prof_kb.target_stages,
channels=["SARES20-MF1:MOT_1.RBV"], channel_types=["CA"],
start=-3600,
)
# ... or for everything at once with force_type=
```
Objects bring their own channel types and legend labels, so leave `force_type`
unset when passing them. Legend labels carry each channel's full alias
(`prof_kb.target_stages.x.offset (SARES20-MF2:MOT_1.OFF)`). The start (and end)
may also follow the objects positionally if it is a number, timedelta, dict or
datetime — `archiver.get_data_time_range(prof_kb.target_stages, -3600)`; a date
*string* must be passed as `start=`, since it could as well be a channel id.
By default the DataFrame columns are channel ids, in the order you asked.
`column_names="alias"` names them by the full alias instead
(`prof_kb.target_stages.x.offset`), `column_names="label"` by the whole legend
label.
### Long ranges of BS channels: `bins`
A BS channel runs at up to 100 Hz: a day of one channel is 8.6 million
samples, roughly 20 s to fetch. A warning (with that estimate) is printed
whenever a request would be that big. Downsample **on the server** instead:
```python
# a bin count (the server rounds to a sensible bin width) ...
df = archiver.get_data_time_range(gasmon, start=-86400, bins=500)
# ... or a bin width: "10s", "1m", "1h", ...
df = archiver.get_data_time_range(gasmon, start=-86400, bins="10m")
```
Each channel comes as its bin average plus `<channel> min`, `<channel> max` and
`<channel> count` columns, stamped at the bin centre; empty bins are NaN.
`bins` works for CA channels and for `get_data_pulse_id_range` too.
### The value at the start of the window: `last_before`
A quiet CA channel has no events in a short window, and a step plot of it has
no starting value. `last_before=True` also fetches each channel's last event
before the window (the archiver's "one before range") and puts it *at* the
start of the window:
```python
df = archiver.get_data_time_range(prof_kb.target_stages, start=-600, last_before=True)
```
It cannot be combined with `bins`.
### Finding channel names
If you are not sure of the exact channel name, search with a glob pattern:
@@ -79,6 +156,12 @@ For beam-synchronous channels you can query a pulse-id range instead. With no
df = archiver.get_data_pulse_id_range(channels=[chan], start=-1000)
```
It takes everything `get_data_time_range` does — eco objects, `bins`,
`last_before`, `column_names` — and positional `start`/`end`:
`archiver.get_data_pulse_id_range(gasmon, 28549932558, 28549938558)`. A negative
`start` is an offset back from `end`. (Pulse ids are mapped to times with the
service's own map; datahub's built-in linear formula was days off.)
## Getting archiver data into a plot
Every retrieval method takes `plot=True`, which draws the returned DataFrame as
@@ -96,6 +179,13 @@ archiver.get_data_time_range(
)
```
With many curves, the figure window's toolbar has a **Select curves** button
(right after *Save*, Qt backends): it opens a window with a checkbox per curve,
a filter box and All/None, and hides or shows curves immediately — the legend
and the y-range follow. The button comes from escape (`escape >= 0.2.14`,
`escape.plot_utilities.attach_select_button`, which also covers the Jupyter
widget backend); with an older escape a built-in Qt-only version is used.
Since you also get the DataFrame back, you can just as easily plot or analyse it
yourself with pandas/matplotlib.
+936 -87
View File
File diff suppressed because it is too large Load Diff
+51 -7
View File
@@ -257,9 +257,10 @@ class Memory:
t = datetime.fromisoformat(key)
row.append(t.strftime("%Y-%m-%d: %a %-H:%M"))
row.append(content["message"])
row.append(content.get("presetname") or "")
a.append(row)
return format_table(a, headers=["Index", "Time", "Message"])
return format_table(a, headers=["Index", "Time", "Message", "Preset"])
def __call__(self, index=None, include_parents=False, **kwargs):
"""Interactive terminal picker over stored memories -- recalls
@@ -295,6 +296,8 @@ class Memory:
for n, (key, content) in enumerate(mem.items()):
t = datetime.fromisoformat(key)
row = t.strftime("%Y-%m-%d: %a %H:%M") + " " + content["message"]
if content.get("presetname"):
row += f" [preset: {content['presetname']}]"
a.append(row)
try:
recall_dicts[n] = self.get_recall_dict(self.get_memory(key=key))
@@ -728,6 +731,37 @@ class Memory:
mem.pop(key)
self._memories.set_target_value(mem).wait()
def set_preset_name(self, index=None, key=None, name=None):
"""Set (`name` given) or remove (`name=None`/falsy, the default)
the `"presetname"` tag on an *already-stored* memory entry --
without touching its captured values, and without deleting the
entry itself (see `clear_memory` for that). The entry keeps
existing as a plain memory either way; this only changes whether
`Presets`/`.presets.<name>` can reach it.
Tagging after the fact this way, or re-tagging an entry that
already has a (possibly different) `presetname`, is exactly
equivalent to having passed `preset_varname=name` to `memorize()`
when it was first saved -- including when `name` collides with
another entry's: nothing here enforces uniqueness, so whichever
tagged entry was written or re-tagged most recently is the one
`Presets._setup_presets()` resolves `.presets.<name>` to (see its
own docstring/comments) -- the earlier one stays a plain memory,
just no longer reachable by that name.
"""
if index is not None:
key = list(self._memories().keys())[index]
if key is None:
raise ValueError("memory key or index needs to be specified")
mem = self._memories.get_current_value()
if key not in mem:
raise KeyError(f"no stored memory entry {key!r}")
if name:
mem[key]["presetname"] = name
else:
mem[key].pop("presetname", None)
self._memories.set_target_value(mem).wait()
def get_recall_dict(self, mem, selection=None):
"""Merge the selection(s) of a stored, per-entry memory dict `mem`
(as returned by `get_memory`) into one flat {name: value} dict.
@@ -1459,17 +1493,24 @@ class Memory:
rec = self.get_recall_dict(full, selection=selection)
except Exception:
continue
loaded.append((key, mem_index[key].get("message", "") or "", rec))
loaded.append(
(
key,
mem_index[key].get("message", "") or "",
mem_index[key].get("presetname") or "",
rec,
)
)
if not loaded:
return "No stored memories could be read."
present_values = self._prefetch_present_values(
itertools.chain.from_iterable(rec.keys() for _, _, rec in loaded)
itertools.chain.from_iterable(rec.keys() for _, _, _, rec in loaded)
)
ranked = []
for key, message, rec in loaded:
for key, message, presetname, rec in loaded:
matches = 0
total = 0
penalty = 0.0
@@ -1500,6 +1541,7 @@ class Memory:
{
"date": date,
"message": message,
"presetname": presetname,
"matches": matches,
"total": total,
"penalty": penalty,
@@ -1537,6 +1579,7 @@ class Memory:
rank + 1,
r["date"].strftime("%Y-%m-%d %a %H:%M"),
r["message"],
r["presetname"],
match_str,
dev_str,
biggest_miss,
@@ -1545,8 +1588,8 @@ class Memory:
return format_table(
table,
headers=["#", "Date", "Message", "matches", "deviation", "biggest miss"],
colalign=("center", "left", "left", "center", "decimal", "left"),
headers=["#", "Date", "Message", "Preset", "matches", "deviation", "biggest miss"],
colalign=("center", "left", "left", "left", "center", "decimal", "left"),
tablefmt=tablefmt,
)
@@ -1620,7 +1663,8 @@ class Preset:
def __str__(self):
s = f"Preset {self._name} - saved values compared to the present status\n"
tmem = self._memory.get_memory(key=self._key)
s += self._memory.get_memory_difference_str(tmem)
rec = self._memory.get_recall_dict(tmem)
s += self._memory.get_memory_difference_str(rec)
return s
def __repr__(self):
+133 -78
View File
@@ -6,81 +6,98 @@ import eco
_logger = logging.getLogger(__name__)
def get_from_archive(Obj, attribute_name="pvname", force_type=None, remove_nulls=True):
def _archiver_channels(self):
"""All channels of this object (ids, types and labels), from its
aliases, falling back to the single `attribute_name` channel.
def get_archiver_channels(obj, attribute_name="pvname"):
"""All channels of `obj` (ids, types and labels), from its aliases,
falling back to the single `attribute_name` channel.
A sibling "unit" sub-component - whether it's an in-memory string
with no channel of its own, or a real channel such as a ".EGU" PV -
is never itself included as a channel to plot; it's metadata, not a
value, and would just show up as an empty/noisy legend entry. Its
current string is read once instead and appended to the label(s) of
the channel(s) it belongs alongside, e.g. "readback (SATES...)
[mbar]".
A sibling "unit" sub-component - whether it's an in-memory string
with no channel of its own, or a real channel such as a ".EGU" PV -
is never itself included as a channel to plot; it's metadata, not a
value, and would just show up as an empty/noisy legend entry. Its
current string is read once instead and appended to the label(s) of
the channel(s) it belongs alongside, e.g. "readback (SATES...)
[mbar]".
Also returns each channel's own leaf attribute name (e.g.
"readback", "speed", "direction") alongside its id/type/label -
`strip_plot`'s `readback_only` uses it to tell "the" value apart
from everything else an Assembly happens to expose as its own
channel."""
Labels carry each channel's *full* alias, through all the parents up to
the top-level object ("prof_kb.target_stages.x.offset (SARES20-...)"),
not just the path below `obj` - for a leaf such as the `offset` of one
stage that would be a bare "offset", the same for all three stages.
def _owner(path):
obj = self
for part in path[1:-1]:
obj = getattr(obj, part)
return obj
Also returns each channel's own leaf attribute name (e.g.
"readback", "speed", "direction") alongside its id/type/label -
`strip_plot`'s `readback_only` uses it to tell "the" value apart
from everything else an Assembly happens to expose as its own
channel."""
try:
channels = self.alias.get_all()
parsed = [(c, c["alias"].split(".")) for c in channels]
value_entries = [(c, path) for c, path in parsed if path[-1] != "unit"]
unit_entries = [(c, path) for c, path in parsed if path[-1] == "unit"]
def _owner(path):
owner = obj
for part in path[1:-1]:
owner = getattr(owner, part)
return owner
units = {}
for c, path in unit_entries:
try:
units[".".join(path[1:-1])] = getattr(
_owner(path), "unit"
).get_current_value()
except Exception:
pass
for c, path in value_entries:
key = ".".join(path[1:-1])
if key in units:
continue
try:
unit_obj = getattr(_owner(path), "unit", None)
if unit_obj is not None and hasattr(unit_obj, "get_current_value"):
units[key] = unit_obj.get_current_value()
except Exception:
pass
try:
channels = obj.alias.get_all()
parsed = [(c, c["alias"].split(".")) for c in channels]
value_entries = [(c, path) for c, path in parsed if path[-1] != "unit"]
unit_entries = [(c, path) for c, path in parsed if path[-1] == "unit"]
channel_ids = [c["channel"] for c, _ in value_entries]
channel_types = [c.get("channeltype") for c, _ in value_entries]
leaf_names = [path[-1] for _, path in value_entries]
labels = []
for c, path in value_entries:
label = f'{c["alias"]} ({c["channel"]})'
unit = units.get(".".join(path[1:-1]))
if unit:
label += f" [{unit}]"
labels.append(label)
except:
channel_ids = [self.__dict__[attribute_name]]
channel_types = None
leaf_names = [attribute_name]
label = f"{self.alias.get_full_name()} ({channel_ids[0]})"
units = {}
for c, path in unit_entries:
try:
unit_obj = getattr(self, "unit", None)
if unit_obj is not None and hasattr(unit_obj, "get_current_value"):
unit = unit_obj.get_current_value()
if unit:
label += f" [{unit}]"
units[".".join(path[1:-1])] = getattr(
_owner(path), "unit"
).get_current_value()
except Exception:
pass
labels = [label]
return channel_ids, channel_types, labels, leaf_names
for c, path in value_entries:
key = ".".join(path[1:-1])
if key in units:
continue
try:
unit_obj = getattr(_owner(path), "unit", None)
if unit_obj is not None and hasattr(unit_obj, "get_current_value"):
units[key] = unit_obj.get_current_value()
except Exception:
pass
channel_ids = [c["channel"] for c, _ in value_entries]
channel_types = [c.get("channeltype") for c, _ in value_entries]
leaf_names = [path[-1] for _, path in value_entries]
try:
own_name = obj.alias.alias
full_name = obj.alias.get_full_name() # this object's name, parents included
except Exception:
own_name = full_name = None
labels = []
for c, path in value_entries:
alias = c["alias"] # "<own name>.<path below obj>"
if full_name is not None and alias.startswith(own_name):
alias = full_name + alias[len(own_name) :]
label = f'{alias} ({c["channel"]})'
unit = units.get(".".join(path[1:-1]))
if unit:
label += f" [{unit}]"
labels.append(label)
except Exception:
channel_ids = [obj.__dict__[attribute_name]]
channel_types = None
leaf_names = [attribute_name]
label = f"{obj.alias.get_full_name()} ({channel_ids[0]})"
try:
unit_obj = getattr(obj, "unit", None)
if unit_obj is not None and hasattr(unit_obj, "get_current_value"):
unit = unit_obj.get_current_value()
if unit:
label += f" [{unit}]"
except Exception:
pass
labels = [label]
return channel_ids, channel_types, labels, leaf_names
def get_from_archive(Obj, attribute_name="pvname", force_type=None, remove_nulls=True):
def _archiver_channels(self):
return get_archiver_channels(self, attribute_name)
def _select_channels(channel_ids, channel_types, labels, select_names, leaf_names=None):
"""Narrow the channel/type/label(/leaf_name) lists down to
@@ -131,15 +148,50 @@ def get_from_archive(Obj, attribute_name="pvname", force_type=None, remove_nulls
def get_archiver_time_range(
self, start=None, end=None, force_type=force_type, plot=True, **kwargs
):
"""Try to retrieve data within timerange from archiver. A time delta from now is assumed if end time is missing."""
"""Retrieve archived data of all channels of this object as a pandas
DataFrame (UTC timestamp index, one column per channel id), or None.
`start` / `end` define the time range; `end` defaults to now:
obj.get_archiver_time_range(start=-3600) # last hour
obj.get_archiver_time_range(start=-60*60*24*300) # last 300 days
obj.get_archiver_time_range(start=dict(days=-2)) # last 2 days
obj.get_archiver_time_range(start=dict(hours=-6)) # last 6 hours
obj.get_archiver_time_range(start="2026-10-01 08:00",
end="2026-10-01 09:30") # local time
obj.get_archiver_time_range(start=-600,
end="2026-10-01 09:00") # 08:50-09:00
A negative number, timedelta or dict means "back in time from `end`",
like start=-3600; the sign is actually ignored, so dict(days=2) or
hours=6 (shortcut keywords instead of `start`) mean the same. Naive
absolute times are local time, a trailing "Z" or a tz-aware datetime
is taken as given.
`plot`: also plot the result. `force_type` "CA"/"BS": query one
backend for all channels instead of using each channel's own type.
`verbose` (default True): print the exact UTC range sent to the
archiver and, per channel, whether data came back or why not (no
events in the range - with the last value before it - or not archived
at all). `column_names="alias"` (or "label") names the columns by
alias instead of channel id; `bins=` downsamples on the server (long
ranges of BS channels!) and `last_before=True` adds each channel's
value at the start of the window. Remaining kwargs go to
`DataHub.get_data_time_range`, which also takes several objects at
once: `archiver.get_data_time_range(obj1, obj2, start=-3600)`.
Channels without any data in the range are dropped from the result
(`remove_nulls`) and listed in a printed notice."""
from ..dbase.archiver import COLUMN_NAME_MODES
column_names = kwargs.pop("column_names", "channel")
if column_names not in COLUMN_NAME_MODES:
raise ValueError(f"column_names must be one of {COLUMN_NAME_MODES}")
channel_ids, channel_types, labels, _leaf_names = _archiver_channels(self)
channels = channel_ids
data = eco.defaults.ARCHIVER.get_data_time_range(
channels=channel_ids,
start=start,
end=end,
plot=plot,
plot=False,
force_type=force_type,
channel_types=None if force_type else channel_types,
labels=labels,
@@ -147,17 +199,20 @@ def get_from_archive(Obj, attribute_name="pvname", force_type=None, remove_nulls
)
if data is None:
return data
if remove_nulls and not kwargs.get("bins"): # empty bins are NaN by design
data = data.dropna(how="all").dropna(how="all", axis=1)
dropped = [c for c in channel_ids if c not in data.keys()]
if dropped and kwargs.get("verbose", True):
print(f" dropped (no data): {', '.join(dropped)}")
if plot and not data.empty:
from ..dbase.archiver import _plot_dataframe
channel_ids_found = [_ for _ in channel_ids if _ in data.keys()]
channels_found = filter(lambda x: x in channel_ids_found, channels)
if remove_nulls:
data = data.dropna(how='all').dropna(how='all', axis=1)
channel_ids_missing = [_ for _ in channel_ids if _ not in data.keys()]
channels_missing = filter(lambda x: x in channel_ids_missing, channels)
_plot_dataframe(data, channel_ids, labels)
if column_names != "channel":
from ..dbase.archiver import _display_names, _rename_columns
return data#.rename(columns={channelname: "data"})
data = _rename_columns(data, _display_names(channel_ids, labels, column_names))
return data
def strip_plot(
self,
+294 -21
View File
@@ -36,6 +36,15 @@ checked items are passed on to `Memory.memorize(pick_items=[...])`. Same
idea as the terminal's own `memorize(pick_items=True)`, just via an inline
GUI checklist instead of a `simple_term_menu` prompt, since a terminal menu
can't run inside a GUI event loop.
A memory can optionally be tagged as a named preset (`Memory.memorize`'s
`preset_varname`, reachable afterwards as `<assembly>.memory.presets.<name>`)
-- an optional "preset name" field next to Save does this at creation time;
an existing stored entry's tag can also be set, changed, or removed
afterwards (`Memory.set_preset_name`) via the "Preset tag" field shown once
that entry is loaded from the overview list. Tagged entries show their
preset name in the overview list like any other field -- a preset is just a
regular memory entry with that one extra tag, never a separate store.
"""
import itertools
import traceback
@@ -81,6 +90,7 @@ def _overview_rows(memory):
"date": date,
"message": content.get("message", ""),
"groups": groups,
"presetname": content.get("presetname") or "",
}
)
rows.reverse()
@@ -348,8 +358,11 @@ class MemoryBrowserQt:
overview_filter_row.addStretch(1)
outer.addLayout(overview_filter_row)
self.list = QtWidgets.QListWidget()
self.list = QtWidgets.QTreeWidget()
self.list.setMaximumHeight(160)
self.list.setHeaderLabels(["Date", "Message", "Groups", "Preset"])
self.list.setRootIsDecorated(False)
self.list.setAlternatingRowColors(True)
self.list.itemClicked.connect(self._on_pick_stored)
outer.addWidget(self.list)
@@ -365,8 +378,31 @@ class MemoryBrowserQt:
load_row.addWidget(load_file_btn)
load_row.addWidget(refresh_btn)
load_row.addStretch(1)
delete_entry_btn = QtWidgets.QPushButton("Delete this memory entry…")
delete_entry_btn.setStyleSheet("color: #c62828;")
delete_entry_btn.setToolTip(
"permanently remove the WHOLE loaded memory entry -- every "
"value it captured, not just its preset tag"
)
delete_entry_btn.clicked.connect(self._on_delete_entry)
load_row.addWidget(delete_entry_btn)
outer.addLayout(load_row)
preset_tag_row = QtWidgets.QHBoxLayout()
preset_tag_row.addWidget(QtWidgets.QLabel("Preset tag (for loaded entry):"))
self.preset_tag_edit = QtWidgets.QLineEdit()
self.preset_tag_edit.setPlaceholderText("empty clears the tag")
preset_tag_row.addWidget(self.preset_tag_edit)
apply_preset_btn = QtWidgets.QPushButton("Apply")
apply_preset_btn.setToolTip(
"set/change/clear just the preset name tag on the loaded entry "
"-- does not touch its captured values or delete it"
)
apply_preset_btn.clicked.connect(self._on_apply_preset_name)
preset_tag_row.addWidget(apply_preset_btn)
preset_tag_row.addStretch(1)
outer.addLayout(preset_tag_row)
# --- filters ---------------------------------------------------
self.filter_box = QtWidgets.QGroupBox("Filters (within an opened memory)")
filter_layout = QtWidgets.QVBoxLayout(self.filter_box)
@@ -391,20 +427,16 @@ class MemoryBrowserQt:
select_all_btn.clicked.connect(lambda: self._set_all_checked(True))
select_none_btn = QtWidgets.QPushButton("select none")
select_none_btn.clicked.connect(lambda: self._set_all_checked(False))
select_row.addWidget(select_all_btn)
select_row.addWidget(select_none_btn)
select_row.addStretch(1)
outer.addLayout(select_row)
action_row = QtWidgets.QHBoxLayout()
recall_btn = QtWidgets.QPushButton("Recall selected values")
recall_btn.clicked.connect(self._on_recall)
export_btn = QtWidgets.QPushButton("Export to file…")
export_btn.clicked.connect(self._on_export)
action_row.addWidget(recall_btn)
action_row.addWidget(export_btn)
action_row.addStretch(1)
outer.addLayout(action_row)
select_row.addWidget(select_all_btn)
select_row.addWidget(select_none_btn)
select_row.addWidget(recall_btn)
select_row.addWidget(export_btn)
select_row.addStretch(1)
outer.addLayout(select_row)
# --- save new memory ---------------------------------------------
save_box = QtWidgets.QGroupBox("Save current state as a new memory")
@@ -432,6 +464,19 @@ class MemoryBrowserQt:
save_layout.addWidget(save_btn)
save_vbox.addLayout(save_layout)
preset_layout = QtWidgets.QHBoxLayout()
preset_layout.addWidget(QtWidgets.QLabel("preset name:"))
self.save_preset_edit = QtWidgets.QLineEdit()
self.save_preset_edit.setPlaceholderText("optional -- leave empty for a plain memory")
self.save_preset_edit.setToolTip(
"tag this memory as a named preset, reachable afterwards as "
"<assembly>.memory.presets.<name>(). Reusing an existing name "
"redirects that preset to this new entry; the old entry stays "
"around as a plain memory."
)
preset_layout.addWidget(self.save_preset_edit)
save_vbox.addLayout(preset_layout)
# opt-in, non-default item picker: unchecked, this row/list stays
# hidden and Save captures everything the selection resolves to,
# exactly as before this existed.
@@ -512,14 +557,19 @@ class MemoryBrowserQt:
for row in self._overview:
date_str = row["date"].strftime("%Y-%m-%d %H:%M") if row["date"] else "?"
groups = ",".join(row["groups"])
label = f"{date_str} {row['message']} [{groups}]"
message = row["message"]
ancestor_name = row.get("ancestor_name")
if ancestor_name:
label = f"[{ancestor_name}] {label}"
item = QtWidgets.QListWidgetItem(label)
message = f"[{ancestor_name}] {message}"
item = QtWidgets.QTreeWidgetItem(
[date_str, message, groups, row.get("presetname") or ""]
)
if ancestor_name:
item.setBackground(QtGui.QColor(color_map[ancestor_name]))
self.list.addItem(item)
for col in range(4):
item.setBackground(col, QtGui.QColor(color_map[ancestor_name]))
self.list.addTopLevelItem(item)
for col in range(4):
self.list.resizeColumnToContents(col)
if color_map:
self.legend_label.setText(
@@ -531,8 +581,8 @@ class MemoryBrowserQt:
else:
self.legend_label.setText("")
def _on_pick_stored(self, item):
idx = self.list.row(item)
def _on_pick_stored(self, item, column=0):
idx = self.list.indexOfTopLevelItem(item)
row = self._overview[idx]
ancestor = row.get("ancestor")
try:
@@ -545,9 +595,11 @@ class MemoryBrowserQt:
{"settings": sliced}, key=None, path=None,
source_ancestor_name=row["ancestor_name"],
)
self.preset_tag_edit.setText("")
else:
mem = self.memory.get_memory(key=row["key"])
self._load_mem(mem, key=row["key"], path=None)
self.preset_tag_edit.setText(row.get("presetname") or "")
except Exception as e:
self._set_status(f"could not load memory: {e}", error=True)
@@ -565,6 +617,61 @@ class MemoryBrowserQt:
self._set_status(f"could not load {path}: {e}", error=True)
return
self._load_mem(mem, key=None, path=path)
self.preset_tag_edit.setText("")
def _on_apply_preset_name(self):
key = self._state.get("key")
if key is None:
self._set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
name = self.preset_tag_edit.text().strip() or None
try:
self.memory.set_preset_name(key=key, name=name)
self._set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
self._refresh_overview()
except Exception as e:
self._set_status(f"could not update preset tag: {e}", error=True)
def _on_delete_entry(self):
from qtpy import QtWidgets
key = self._state.get("key")
if key is None:
self._set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
reply = QtWidgets.QMessageBox.question(
self.window,
"Confirm delete",
f"Permanently remove this stored memory entry ({key})? "
"This cannot be undone from here.",
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
)
if reply != QtWidgets.QMessageBox.Yes:
return
try:
self.memory.clear_memory(key=key)
except Exception as e:
self._set_status(f"delete failed: {e}", error=True)
return
self._state = {
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
"source_ancestor_name": None,
}
self.tree.clear()
while self.group_row.count() > 1:
child = self.group_row.takeAt(1)
if child.widget():
child.widget().deleteLater()
self._group_checks = {}
self.preset_tag_edit.setText("")
self._set_status(f"deleted stored memory {key}")
self._refresh_overview()
def _load_mem(self, mem, key, path, source_ancestor_name=None):
self._state = {
@@ -778,12 +885,14 @@ class MemoryBrowserQt:
self._set_status("no items selected -- uncheck 'choose items' to save everything, or check at least one", error=True)
return
kwargs["pick_items"] = names
preset_name = self.save_preset_edit.text().strip() or None
try:
self.memory.memorize(
message=message, force_message=False, to_elog=self.elog_cb.isChecked(),
selection=selection, **kwargs,
selection=selection, preset_varname=preset_name, **kwargs,
)
self.message_edit.clear()
self.save_preset_edit.clear()
self._set_status("saved new memory")
self._refresh_overview()
except Exception as e:
@@ -834,6 +943,20 @@ def make_memory_browser_ipywidgets(assembly):
)
load_file_btn = widgets.Button(description="Load from file")
preset_tag_text = widgets.Text(
placeholder="preset name for the loaded entry (empty clears it)",
layout=widgets.Layout(width="50%"),
)
apply_preset_btn = widgets.Button(description="Apply preset tag")
delete_entry_btn = widgets.Button(
description="Delete entry", button_style="danger",
tooltip=(
"permanently remove this stored memory entry (not the whole "
"device's memory -- just this one loaded snapshot)"
),
)
_delete_armed = {"key": None}
group_filter_box = widgets.HBox([widgets.Label("Keyword groups:")])
changed_only_cb = widgets.Checkbox(value=True, description="show changed only")
@@ -867,6 +990,16 @@ def make_memory_browser_ipywidgets(assembly):
)
elog_cb = widgets.Checkbox(value=True, description="post to elog")
save_btn = widgets.Button(description="Save new memory", button_style="success")
save_preset_text = widgets.Text(
placeholder="preset name (optional) -- leave empty for a plain memory",
layout=widgets.Layout(width="60%"),
tooltip=(
"tag this memory as a named preset, reachable afterwards as "
"<assembly>.memory.presets.<name>(). Reusing an existing name "
"redirects that preset to this new entry; the old entry stays "
"around as a plain memory."
),
)
# opt-in, non-default item picker: unchecked, this box stays empty and
# Save captures everything the selection resolves to, exactly as
@@ -890,6 +1023,10 @@ def make_memory_browser_ipywidgets(assembly):
color = "#c62828" if error else "#2e7d32"
status_html.value = f"<span style='color:{color}'>{text}</span>"
def _reset_delete_arm():
_delete_armed["key"] = None
delete_entry_btn.description = "Delete entry"
def _load_row(row):
ancestor = row.get("ancestor")
try:
@@ -900,12 +1037,61 @@ def make_memory_browser_ipywidgets(assembly):
{"settings": sliced}, key=None, path=None,
source_ancestor_name=row["ancestor_name"],
)
preset_tag_text.value = ""
else:
mem = memory.get_memory(key=row["key"])
_load_mem(mem, key=row["key"], path=None)
preset_tag_text.value = row.get("presetname") or ""
_reset_delete_arm()
except Exception as e:
_set_status(f"could not load memory: {e}", error=True)
def _on_apply_preset_name(_b):
key = state.get("key")
if key is None:
_set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
name = preset_tag_text.value.strip() or None
try:
memory.set_preset_name(key=key, name=name)
_set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
_refresh_overview()
except Exception as e:
_set_status(f"could not update preset tag: {e}", error=True)
def _on_delete_entry(_b):
key = state.get("key")
if key is None:
_set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
if _delete_armed["key"] != key:
_delete_armed["key"] = key
delete_entry_btn.description = "Click again to confirm delete"
_set_status(f"click 'Delete entry' again to permanently remove {key}", error=True)
return
try:
memory.clear_memory(key=key)
except Exception as e:
_set_status(f"delete failed: {e}", error=True)
return
state.update({
"key": None, "path": None, "mem": None, "checked": {},
"source_ancestor_name": None,
})
detail_box.children = ()
group_filter_box.children = (widgets.Label("Keyword groups:"),)
group_checks.clear()
preset_tag_text.value = ""
_reset_delete_arm()
_set_status(f"deleted stored memory {key}")
_refresh_overview()
def _refresh_overview(_b=None):
own_rows = _overview_rows(memory)
all_rows = list(own_rows)
@@ -924,6 +1110,8 @@ def make_memory_browser_ipywidgets(assembly):
groups = ",".join(row["groups"])
ancestor_name = row.get("ancestor_name")
label = f"{date_str} {row['message']} [{groups}]"
if row.get("presetname"):
label += f" ★ {row['presetname']}"
if ancestor_name:
label = f"[{ancestor_name}] {label}"
btn = widgets.Button(
@@ -1044,6 +1232,8 @@ def make_memory_browser_ipywidgets(assembly):
_set_status(f"could not load {path}: {e}", error=True)
return
_load_mem(mem, key=None, path=path)
preset_tag_text.value = ""
_reset_delete_arm()
def _select_all(_b):
for r in state.get("rows", []):
@@ -1142,12 +1332,14 @@ def make_memory_browser_ipywidgets(assembly):
)
return
kwargs["pick_items"] = names
preset_name = save_preset_text.value.strip() or None
try:
memory.memorize(
message=message, force_message=False, to_elog=elog_cb.value,
selection=selection, **kwargs,
selection=selection, preset_varname=preset_name, **kwargs,
)
message_text.value = ""
save_preset_text.value = ""
_set_status("saved new memory")
_refresh_overview()
except Exception as e:
@@ -1157,6 +1349,8 @@ def make_memory_browser_ipywidgets(assembly):
show_parents_cb.observe(_refresh_overview, names="value")
refresh_btn.on_click(_refresh_overview)
load_file_btn.on_click(_on_load_file)
apply_preset_btn.on_click(_on_apply_preset_name)
delete_entry_btn.on_click(_on_delete_entry)
changed_only_cb.observe(_refresh_detail, names="value")
select_all_btn.on_click(_select_all)
select_none_btn.on_click(_select_none)
@@ -1179,6 +1373,7 @@ def make_memory_browser_ipywidgets(assembly):
overview_box,
legend_html,
widgets.HBox([refresh_btn, load_path_text, load_file_btn]),
widgets.HBox([preset_tag_text, apply_preset_btn, delete_entry_btn]),
group_filter_box,
changed_only_cb,
widgets.HTML("<b>Details</b> (checkbox selects for recall)"),
@@ -1187,6 +1382,7 @@ def make_memory_browser_ipywidgets(assembly):
widgets.HBox([recall_btn, export_path_text, export_btn]),
widgets.HTML("<b>Save current state as a new memory</b>"),
widgets.HBox([message_text, save_selection_combo, elog_cb, save_btn]),
widgets.HBox([save_preset_text]),
widgets.HBox([
pick_items_cb, refresh_items_btn, pick_all_items_btn, pick_none_items_btn,
]),
@@ -1267,6 +1463,20 @@ class MemoryBrowserTk:
side="left", padx=(4, 0)
)
preset_tag_row = ttk.Frame(self.top)
preset_tag_row.pack(fill="x", padx=4, pady=2)
ttk.Label(preset_tag_row, text="Preset tag (for loaded entry):").pack(side="left")
self.preset_tag_var = tk.StringVar()
ttk.Entry(preset_tag_row, textvariable=self.preset_tag_var, width=20).pack(
side="left", padx=(4, 4)
)
ttk.Button(
preset_tag_row, text="Apply", command=self._on_apply_preset_name
).pack(side="left")
ttk.Button(
preset_tag_row, text="Delete entry", command=self._on_delete_entry
).pack(side="left", padx=(8, 0))
filter_box = ttk.LabelFrame(self.top, text="Filters (within an opened memory)")
filter_box.pack(fill="x", padx=4, pady=4)
self.group_row = ttk.Frame(filter_box)
@@ -1336,6 +1546,11 @@ class MemoryBrowserTk:
ttk.Checkbutton(save_box, text="post to elog", variable=self.elog_var).pack(
side="left", padx=4
)
ttk.Label(save_box, text="preset:").pack(side="left", padx=(8, 0))
self.save_preset_var = tk.StringVar()
ttk.Entry(save_box, textvariable=self.save_preset_var, width=16).pack(
side="left", padx=(2, 4)
)
ttk.Button(save_box, text="Save", command=self._on_save).pack(side="left", padx=4)
self.status_label = ttk.Label(self.top, text="")
@@ -1363,6 +1578,8 @@ class MemoryBrowserTk:
groups = ",".join(row["groups"])
ancestor_name = row.get("ancestor_name")
label = f"{date_str} {row['message']} [{groups}]"
if row.get("presetname"):
label += f" ★ {row['presetname']}"
if ancestor_name:
label = f"[{ancestor_name}] {label}"
self.overview_list.insert("end", label)
@@ -1393,9 +1610,11 @@ class MemoryBrowserTk:
{"settings": sliced}, key=None, path=None,
source_ancestor_name=row["ancestor_name"],
)
self.preset_tag_var.set("")
else:
mem = self.memory.get_memory(key=row["key"])
self._load_mem(mem, key=row["key"], path=None)
self.preset_tag_var.set(row.get("presetname") or "")
except Exception as e:
self._set_status(f"could not load memory: {e}", error=True)
@@ -1413,6 +1632,58 @@ class MemoryBrowserTk:
self._set_status(f"could not load {path}: {e}", error=True)
return
self._load_mem(mem, key=None, path=path)
self.preset_tag_var.set("")
def _on_apply_preset_name(self):
key = self._state.get("key")
if key is None:
self._set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
name = self.preset_tag_var.get().strip() or None
try:
self.memory.set_preset_name(key=key, name=name)
self._set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
self._refresh_overview()
except Exception as e:
self._set_status(f"could not update preset tag: {e}", error=True)
def _on_delete_entry(self):
from tkinter import messagebox
key = self._state.get("key")
if key is None:
self._set_status(
"select a stored memory from the list above first "
"(not a file-loaded or parent-sourced one)", error=True,
)
return
if not messagebox.askyesno(
"Confirm delete",
f"Permanently remove this stored memory entry ({key})? "
"This cannot be undone from here.",
parent=self.top,
):
return
try:
self.memory.clear_memory(key=key)
except Exception as e:
self._set_status(f"delete failed: {e}", error=True)
return
self._state = {
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
"source_ancestor_name": None,
}
for child in self.detail_inner.winfo_children():
child.destroy()
for child in self.group_row.winfo_children()[1:]:
child.destroy()
self._group_vars = {}
self.preset_tag_var.set("")
self._set_status(f"deleted stored memory {key}")
self._refresh_overview()
def _load_mem(self, mem, key, path, source_ancestor_name=None):
import tkinter as tk
@@ -1568,12 +1839,14 @@ class MemoryBrowserTk:
self._set_status("enter a message before saving", error=True)
return
selection = self.save_selection_var.get().strip() or "settings"
preset_name = self.save_preset_var.get().strip() or None
try:
self.memory.memorize(
message=message, force_message=False, to_elog=self.elog_var.get(),
selection=selection,
selection=selection, preset_varname=preset_name,
)
self.message_var.set("")
self.save_preset_var.set("")
self._set_status("saved new memory")
self._refresh_overview()
except Exception as e:
+183
View File
@@ -0,0 +1,183 @@
# Task: create the default `EventWorker` lazily in `escape.stream`
For a session working in the **escape-fel** repo. You have no context from the
conversation this came from, so everything you need is here. Line numbers refer
to the installed copy (escape-fel 0.2.13, `.../site-packages/escape/stream/`);
your checkout may differ slightly, so grep for the patterns quoted below rather
than trusting the numbers. The likely working checkout is
`/sf/bernina/config/personal/lemke_h/escape-fel` (its `escape_stream.py` is the
closest to the installed copy, but not identical, so confirm against the code you
actually have).
## 1. The bug
In a fresh process, with no `EventWorker(make_default=True)` created beforehand:
```python
from escape.stream import Stream
t = Stream.from_dispatcher("JF16T03V02:roi_intensities")
t.accumulate()
# AttributeError: 'NoneType' object has no attribute 'eventCallbacks'
# Stream.accumulate -> Stream._is_accumulating -> self._source.eventWorker.eventCallbacks
```
`t._source.eventWorker` is `None`. A stream built before a default worker exists
keeps `None` **permanently**: creating a worker afterwards does not repair it.
Reproduced like this:
```python
s = Stream.from_dispatcher("x") # s._source.eventWorker -> None
ew = EventWorker(make_default=True)
s2 = Stream.from_dispatcher("x") # s2._source.eventWorker is ew -> True
s._source.eventWorker # still None
```
## 2. Root cause
The default worker is a module global, `escape.stream.escape_stream.eventworker`.
Only one thing sets it: `EventWorker.__init__(..., make_default=True)`
(`globals()["eventworker"] = self`). Nothing in escape creates a worker on its
own. Consumers look the global up **once, at construction time**, and fall back
to `None`:
| Site | What it does today |
|---|---|
| `EventSource.__init__` (~L430) | `if eventWorker is None and "eventworker" in globals(): eventWorker = globals()["eventworker"]`, else stays `None` |
| `Stream.__init__` (~L1356) | for a `str` name: `eventworker = globals().get("eventworker")`, then builds `EventSource(...)` |
| `from_getter` (~L598) | `eventworker = globals().get("eventworker")` |
| `_resolve_eventworker` (~L2898), used by `pulse_id()` / `lab_time()` | same lookup, but **raises** `RuntimeError("No default EventWorker registered; pass one explicitly: ...")` |
| `StreamSession.__init__` (`session.py` ~L105) | `_esn.__dict__.get("eventworker")`: reuse an existing default, else build its own worker with the caller's `handler_kwargs` |
| `_wrapOperatorJoin` (~L3007) | takes `ew` from the *other* operand, then passes it to `from_getter(..., eventworker=ew)`. `ew` can be `None` here |
Downstream (the eco beamline package) only creates the worker as a side effect of
constructing its first `DetectorBsStream`. In a fresh session where the user goes
straight to `Stream.from_dispatcher(...)`, nothing has created one, hence the crash.
## 3. Required behaviour
1. Any `Stream` / `EventSource` / `from_getter` built **without** an explicit
worker gets a usable default `EventWorker`, creating and registering one if none
exists yet.
2. All such objects in a process share **one** default worker (it owns the single
dispatcher connection, so a second default means a second connection).
3. An explicitly passed `eventworker=` always wins and never touches the default.
4. Creation is **lazy and offline**: no thread started and no connection opened
until something calls `registerSource(s)` / `startEventLoop()`.
Constructing `EventWorker()` does no network I/O. `DataHubEventHandler.__init__`
and `EventHandler_SFEL.__init__` only store config. So creating the default at
`Stream(...)` construction time is safe.
5. No behaviour change for code that already creates its own
`EventWorker(make_default=True)` first. That one is simply returned.
## 4. Implementation
Put two small helpers in `escape_stream.py` next to `EventWorker`, with a module
lock:
```python
_default_lock = threading.Lock()
def _peek_default_eventworker():
"""The registered default EventWorker, or None. Never creates one."""
return globals().get("eventworker")
def get_default_eventworker():
"""Return the module-default EventWorker, creating and registering one
(``EventWorker(make_default=True)``, default handler) on first use.
Thread-safe: concurrent first calls yield exactly one worker."""
ew = globals().get("eventworker")
if ew is None:
with _default_lock:
ew = globals().get("eventworker") # re-check under the lock
if ew is None:
ew = EventWorker(make_default=True) # sets globals()["eventworker"]
return ew
```
Notes on the helper:
- `EventWorker.__init__` already picks `DataHubEventHandler(backend="bsread")` when
`_HAS_DATAHUB`, else `EventHandler_SFEL()`, so no handler logic is needed here.
- `EventWorker(make_default=True)` prints "EventWorker registered as module default."
Keeping that is fine and useful for the user.
- Export `get_default_eventworker` from `escape/stream/__init__.py`. Downstream
code will import it by that name (see section 6), so **keep this name and
signature stable**, and tell the user the final public name.
Call-site changes:
| Site | Change |
|---|---|
| `EventSource.__init__` | `if eventWorker is None: eventWorker = get_default_eventworker()` |
| `Stream.__init__` (str-name branch) | `if eventworker is None: eventworker = get_default_eventworker()` |
| `from_getter` | same. Harmless even with no real Stream involved, since the worker's thread isn't started until something registers a channel. It also makes `from_getter(x) + Stream("ch")` always share one worker |
| `_resolve_eventworker` | use `get_default_eventworker()` instead of raising, so `pulse_id()` / `lab_time()` work out of the box. **This removes the old `RuntimeError`.** grep the tests for `"No default EventWorker"` and update any that assert it |
| `StreamSession.__init__` | **do not** use the creating helper. Keep reuse-or-build-own semantics, using `_peek_default_eventworker()` for the "reuse" check. Otherwise a session with custom `handler_kwargs` would silently get a default worker built without them |
| `_wrapOperatorJoin` | no change needed once `from_getter` resolves the default itself |
Also update the docstrings that say "falls back to the module-level default
registered by `EventWorker(make_default=True)`" (`Stream`, `Stream.from_dispatcher`,
`from_getter`, `pulse_id`, `lab_time`, `EventWorker`'s `make_default`): the default
is now also *created on first use*.
## 5. Tests to add
Tests must **not** touch the real dispatcher. Everything below only constructs
objects. Never call `.accumulate()` on a default-handler worker in a test. Reset
module state per test:
```python
import escape.stream.escape_stream as esn
@pytest.fixture(autouse=True)
def _clean_default(monkeypatch):
monkeypatch.delattr(esn, "eventworker", raising=False)
```
1. `Stream("x")` and `Stream.from_dispatcher("x")` in a clean module have a non-`None`
`_source.eventWorker`, and no thread started (`ew.loopThread is None`).
2. Two streams built back-to-back share the identical worker object.
3. An explicit `eventworker=` is used as-is, and `esn.eventworker` stays unset.
4. A pre-existing `EventWorker(make_default=True)` is returned, not replaced.
5. 16 threads calling `get_default_eventworker()` behind a `threading.Barrier`
produce exactly one distinct worker.
6. `pulse_id()` / `lab_time()` work with no prior worker (and replace any old
`RuntimeError` test).
7. `from_getter(lambda: 1)` has a non-`None` `eventWorker`, and `from_getter(...) +
Stream("x")` shares one worker.
8. `StreamSession("localhost:9999")` (or a `LocalEventHandler`) still builds its
own worker with its handler kwargs, and does not create the default as a side
effect.
9. Regression for the original traceback: `Stream.from_dispatcher("x")._is_accumulating()`
returns `False` instead of raising.
## 6. Downstream impact: read before finishing
The eco package (`eco/detector/detectors_psi.py::_ensure_bs_event_worker`) currently
creates its own worker with `stream.EventWorker(make_default=True)` on its first
`DetectorBsStream`. **With your change alone there is a new failure mode:**
1. the user does `Stream.from_dispatcher(...)`, so your lazy default is created;
2. later they touch an eco bs-stream device, and eco builds a **second**
`EventWorker(make_default=True)`, which overwrites `esn.eventworker`.
Result: two dispatcher connections, and the user's earlier streams are orphaned on
the first worker while new ones land on the second. This is the same footgun as any
second `make_default=True` call.
eco will be switched to `get_default_eventworker()` (falling back to the old call
when it isn't available, so old escape installs keep working). That is a separate
change in the eco repo, not yours. But it is why the helper's name must be stable,
and why you should tell the user when it lands.
Optional, only if cheap: have `EventWorker.__init__(make_default=True)` print a
one-line warning when it replaces an existing, different default instance. That would
have made the situation above visible. Don't change the overwrite semantics.
## 7. Done when
- The repro in section 1 no longer raises: `Stream.from_dispatcher("x")._source.eventWorker`
is a real `EventWorker`, `_is_accumulating()` returns `False`, and no network
traffic occurred.
- The new tests pass, and the existing test suite still passes (apart from the
deliberately changed `RuntimeError` expectation).
- Report to the user: the exported helper name, the list of call sites changed,
and whether you bumped the version, so eco can pin or feature-detect accordingly.
+806
View File
@@ -0,0 +1,806 @@
# Task: add a "Select curves" toolbar button to `escape.plot_utilities`
> **Status 2026-10-05: done.** Implemented in escape-fel as
> `escape.plot_utilities.attach_select_button` (`escape/select_gui.py`, commit
> `f779cb2`, tag `v0.2.14`; 14 tests in `tests/plot/test_select_curves.py`;
> Appendix B below passes unchanged against it). eco calls it from
> `eco/dbase/archiver.py::_attach_select_curves`, falling back to its built-in
> copy until the beamline environments have escape >= 0.2.14. This file is kept
> for reference only: do not redo the task.
For a session working in the **escape-fel** repo. You have no context from the
conversation this came from, so everything you need is here. The working checkout
is `/sf/bernina/config/personal/lemke_h/escape-fel`. Line numbers are deliberately
not quoted: grep for the names given below.
A **tested prototype** exists in the eco repo, in
`/sf/bernina/config/personal/lemke_h/eco/eco/dbase/archiver.py` (functions
`_antialiasing` ... `_attach_curve_selector`, called from `_plot_dataframe`).
Appendix A is a verbatim copy as of 2026-10-05; the eco file is authoritative if
they differ. Appendix B is a headless behaviour check that passes against the
prototype and is meant to pass, unchanged, against your implementation. Your job
is to turn the prototype into a proper escape tool that follows escape's existing
toolbar-button pattern. Don't paste it in as it is.
## 1. What this is for
eco's archiver plots (`DataHub.get_data_time_range(..., plot=True)`) now produce
figures with many curves. One Assembly gives nine: x/y/z times direction/offset/
readback. The legend covers the data, and one curve's scale (up to about 120)
dwarfs the others (0 to 25), so the interesting curves are squashed flat. The user
wants a button in the plot window's own toolbar that opens a small window with one
checkbox per curve, to show only some of them. Hiding a curve must also drop it
from the legend and re-fit the y-range, since "this one's scale dwarfs the rest"
is the usual reason to hide one.
The user decided this belongs in escape's plot helpers, next to the Fit / Peak /
Freq toolbar buttons, as an **opt-in option**, and that eco will then call it
instead of carrying its own copy (section 6). The user was specific about
placement: **the toolbar's empty slot directly to the right of the Save icon**.
Is there a stock matplotlib tool for this? Not as a ready-made popup.
`matplotlib.widgets.CheckButtons` is the building block, and the "legend picking"
recipe lets you click legend entries. The Qt figure-options dialog (the toolbar's
"Edit axis, curve ..." button) edits colour/style/labels but has **no show/hide
toggle**. The first prototype used `CheckButtons` and was dropped, see 4.2.
## 2. Required behaviour
1. **Button (Qt backends).** A toolbar button with a "list of checkboxes" icon,
tooltip `Select curves to show`, placed **directly after Save** and in front of
the stretchy coordinates label.
2. **Window.** Clicking opens a non-modal window titled `Select curves`, a child of
the figure window, containing:
- a filter box (case-insensitive substring match on the labels),
- a scrollable list, one checkbox per curve of the active axes, each row showing
the curve's legend handle (short coloured line with a dot) and its label,
- `All` / `None` buttons that act on **the rows the filter currently shows**,
so "only the readbacks" is: `None`, type `readback`, `All`.
Ticking applies immediately. There is no OK button.
3. **Effect of hiding a curve.**
- `line.set_visible(False)`.
- The legend is rebuilt without it: same location, title, column count and
frame. Other labelled artists (bands, scatter) keep their entries. If the axes
had **no** legend, none is created. The legend look must survive the state
where every curve is hidden (the legend is then gone, and "All" must bring it
back).
- The y-range is re-fit to the visible curves: `relim(visible_only=True)`,
`autoscale(enable=True, axis="y")`, `autoscale_view(scalex=False)`. The x-range
is left alone. This must also work **after a toolbar zoom/pan**, which switches
y-autoscale off.
4. **Lifetime.** Clicking again reuses the same window (it keeps the filter text and
scroll position) and brings it to the front. Closing the figure closes it.
5. **Contract**, the same as `attach_fit_button` / `attach_peak_button`:
- a no-op, not an error, on unsupported backends,
- failures are swallowed with a printed `[escape] ...` note,
- idempotent per figure,
- honours `ESCAPE_TOOLBAR_BUTTONS`,
- accepts and runs the `before_click` hook,
- no button for fewer than two curves.
6. **ipympl** (`%matplotlib widget`) gets the same behaviour through ipywidgets, 4.3.
## 3. Where it goes
| Where | What |
|---|---|
| `escape/select_gui.py` (new) | The engine (pure matplotlib: apply visibility, rebuild legend, refit y), the Qt dialog, and the ipywidgets panel. Import only matplotlib at module level, and qtpy/ipywidgets **lazily inside functions**. Same split as `fit_gui.py` / `freq_gui.py` and `_PeakEngine`'s "engine shared by Qt and ipywidgets front-ends". It also keeps the engine and dialog testable in a bare interpreter. |
| `escape/plot_utilities.py` | `_build_select_icon`, `_insert_after_save`, `_run_select_button`, `_attach_select_button_qt`, `_attach_select_button_ipympl`, public `attach_select_button(fig, *, before_click=None)`. Add `select=False` to `attach_escape_buttons`. Add `select_button=False` to `nfigure`, `nsubplots`, `nsubplot_mosaic`, with docstrings. Add `"_escape_select_gui"` to `_AXES_GUI_ATTRS`, so `_close_axes_guis` cleans it up. |
| `docs/api/plotting.rst` | A short "Toolbar buttons" section with `.. autofunction:: escape.plot_utilities.attach_select_button`. The other `attach_*` functions aren't listed there at present. Don't add them unasked. |
| `escape/icons/` | Probably nothing: see 4.5. |
Follow the existing pattern literally. Read, in `escape/plot_utilities.py`:
`attach_fit_button`, `_attach_fit_button_qt`, `_attach_fit_button_ipympl`,
`_run_fit_button`, `_run_before_click`, `_detect_plot_backend`,
`_track_active_axes` / `_get_active_axes`, `_get_or_create_axes_gui`,
`_defer_to_event_loop`, `ESCAPE_TOOLBAR_BUTTONS`, and `IpywidgetsPeakAnalyzer`
for how an ipywidgets panel is built, shown and closed. In `escape/_axes_selection.py`
read `snapshot_data_lines`.
Specifics:
- **`_run_select_button(fig)`**: `_run_before_click(fig)`, then `ax = _get_active_axes(fig)`
(print and return if `None`), then
`_get_or_create_axes_gui(ax, "_escape_select_gui", lambda: <dialog>)`.
`_get_or_create_axes_gui` raises an existing panel to the front but does not
`show()` a new one, so **the factory must show the dialog it returns**.
Importing the dialog's module belongs inside the click handler, as for Fit.
- **Which lines:** the Line2D artists of the active axes. Use `snapshot_data_lines(ax)`
for consistency with the Fit/Freq tools. It already skips escape's own overlays
(`_escape_overlay`) and SpanSelector handle lines. If you find lines added after
that snapshot are missing, filter `ax.get_lines()` yourself with the same
exclusion rule instead, and say so in your report.
- **Labels:** `line.get_label()`. Matplotlib's "no legend entry" labels start with
`_` (`_child3`): show those as `(line 3)`.
- **Multi-axes figures:** active axes only, like Fit/Peak. Lines on a `twinx` axes
belong to another Axes object: not handled in this version, mention it in your report.
- **Default:** `select_button=False` / `select=False` (opt-in), unlike Fit.
## 4. Implementation notes (all learned the hard way while prototyping)
### 4.1 Engine
Port `_CurveSelector.apply` and `_legend_kwargs` from Appendix A. Why each piece:
- `self._legend_look` is captured once at construction and refreshed from the live
legend on every `apply`, because with every curve hidden there is no live legend
to read from and "All" would otherwise not bring it back. `None` means "the axes
never had a legend: never create one".
- The rebuild goes through `ax.get_legend_handles_labels()` filtered by
`get_visible()`, not through "the selector's lines", so labelled artists the
selector doesn't manage (a `fill_between` band, a scatter) keep their entries.
- `ax.autoscale(enable=True, axis="y")` is needed: after a toolbar zoom/pan
matplotlib switches autoscale off for that axis and `autoscale_view` alone then
does nothing.
- `relim(visible_only=True)` (the default includes hidden lines), **but** `relim`
only knows lines/patches/images, not collections. With `ax.collections` present
(scatter, `fill_between`, escape's `errortube`) it would shrink the range to the
lines alone, so the prototype leaves the limits alone then. If you can do better
(compute the range from the visible artists by hand), do, and say so.
- Not preserved on the rebuilt legend: `bbox_to_anchor` (the stored value is a
`TransformedBbox` that can't be passed back as it is). Mention it in your report.
- Legend look is read from the private `legend._loc` / `_ncols`(`_ncol` on older
matplotlib), there is no public getter. Guarded with `getattr`.
### 4.2 Qt dialog
Port `_make_select_dialog`. It already uses `qtpy`, like the rest of escape.
Don't use `matplotlib.widgets.CheckButtons` for the list: it puts the label at 25%
of the axes width (a huge gap in a wide window), clips long labels (these are ~55
characters), and has neither scrolling nor a filter. The prototype's first version
used it and was replaced.
Compatibility points already handled in the prototype (keep them):
- No Qt enums at all (`QCheckBox.setChecked(bool)`, colour via `QColor(0,0,0,0)`),
so it works on PyQt5/6 and PySide alike.
- `getattr(QtGui.QPainter, "Antialiasing", None) or QtGui.QPainter.RenderHint.Antialiasing`
- `getattr(QtGui, "QAction", None) or QtWidgets.QAction` (Qt6 moved it to QtGui)
- `fontMetrics().horizontalAdvance` with a `width` fallback (Qt < 5.11)
- **Every slot is wrapped** (`guarded`): PyQt5 calls `qFatal()`/`abort()` when an
exception escapes a slot unless `sys.excepthook` was replaced, and "swallow and
print" is escape's convention anyway. Print `[escape] ...` rather than using the
logger the prototype uses.
- The dialog is **parented to `fig.canvas.manager.window`**, and the selector also
closes it from a `close_event` hook. Parent-child deletion alone leaves it alive
until Qt processes deferred deletes. Keep a Python reference (the
`_get_or_create_axes_gui` cache does): a QWidget nobody references is garbage
collected and silently disappears, as that function's docstring already says.
### 4.3 ipympl front-end
Mirror the Fit button's ipympl path exactly: a zero-argument callable set as an
attribute on the toolbar (`toolbar.escape_select_button = _on_click`) plus an extra
`toolbar.toolitems` entry `("Select", "Select curves to show", "<font-awesome-4 name>",
"escape_select_button")`. Try `check-square-o` or `list-ul` and look at what renders.
The click handler is `_defer_to_event_loop(lambda: _run_select_button(fig))`.
The panel is an ipywidgets `VBox`: a `Text` filter, a scrollable container of
`Checkbox` rows (`layout=widgets.Layout(max_height="400px", overflow_y="auto")`), an
`HBox` of `All`/`None` buttons. It drives the **same engine** as the Qt dialog. Show,
cache and close it the way `IpywidgetsPeakAnalyzer` does. You probably cannot run an
ipympl notebook in this environment: implement it per the pattern, then say plainly in
your report that the ipympl path is untested, so the user can try it.
### 4.4 Placing the Qt button after Save
`toolbar.addAction(...)` **appends**, and matplotlib's Qt toolbar ends in a stretchy
coordinates label (`toolbar.locLabel`), so an appended button lands at the far right,
past the label. (That is where escape's existing Fit/Peak/Freq buttons land, via
`addSeparator()` + `addAction`. Leave them alone, but tell the user: the same helper
could move them next to Save if they want.) Instead insert in front of whatever
follows Save: `toolbar._actions["save_figure"]` is matplotlib's own record of it
(private, so guard with `getattr`), then `toolbar.insertAction(next_action, action)`.
Fallbacks, in order: in front of the action that wraps `locLabel`
(`toolbar.widgetForAction(a) is toolbar.locLabel`), then plain append. See
`_add_toolbar_button` in Appendix A. Put the logic in `_insert_after_save`.
### 4.5 Icon
`_build_select_icon(size=24)`: draw it procedurally, as `_checklist_icon` does in
Appendix A, in **the toolbar's own text colour**:
`toolbar.palette().color(toolbar.foregroundRole())`. `QToolBar` paints with the
`ButtonText` role, not `WindowText`. matplotlib only recolours its own PNG icons when
the toolbar background is dark, and uses that same role. Do **not** hard-code
`"black"` as `_build_fit_icon`'s fallback does: a black glyph vanishes on dark themes.
For the same reason, don't add an SVG for this one (fixed colours): leave
`escape/icons/` alone unless you have a better idea, and say why in your report.
### 4.6 Traps when testing headless
Appendix B already contains these workarounds. For reference:
- matplotlib refuses a Qt backend when it thinks there is no display
(`cbook._get_running_interactive_framework()` returns `"headless"`): bypass it.
- `backend_qt._create_qApp` raises `Invalid DISPLAY variable` unless a `QApplication`
already exists: create one yourself, with `QT_QPA_PLATFORM=offscreen`, **and keep a
reference to it**: it is destroyed as soon as it is garbage-collected.
- `QApplication.processEvents()` does not run deferred deletions.
## 5. Tests / verification
`CLAUDE.md` says there is no maintained suite, but the sibling hand-off in the eco
repo (`escape_default_eventworker_handoff.md`) assumes pytest. Check the repo's
current state: if a pytest setup exists, convert Appendix B into a test file
(`pytest.importorskip("qtpy")`, and skip when the Qt platform plugin can't start).
If not, keep it as a standalone script and say how to run it. Either way it must pass:
QT_QPA_PLATFORM=offscreen python3 check_select_curves.py # prints "all checks passed"
It was written against the API names in section 3: the adapter at its top imports
`attach_select_button` and finds the dialog at `fig.axes[0]._escape_select_gui`. It
falls back to the eco prototype when escape doesn't have the function yet, which is
how it was validated. If you name or cache things differently, adjust the two
adapter functions, not the checks.
Environment: per `CLAUDE.md`, the bare `python3` usually lacks the dependencies to
import `escape` itself. The check script needs only numpy, matplotlib, qtpy and a Qt
binding, so you can run it even in such an interpreter, but it only exercises your
code once `escape.plot_utilities` is importable. Use the interpreter you normally use
for this repo for that. The prototype was verified with Python 3.9, matplotlib 3.9.4,
PyQt5 and qtpy 2.4.3.
Also try it for real once, if a display is available: `fig = nfigure(select_button=True)`,
plot a few labelled lines, and look at where the button sits.
## 6. Downstream: eco switches to your version
Not your change, but it is why names matter. Once your version is released, eco's
`_plot_dataframe` (in `eco/dbase/archiver.py`) will replace its own
`_attach_curve_selector(fig, ah, lines)` call with, roughly:
try:
from escape.plot_utilities import attach_select_button
except ImportError: # older escape without the selector
attach_select_button = None
...
if attach_select_button is not None:
attach_select_button(fig)
and delete the local copy (`_antialiasing` ... `_attach_curve_selector`). So keep
`attach_select_button(fig, *, before_click=None)` **stable**, and tell the user the
final name, the files you changed, and the version that contains it, so eco can
feature-detect or pin. eco's figures have a legend created by `plt.legend()` and `.step`
lines with `.-` markers, all on the first (only) axes, which your "active axes"
default covers.
## 7. Don't
- **Don't `git push`.** Per `CLAUDE.md`, pushing `main` auto-tags and **publishes to
PyPI** via `.githooks/pre-push`. Commit locally only if the user asks, and let them
decide about pushing.
- Don't change Fit/Peak/Freq placement or behaviour (see 4.4), and don't touch the
EventWorker work in the other hand-off file.
- Don't add `matplotlib.widgets.CheckButtons` back (see 4.2).
## 8. Done when
- `attach_select_button(fig)` adds the button right after Save on a Qt figure with two
or more labelled lines, and does nothing, silently, on a single-line figure, on a
non-Qt/non-ipympl backend, and when `ESCAPE_TOOLBAR_BUTTONS` is off.
- `nfigure/nsubplots/nsubplot_mosaic(select_button=True)` and
`attach_escape_buttons(fig, select=True)` do the same.
- Appendix B passes against your implementation, and the existing code paths (Fit, Peak,
Freq buttons) behave as before.
- The docs entry builds (`sphinx-build docs docs/_build/html`, if the environment has Sphinx).
- Your report to the user states: the public name and signature, files changed, how the
lines are chosen (`snapshot_data_lines` or your own filter), what is **not** handled
(collections, `twinx`, `bbox_to_anchor`, ipympl untested?), whether the version needs a
bump (it comes from git tags), and the observation about the other buttons' placement.
---
## Appendix A: the prototype (eco, 2026-10-05), verbatim
Uses `_logger` (a stdlib logger) and `from matplotlib import colors as mcolors`
from the top of that module. Port to escape's conventions: `[escape]` prints instead
of the logger, lazy qtpy imports as already done here.
```python
def _antialiasing(QtGui):
return getattr(QtGui.QPainter, "Antialiasing", None) or QtGui.QPainter.RenderHint.Antialiasing
def _checklist_icon(toolbar, QtGui):
"""A small "list of checkboxes" icon, drawn in the toolbar's own text
colour so it matches the built-in monochrome buttons in light and dark
themes."""
size, ratio = 24, 2
pixmap = QtGui.QPixmap(size * ratio, size * ratio)
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(QtGui.QColor(0, 0, 0, 0))
painter = QtGui.QPainter(pixmap)
try:
painter.setRenderHint(_antialiasing(QtGui))
painter.setPen(QtGui.QPen(toolbar.palette().color(toolbar.foregroundRole()), 1.6))
for row, y in enumerate((3, 10, 17)):
painter.drawRect(3, y, 5, 5)
painter.drawLine(11, y + 2, 21, y + 2)
if row != 1: # the middle one is the unticked one
painter.drawLine(4, y + 3, 5, y + 4)
painter.drawLine(5, y + 4, 8, y)
finally:
painter.end()
return QtGui.QIcon(pixmap)
def _swatch_icon(QtGui, color):
"""A short line with a dot in `color`: the curve's legend handle."""
pixmap = QtGui.QPixmap(28, 12)
pixmap.fill(QtGui.QColor(0, 0, 0, 0))
painter = QtGui.QPainter(pixmap)
try:
painter.setRenderHint(_antialiasing(QtGui))
qcolor = QtGui.QColor(mcolors.to_hex(color))
painter.setPen(QtGui.QPen(qcolor, 2))
painter.drawLine(1, 6, 27, 6)
painter.setBrush(qcolor)
painter.drawEllipse(10, 3, 6, 6)
finally:
painter.end()
return QtGui.QIcon(pixmap)
def _add_toolbar_button(fig, tooltip, callback):
"""Add a button to `fig`'s own window toolbar - right after "Save" - if
its backend is a Qt one. Returns whether it did."""
if "qt" not in type(fig.canvas).__module__.lower():
return False
try:
from qtpy import QtGui, QtWidgets
toolbar = getattr(fig.canvas.manager, "toolbar", None)
if not isinstance(toolbar, QtWidgets.QToolBar):
return False
QAction = getattr(QtGui, "QAction", None) or QtWidgets.QAction # Qt6: QtGui
def run(*_):
# an exception escaping a Qt slot can take the whole session down
try:
callback()
except Exception:
_logger.exception("%s failed", tooltip)
action = QAction(_checklist_icon(toolbar, QtGui), tooltip, toolbar)
action.setToolTip(tooltip)
action.triggered.connect(run)
actions = toolbar.actions()
# The toolbar ends in a stretchy coordinates label: appending would
# put the button far right of it, so insert in front of whatever
# follows "Save" (or, without one, in front of that label).
anchor = getattr(toolbar, "_actions", {}).get("save_figure")
label = getattr(toolbar, "locLabel", None)
if anchor is not None and actions.index(anchor) + 1 < len(actions):
before = actions[actions.index(anchor) + 1]
else:
before = next((a for a in actions if toolbar.widgetForAction(a) is label), None)
if before is None:
toolbar.addAction(action)
else:
toolbar.insertAction(before, action)
return True
except Exception:
_logger.debug("no toolbar button for %r", tooltip, exc_info=True)
return False
def _make_select_dialog(selector):
"""The Qt window behind the toolbar button: a scrollable list with one
checkbox per curve (with the curve's legend handle), a filter box, and
All/None for the rows currently shown by the filter. Ticking applies
immediately."""
from qtpy import QtCore, QtGui, QtWidgets
lines = selector.lines
dialog = QtWidgets.QDialog(selector.fig.canvas.manager.window)
dialog.setWindowTitle("Select curves")
layout = QtWidgets.QVBoxLayout(dialog)
filter_box = QtWidgets.QLineEdit()
filter_box.setPlaceholderText("filter, e.g. readback")
filter_box.setClearButtonEnabled(True)
layout.addWidget(filter_box)
holder = QtWidgets.QWidget()
rows = QtWidgets.QVBoxLayout(holder)
boxes = []
for line in lines:
box = QtWidgets.QCheckBox(line.get_label())
box.setIcon(_swatch_icon(QtGui, line.get_color()))
box.setIconSize(QtCore.QSize(28, 12))
box.setChecked(line.get_visible())
rows.addWidget(box)
boxes.append(box)
rows.addStretch(1)
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(holder)
layout.addWidget(scroll)
def guarded(fn):
def run(*_):
try:
fn()
except Exception:
_logger.exception("curve selection failed")
return run
def sync():
selector.apply([box.isChecked() for box in boxes])
def set_shown(state):
for box in boxes:
if not box.isHidden(): # i.e. matches the filter
box.blockSignals(True)
box.setChecked(state)
box.blockSignals(False)
sync()
def refilter():
needle = filter_box.text().strip().lower()
for box in boxes:
box.setVisible(needle in box.text().lower())
buttons = QtWidgets.QHBoxLayout()
for text, state in (("All", True), ("None", False)):
button = QtWidgets.QPushButton(text)
button.clicked.connect(guarded(lambda state=state: set_shown(state)))
buttons.addWidget(button)
buttons.addStretch(1)
layout.addLayout(buttons)
for box in boxes:
box.toggled.connect(guarded(sync))
filter_box.textChanged.connect(guarded(refilter))
metrics = dialog.fontMetrics()
advance = getattr(metrics, "horizontalAdvance", None) or metrics.width
widest = max(advance(box.text()) for box in boxes)
dialog.resize(min(widest + 130, 1000), min(28 * len(boxes) + 140, 760))
return dialog
def _legend_kwargs(legend):
"""What is worth carrying over from `legend` to its replacement."""
kwargs = {"frameon": legend.get_frame_on()}
loc = getattr(legend, "_loc", None) # private, but the only place it is kept
if loc is not None:
kwargs["loc"] = loc
ncol = getattr(legend, "_ncols", None) or getattr(legend, "_ncol", None)
if ncol:
kwargs["ncol"] = ncol
title = legend.get_title().get_text()
if title:
kwargs["title"] = title
return kwargs
class _CurveSelector:
"""Show/hide a figure's curves from a Qt checkbox window (see
`_attach_curve_selector`). Hiding a curve also drops it from the legend
and re-fits the y-range to what is left, since the usual reason to hide
one is that its scale dwarfs the others."""
def __init__(self, fig, ax, lines):
self.fig = fig
self.ax = ax
self.lines = list(lines)
self._dialog = None
# None: there is no legend to maintain. Kept apart from the live one
# so it survives the state where every curve is hidden and it is gone.
legend = ax.get_legend()
self._legend_look = None if legend is None else _legend_kwargs(legend)
fig.canvas.mpl_connect("close_event", self._close_dialog)
def _close_dialog(self, _event=None):
try:
if self._dialog is not None:
self._dialog.close()
except RuntimeError: # already deleted along with the figure window
pass
self._dialog = None
def apply(self, states):
"""Show exactly the curves whose entry in `states` (parallel to the
curves) is true."""
for line, state in zip(self.lines, states):
line.set_visible(bool(state))
shown = [line for line in self.lines if line.get_visible()]
legend = self.ax.get_legend()
if legend is not None:
self._legend_look = _legend_kwargs(legend) # as the user left it
legend.remove()
if shown and self._legend_look is not None:
# rebuilt without the hidden curves; other labelled artists (bands,
# scatter, ...) keep their entries
entries = [
(handle, label)
for handle, label in zip(*self.ax.get_legend_handles_labels())
if getattr(handle, "get_visible", lambda: True)()
]
if entries:
self.ax.legend(*zip(*entries), **self._legend_look)
# relim() knows lines, patches and images but not collections (scatter,
# fill_between, ...): with those present it would shrink the range to
# the lines alone, so leave the limits alone then
if shown and not self.ax.collections:
self.ax.relim(visible_only=True)
self.ax.autoscale(enable=True, axis="y") # zooming had switched it off
self.ax.autoscale_view(scalex=False)
self.fig.canvas.draw_idle()
def open_dialog(self):
"""Show the checkbox window, creating it on first use; a closed one
is just shown again (it is a child of the figure window, so it goes
away with that)."""
dialog = self._dialog
try:
if dialog is not None:
dialog.isVisible() # raises RuntimeError once the window is gone
except RuntimeError:
dialog = None
if dialog is None:
dialog = self._dialog = _make_select_dialog(self)
dialog.show()
dialog.raise_()
dialog.activateWindow()
def _attach_curve_selector(fig, ax, lines):
"""Give `fig` a "select curves" button in its Qt window toolbar, which
opens a checkbox window to show/hide `lines`. Does nothing for a single
curve, or on backends without a Qt toolbar (inline, ipympl, non-GUI)."""
if len(lines) < 2:
return None
selector = _CurveSelector(fig, ax, lines)
if not _add_toolbar_button(fig, "Select curves to show", selector.open_dialog):
return None
fig._eco_curve_selector = selector
return selector
```
The call site in eco's `_plot_dataframe`, after the legend and `tight_layout()`:
```python
ah.figure.tight_layout()
_attach_curve_selector(fig, ah, lines) # <- the only eco-side hook
_show_figure(ah.figure)
```
## Appendix B: headless behaviour check
Passes against the prototype. Save as `check_select_curves.py`.
```python
"""Headless behaviour check for the "select curves" toolbar button.
Run: QT_QPA_PLATFORM=offscreen python3 check_select_curves.py
Exits non-zero on the first failed assertion.
"""
import sys
import matplotlib
import matplotlib.cbook as cbook
import numpy as np
from qtpy import QtGui, QtWidgets
# Offscreen Qt needs no display, but matplotlib insists on one before it will
# use a Qt backend or create the QApplication itself: bypass both checks, and
# keep a reference to the app (it is destroyed the moment it is garbage-collected).
cbook._get_running_interactive_framework = lambda: None
matplotlib.use("QtAgg")
APP = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
import matplotlib.pyplot as plt # noqa: E402
# --- adapter: the only part that differs between the eco prototype and escape ---
try:
from escape.plot_utilities import attach_select_button # target API
def attach(fig):
attach_select_button(fig)
def get_dialog(fig):
return getattr(fig.axes[0], "_escape_select_gui", None)
except ImportError: # the eco prototype this task was derived from
from eco.dbase import archiver as _proto
def attach(fig):
ax = fig.axes[0]
_proto._attach_curve_selector(fig, ax, ax.get_lines())
def get_dialog(fig):
return fig._eco_curve_selector._dialog
TIP = "Select curves to show"
def make_figure(n_lines=9, legend=True):
fig, ax = plt.subplots()
x = np.arange(200.0)
for i in range(n_lines):
kind = ("direction", "offset", "readback")[i % 3]
scale = 100.0 if i == 5 else 1.0 # one curve whose scale dwarfs the rest
ax.plot(x, scale * (np.sin(x / 20 + i) + i), ".-", label=f"stage.{i // 3}.{kind}")
if legend:
ax.legend()
return fig, ax
def toolbar_of(fig):
return fig.canvas.manager.toolbar
def select_action(fig):
return next((a for a in toolbar_of(fig).actions() if a.toolTip() == TIP), None)
def pump():
APP.processEvents()
def open_dialog(fig):
select_action(fig).trigger()
pump()
dialog = get_dialog(fig)
assert dialog is not None and dialog.isVisible(), "dialog did not open"
return dialog
def widgets_of(dialog):
boxes = dialog.findChildren(QtWidgets.QCheckBox)
edit = dialog.findChild(QtWidgets.QLineEdit)
buttons = {b.text(): b for b in dialog.findChildren(QtWidgets.QPushButton)}
return boxes, edit, buttons
def ylim_span(ax):
lo, hi = ax.get_ylim()
return hi - lo
# 1. placement: directly after Save, in front of the stretchy coordinates label
fig, ax = make_figure()
attach(fig)
tb = toolbar_of(fig)
acts = tb.actions()
save = tb._actions["save_figure"]
assert select_action(fig) is not None, "no toolbar action"
assert acts.index(select_action(fig)) == acts.index(save) + 1, "not right after Save"
assert tb.widgetForAction(acts[acts.index(select_action(fig)) + 1]) is tb.locLabel
assert not select_action(fig).icon().isNull(), "button has no icon"
# 2. dialog: one checked box per line, child of the figure window, colour swatches
dialog = open_dialog(fig)
boxes, edit, buttons = widgets_of(dialog)
assert len(boxes) == 9 and all(b.isChecked() for b in boxes)
assert not boxes[0].icon().isNull(), "rows should carry the line's legend handle"
w = dialog
while w is not None and w is not fig.canvas.manager.window:
w = w.parent()
assert w is not None, "dialog is not a child of the figure window"
assert len(ax.get_legend().get_texts()) == 9
# 3. unticking: line hidden, legend entry gone, y-range refit to what is left
before = ylim_span(ax)
big = next(b for b in boxes if b.text() == "stage.1.readback") # the x100 curve (i=5)
big.setChecked(False)
pump()
assert not ax.get_lines()[5].get_visible()
assert len(ax.get_legend().get_texts()) == 8
assert ylim_span(ax) < before / 10, "y-range was not refit after hiding the big curve"
big.setChecked(True)
pump()
assert len(ax.get_legend().get_texts()) == 9
# 4. a manual zoom switches y-autoscale off; hiding a curve must still refit
ax.set_ylim(-1, 1)
assert not ax.get_autoscaley_on()
boxes[0].setChecked(False)
pump()
assert ylim_span(ax) > 2.5, "y-range stayed frozen after a manual zoom"
boxes[0].setChecked(True)
pump()
# 5. filter + All/None act on the rows the filter shows
edit.setText("readback")
pump()
shown = [b for b in boxes if not b.isHidden()]
assert [b.text() for b in shown] == [b.text() for b in boxes if "readback" in b.text()] and len(shown) == 3
buttons["None"].click()
pump()
vis = [ln.get_visible() for ln in ax.get_lines()]
assert [v for v, b in zip(vis, boxes) if "readback" in b.text()] == [False] * 3
assert all(v for v, b in zip(vis, boxes) if "readback" not in b.text()), "None touched filtered-out rows"
buttons["All"].click()
pump()
assert all(ln.get_visible() for ln in ax.get_lines())
edit.setText("")
pump()
assert all(not b.isHidden() for b in boxes)
# 6. None without a filter empties the plot (and drops the legend); All restores it
buttons["None"].click()
pump()
assert not any(ln.get_visible() for ln in ax.get_lines()) and ax.get_legend() is None
buttons["All"].click()
pump()
assert len(ax.get_legend().get_texts()) == 9
# "only the readbacks": None, then filter + All
buttons["None"].click()
edit.setText("readback")
buttons["All"].click()
edit.setText("")
pump()
assert [t.get_text() for t in ax.get_legend().get_texts()] == [
ln.get_label() for ln in ax.get_lines() if "readback" in ln.get_label()
]
buttons["All"].click()
pump()
# 7. clicking the button again reuses the same dialog
dialog.close()
pump()
select_action(fig).trigger()
pump()
assert get_dialog(fig) is dialog and dialog.isVisible()
# 8. closing the figure takes the dialog with it
plt.close(fig)
pump()
try:
assert not dialog.isVisible()
except RuntimeError:
pass # C++ object already deleted: also fine
# 9. a figure without a legend keeps none (the prototype only has legend-ful plots)
fig, ax = make_figure(legend=False)
attach(fig)
dialog = open_dialog(fig)
boxes, edit, buttons = widgets_of(dialog)
boxes[0].setChecked(False)
pump()
assert ax.get_legend() is None, "a legend must not appear where there was none"
plt.close(fig)
# 9b. an existing legend keeps its position, title and column count
fig, ax = make_figure()
ax.legend(loc="lower left", title="stages", ncol=2)
loc = ax.get_legend()._loc # private, but the only handle on where it sits
attach(fig)
dialog = open_dialog(fig)
boxes, edit, buttons = widgets_of(dialog)
boxes[0].setChecked(False)
pump()
legend = ax.get_legend()
assert legend._loc == loc and legend.get_title().get_text() == "stages"
assert len(legend.get_texts()) == 8
plt.close(fig)
# 9c. other labelled artists (a band) keep their legend entry; no exception with collections
fig, ax = make_figure()
ax.fill_between(np.arange(200.0), -1, 1, alpha=0.2, label="band")
ax.legend()
assert len(ax.get_legend().get_texts()) == 10
attach(fig)
dialog = open_dialog(fig)
boxes, edit, buttons = widgets_of(dialog)
assert len(boxes) == 9, "only the lines are selectable"
boxes[0].setChecked(False)
pump()
assert len(ax.get_legend().get_texts()) == 9 and "band" in [t.get_text() for t in ax.get_legend().get_texts()]
plt.close(fig)
# 10. one curve: nothing to select, so no button
fig, ax = make_figure(n_lines=1)
attach(fig)
assert select_action(fig) is None
plt.close(fig)
# 11. icon follows the toolbar's own colour (dark themes)
fig, ax = make_figure()
tb = toolbar_of(fig)
palette = QtGui.QPalette()
palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(240, 240, 240))
palette.setColor(QtGui.QPalette.Button, QtGui.QColor(40, 40, 40))
tb.setPalette(palette)
attach(fig)
img = select_action(fig).icon().pixmap(24, 24).toImage()
opaque = [img.pixelColor(x, y) for x in range(24) for y in range(24) if img.pixelColor(x, y).alpha() > 200]
assert opaque and all(c.red() > 200 for c in opaque), "icon is not drawn in the toolbar's text colour"
plt.close(fig)
print("all checks passed")
sys.exit(0)
```