Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/gleplot/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ def __init__(self, figure, position: Tuple[int, int, int] = None):
def plot(self, x, y, linestyle: str = '-', color: Optional[str] = None,
marker: Optional[str] = None, markersize: float = 6,
linewidth: float = 1, label: Optional[str] = None,
yaxis: str = 'y', **kwargs):
yaxis: str = 'y', offset: float = 0.0, **kwargs):
"""
Plot line or scatter plot (if marker without line).

Expand Down Expand Up @@ -410,10 +410,11 @@ def plot(self, x, y, linestyle: str = '-', color: Optional[str] = None,
'linewidth': linewidth,
'label': label,
'yaxis': yaxis, # 'y' or 'y2'
'offset': float(offset),
'data_file': _resolve_data_file(self.figure, data_name),
'column_names': _build_column_names('x', ['y'], label),
}

if is_scatter:
self.scatters.append(line_data)
else:
Expand All @@ -426,7 +427,7 @@ def errorbar(self, x, y, yerr=None, xerr=None, fmt: str = '-',
markersize: float = 6, linewidth: float = 1,
label: Optional[str] = None, capsize: Optional[float] = None,
capsize_cm: Optional[float] = None,
yaxis: str = 'y',
yaxis: str = 'y', offset: float = 0.0,
**kwargs):
"""
Plot data with error bars.
Expand Down Expand Up @@ -601,6 +602,7 @@ def errorbar(self, x, y, yerr=None, xerr=None, fmt: str = '-',
'capsize': stored_capsize,
'gle_capsize': gle_capsize, # Separate field for the GLE-converted value
'yaxis': yaxis, # 'y' or 'y2'
'offset': float(offset),
'data_file': _resolve_data_file(self.figure, data_name),
'column_names': _build_errorbar_column_names(
label, yerr_up, yerr_down, xerr_left, xerr_right
Expand Down Expand Up @@ -795,7 +797,8 @@ def bar(self, x, height, color: Optional[Union[str, List[str]]] = None,
return self

def fill_between(self, x, y1, y2, color: Optional[str] = None,
alpha: float = 0.3, label: Optional[str] = None, **kwargs):
alpha: float = 0.3, label: Optional[str] = None,
offset: float = 0.0, **kwargs):
"""
Fill area between two curves.

Expand Down Expand Up @@ -836,6 +839,7 @@ def fill_between(self, x, y1, y2, color: Optional[str] = None,
'color': color,
'alpha': alpha,
'label': label,
'offset': float(offset),
'data_file': _resolve_data_file(self.figure, data_name),
'column_names': _unique_column_names(['x', 'upper', 'lower']),
}
Expand Down
18 changes: 13 additions & 5 deletions src/gleplot/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes):
fill_data['data_file'],
fill_data['color'],
fill_data['alpha'],
offset=fill_data.get('offset', 0.0),
column_names=fill_data.get('column_names'),
)

Expand Down Expand Up @@ -752,6 +753,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes):
marker=line_data.get('marker'),
markersize=line_data.get('markersize', 0.1),
yaxis=line_data.get('yaxis', 'y'),
offset=line_data.get('offset', 0.0),
column_names=line_data.get('column_names'),
)

Expand All @@ -767,6 +769,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes):
markersize=scatter_data['markersize'],
label=scatter_data['label'],
yaxis=scatter_data.get('yaxis', 'y'),
offset=scatter_data.get('offset', 0.0),
column_names=scatter_data.get('column_names'),
)

Expand All @@ -788,6 +791,7 @@ def _write_axes_content(self, writer: GLEWriter, ax: Axes):
xerr_right=eb_data['xerr_right'],
capsize=eb_data.get('gle_capsize', eb_data.get('capsize')),
yaxis=eb_data.get('yaxis', 'y'),
offset=eb_data.get('offset', 0.0),
column_names=eb_data.get('column_names'),
)

Expand Down Expand Up @@ -926,9 +930,12 @@ def _get_data_ylim(self, ax: Axes) -> Tuple[Optional[float], Optional[float]]:
"""Calculate y-axis limits from data."""
ymin, ymax = None, None

# A series' ``offset`` shifts its trace vertically at plot time (the .dat
# values stay raw), so autoscale must add it back when bounding the data
# -- otherwise a waterfall stack falls off the auto-computed axis.
for data_list in [ax.lines, ax.scatters]:
for data in data_list:
y = np.asarray(data['y'])
y = np.asarray(data['y']) + data.get('offset', 0.0)
if len(y) > 0:
if ymin is None or y.min() < ymin:
ymin = float(y.min())
Expand All @@ -944,8 +951,9 @@ def _get_data_ylim(self, ax: Axes) -> Tuple[Optional[float], Optional[float]]:
ymax = float(height.max())

for fill_data in ax.fills:
y1 = np.asarray(fill_data['y1'])
y2 = np.asarray(fill_data['y2'])
off = fill_data.get('offset', 0.0)
y1 = np.asarray(fill_data['y1']) + off
y2 = np.asarray(fill_data['y2']) + off
all_y = np.concatenate([y1, y2])
if len(all_y) > 0:
if ymin is None or all_y.min() < ymin:
Expand All @@ -954,10 +962,10 @@ def _get_data_ylim(self, ax: Axes) -> Tuple[Optional[float], Optional[float]]:
ymax = float(all_y.max())

for eb_data in ax.errorbars:
y = np.asarray(eb_data['y'])
y = np.asarray(eb_data['y']) + eb_data.get('offset', 0.0)
yerr_up = eb_data.get('yerr_up')
yerr_down = eb_data.get('yerr_down')

if len(y) > 0:
y_with_err = y.copy()
if yerr_up is not None:
Expand Down
57 changes: 43 additions & 14 deletions src/gleplot/gui/panels/series_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,22 @@
This panel shows the user a matplotlib-scale number and inverts that
formula on read/write so edits round-trip through the same conversion.
- ``label``: stored verbatim, ``None`` allowed.
- ``offset``: vertical shift applied at plot time (waterfall/overlay). Stored
verbatim as a float; the writer emits ``let dK = dJ+offset`` so the .dat
file keeps its raw values (see ``writer.GLEWriter._apply_offset``).

Series kinds and their applicable controls:

=========== ===== ======= ========== ========= =====
kind color marker linestyle linewidth label
=========== ===== ======= ========== ========= =====
line yes yes yes yes yes
scatter yes yes no no yes
errorbar yes yes yes yes yes
bar yes* no no no yes
fill yes no no no yes
file_series yes depends depends depends yes
=========== ===== ======= ========== ========= =====
=========== ===== ======= ========== ========= ===== ======
kind color marker linestyle linewidth label offset
=========== ===== ======= ========== ========= ===== ======
line yes yes yes yes yes yes
scatter yes yes no no yes yes
errorbar yes yes yes yes yes yes
bar yes* no no no yes no
fill yes no no no yes yes
file_series yes depends depends depends yes no
=========== ===== ======= ========== ========= ===== ======

``bar`` stores a per-point ``colors`` list rather than a single ``color``
key (GLE only supports one color per bar chart in practice, so all entries
Expand Down Expand Up @@ -271,12 +274,24 @@ def _build_ui(self) -> None:
self.markersize_spin.setSingleStep(0.5)
self.markersize_spin.setDecimals(2)

# Vertical offset (waterfall/overlay). A non-zero value shifts the trace
# in the GLE script (via 'let dK = dJ+off'); the .dat file stays raw.
self.offset_spin = QDoubleSpinBox(self)
self.offset_spin.setRange(-1.0e9, 1.0e9)
self.offset_spin.setSingleStep(0.1)
self.offset_spin.setDecimals(6)
self.offset_spin.setToolTip(
"Vertical offset applied at plot time (waterfall/overlay stacking). "
"The data file keeps its raw values."
)

form.addRow("Label", self.label_edit)
form.addRow("Color", color_row)
form.addRow("Line style", self.linestyle_combo)
form.addRow("Marker", self.marker_combo)
form.addRow("Line width", self.linewidth_spin)
form.addRow("Marker size", self.markersize_spin)
form.addRow("Offset", self.offset_spin)

outer.addLayout(form)

Expand All @@ -302,6 +317,7 @@ def _connect_signals(self) -> None:
self.marker_combo.currentTextChanged.connect(self._on_marker_changed)
self.linewidth_spin.editingFinished.connect(self._on_linewidth_edited)
self.markersize_spin.editingFinished.connect(self._on_markersize_edited)
self.offset_spin.editingFinished.connect(self._on_offset_edited)
self.locate_button.clicked.connect(self._on_locate_clicked)

def _on_figure_replaced(self) -> None:
Expand Down Expand Up @@ -418,6 +434,9 @@ def _populate_style_editor(self, kind: Optional[str], series: Optional[dict] = N
scale = self._msize_scale()
mpl_size = gle_size / (_MSIZE_PER_MPL_UNIT * scale) if scale else 0.0
self.markersize_spin.setValue(float(mpl_size))

if applicable.get("offset"):
self.offset_spin.setValue(float(series.get("offset") or 0.0))
finally:
self._updating = was_updating

Expand Down Expand Up @@ -450,6 +469,7 @@ def _set_style_controls_enabled(self, enabled: bool, applicable: Optional[dict]
self.marker_combo.setEnabled(enabled and applicable.get("marker", False))
self.linewidth_spin.setEnabled(enabled and applicable.get("linewidth", False))
self.markersize_spin.setEnabled(enabled and applicable.get("markersize", False))
self.offset_spin.setEnabled(enabled and applicable.get("offset", False))
self.remove_button.setEnabled(enabled)
self.up_button.setEnabled(enabled)
self.down_button.setEnabled(enabled)
Expand Down Expand Up @@ -614,6 +634,15 @@ def _on_markersize_edited(self) -> None:
series["markersize"] = mpl_value * _MSIZE_PER_MPL_UNIT * scale
self._document.notify_changed()

def _on_offset_edited(self) -> None:
if self._updating:
return
series = self._selected_series_dict()
if series is None or not self.offset_spin.isEnabled():
return
series["offset"] = float(self.offset_spin.value())
self._document.notify_changed()

def _on_locate_clicked(self) -> None:
"""Repoint a broken ``file_series`` entry at a real file on disk.

Expand Down Expand Up @@ -690,19 +719,19 @@ def _applicable_controls(kind: str, series: dict) -> dict:
# a marker alongside a solid/dashed line), so a line series may carry
# both a line style and a marker with a marker size.
return {"color": True, "marker": True, "linestyle": True,
"linewidth": True, "markersize": True}
"linewidth": True, "markersize": True, "offset": True}
if kind == "scatter":
return {"color": True, "marker": True, "linestyle": False,
"linewidth": False, "markersize": True}
"linewidth": False, "markersize": True, "offset": True}
if kind == "errorbar":
return {"color": True, "marker": True, "linestyle": True,
"linewidth": True, "markersize": True}
"linewidth": True, "markersize": True, "offset": True}
if kind == "bar":
return {"color": True, "marker": False, "linestyle": False,
"linewidth": False, "markersize": False}
if kind == "fill":
return {"color": True, "marker": False, "linestyle": False,
"linewidth": False, "markersize": False}
"linewidth": False, "markersize": False, "offset": True}
if kind == "file_series":
if series.get("data_error"):
# Broken reference: the file couldn't be read, so there is no
Expand Down
69 changes: 69 additions & 0 deletions src/gleplot/parser/recognizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,15 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic
# this set lets pass 2 recognize and skip it as a suppression
# marker instead of fabricating a phantom series.
"_key_suppress_datasets": set(),
# dataset name (e.g. 'd10') -> vertical offset, for datasets the
# writer defines via 'let dK = dJ+off' to stack a waterfall/overlay
# trace at plot time. The recovered series carries the RAW file
# values plus this offset as an editable property (the offset never
# touches the .dat file). See _parse_let_command.
"_dataset_offsets": {},
# Names of 'let' targets recognized as offset aliases, so pass 2
# consumes those 'let' lines instead of preserving them as raw GLE.
"_offset_let_datasets": set(),
}

