fixes
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+51
-7
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
```
|
||||
Reference in New Issue
Block a user