# Local dataset map for THIS block (dataset refs are graph-local).
Expand All @@ -634,6 +643,12 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic
if kw == "data":
self._parse_data_command(_words_and_values(child), datasets)
continue
if kw == "let":
# 'let dK = dJ+off' -- register dK as an offset alias of dJ
# (dJ's 'data' command precedes it in body order, so dJ is
# already known here in pass 1).
self._parse_let_command(_words_and_values(child), datasets, info)
continue
if kw is not None and _DATASET_RE.match(kw):
name = kw
if name not in merged_attr_toks:
Expand Down Expand Up @@ -667,6 +682,14 @@ def _parse_graph_block(self, block: GraphBlock, marker_cfg, smooth_flags) -> dic
kw = child.keyword
if kw == "data":
continue # already handled in pass 1
if kw == "let":
# Recognized offset alias -> consumed (the offset now lives on
# the aliased series). An unrecognized 'let' falls through to
# generic dispatch, which preserves it as raw GLE.
toks = _words_and_values(child)
target = toks[1].value.lower() if len(toks) > 1 else None
if target in info["_offset_let_datasets"]:
continue
if kw is not None and _DATASET_RE.match(kw):
name = kw
if name in emitted:
Expand Down Expand Up @@ -988,6 +1011,45 @@ def _register_dataset(self, name, data_file, xcol, ycol, datasets):
datasets[name] = (data_file, xcol, ycol)
self._datasets[name] = (data_file, xcol, ycol)

def _parse_let_command(self, toks, datasets, info):
"""``let dK = dJ+off`` / ``let dK = dJ-off`` -> register dK as an
offset alias of dJ.

Recognizes only the exact vertical-shift shape gleplot's writer emits
(see :meth:`gleplot.writer.GLEWriter._apply_offset`): a target dataset,
``=``, a source dataset already registered by a ``data`` command, a
single ``+``/``-``, and a numeric value. dK is registered pointing at
dJ's file and columns, and the signed offset is recorded in
``info["_dataset_offsets"]`` so the recovered series carries the raw
file values plus an editable offset. Anything richer (a general GLE
``let`` expression) is left unregistered and preserved as raw GLE.

Tokens (keyword included): ``let dK = dJ <op> <number...>``.
"""
# let dK = dJ <op> number -> at least 6 tokens.
if len(toks) < 6:
return
target = toks[1].value.lower()
eq = toks[2]
source = toks[3].value.lower()
op = toks[4].value
if not _DATASET_RE.match(target) or not _DATASET_RE.match(source):
return
if eq.type is not TokenType.OP or eq.value != "=":
return
if op not in ("+", "-"):
return
if source not in datasets:
return
magnitude = eval_gle_number(toks[5:])
if magnitude is None:
return
offset = -magnitude if op == "-" else magnitude
data_file, xcol, ycol = datasets[source]
self._register_dataset(target, data_file, xcol, ycol, datasets)
info["_dataset_offsets"][target] = float(offset)
info["_offset_let_datasets"].add(target)

#: OP token values that may appear glued mid-filename (hyphenated names,
#: relative paths, and -- defensively -- a literal '+') when assembling
#: an unquoted filename from contiguous tokens. See ``_read_filename``.
Expand Down Expand Up @@ -1134,6 +1196,7 @@ def _parse_fill_command(self, toks, datasets, info):
fill_entry = {
"x": x, "y1": y1, "y2": y2,
"color": color, "alpha": 0.3, "label": None,
"offset": info["_dataset_offsets"].get(d_names[0], 0.0),
"data_file": f1,
}
column_names = self._recovered_column_names(f1, [xc1, yc1, yc2])
Expand Down Expand Up @@ -1230,6 +1293,7 @@ def _parse_series_command(self, toks, datasets, info, marker_cfg, smooth_flags):
"linewidth": linewidth,
"label": attrs["label"],
"yaxis": "y2" if attrs["y2axis"] else "y",
"offset": info["_dataset_offsets"].get(d_name, 0.0),
"data_file": data_file,
}
column_names = self._recovered_column_names(data_file, [xcol, ycol])
Expand Down Expand Up @@ -1517,6 +1581,11 @@ def const_arr(value, is_percent, horizontal):
"capsize": stored_capsize,
"gle_capsize": gle_capsize,
"yaxis": "y2" if attrs["y2axis"] else "y",
"offset": (
info["_dataset_offsets"].get(orig_toks[0].value.lower(), 0.0)
if orig_toks
else 0.0
),
"data_file": data_file,
}
if not err_consts:
Expand Down
Loading
Loading