diff --git a/src/gleplot/axes.py b/src/gleplot/axes.py index 2836c69..8823f57 100644 --- a/src/gleplot/axes.py +++ b/src/gleplot/axes.py @@ -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). @@ -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: @@ -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. @@ -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 @@ -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. @@ -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']), } diff --git a/src/gleplot/figure.py b/src/gleplot/figure.py index 143414c..589b6ea 100644 --- a/src/gleplot/figure.py +++ b/src/gleplot/figure.py @@ -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'), ) @@ -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'), ) @@ -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'), ) @@ -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'), ) @@ -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()) @@ -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: @@ -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: diff --git a/src/gleplot/gui/panels/series_panel.py b/src/gleplot/gui/panels/series_panel.py index 723433d..7da8c63 100644 --- a/src/gleplot/gui/panels/series_panel.py +++ b/src/gleplot/gui/panels/series_panel.py @@ -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 @@ -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) @@ -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: @@ -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 @@ -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) @@ -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. @@ -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 diff --git a/src/gleplot/parser/recognizer.py b/src/gleplot/parser/recognizer.py index 092f40f..2e04177 100644 --- a/src/gleplot/parser/recognizer.py +++ b/src/gleplot/parser/recognizer.py @@ -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). @@ -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: @@ -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: @@ -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 ``. + """ + # let dK = dJ 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``. @@ -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]) @@ -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]) @@ -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: diff --git a/src/gleplot/writer.py b/src/gleplot/writer.py index f455506..cfe155f 100644 --- a/src/gleplot/writer.py +++ b/src/gleplot/writer.py @@ -356,12 +356,35 @@ def add_data_file(self, filename: str, columns: List[np.ndarray], # Add trailing newline for GLE compatibility self.data_files[filename] = '\n'.join(lines) + '\n' - + + def _apply_offset(self, source_name: str, offset: float) -> str: + """Emit a ``let`` command that shifts *source_name* vertically by *offset*. + + Returns the name of a fresh dataset holding ``source_name + offset`` so + the caller can display the shifted trace while the on-disk ``.dat`` file + keeps its raw values. The offset therefore lives entirely in the GLE + script (a waterfall/overlay stack applied at plot time), never baked + into the data. Error datasets are unaffected -- they carry magnitudes, + so the caller keeps its ``err`` references on the raw error datasets and + they ride along with the shifted centre. + + GLE's ``let`` parser is whitespace-sensitive around arithmetic on a + dataset reference: ``let d2 = d1 + 5`` fails ("unknown token '+'") but + ``let d2 = d1+5`` works, so the operator is glued to its operands. A + negative offset is emitted as ``d1-5`` (not ``d1+-5``). + """ + shifted = f'd{self.dataset_index}' + self.dataset_index += 1 + mag = self._format_number(abs(offset)) + sign = '-' if offset < 0 else '+' + self.lines_gle.append(f' let {shifted} = {source_name}{sign}{mag}') + return shifted + def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, color: str = 'BLUE', linestyle: str = '-', linewidth: float = 1.0, label: Optional[str] = None, marker: Optional[str] = None, markersize: float = 0.1, - yaxis: str = 'y', + yaxis: str = 'y', offset: float = 0.0, column_names: Optional[List[str]] = None): """ Add line plot to graph. @@ -409,9 +432,13 @@ def add_plot_line(self, x: np.ndarray, y: np.ndarray, data_file: str, cmd = f' data {_format_data_filename(data_file)} {d_name}=c1,c2' self.lines_gle.append(cmd) - + + # A non-zero offset shifts the trace vertically at plot time via a + # ``let`` on a fresh dataset, leaving the .dat file's values raw. + display_name = self._apply_offset(d_name, offset) if offset else d_name + # Generate line command - line_cmd = f' {d_name}' + line_cmd = f' {display_name}' # Convert matplotlib linewidth (points) to GLE lwidth (cm) # If linewidth is 0 or 1, use default from style config @@ -524,7 +551,7 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, xerr_left: Optional[np.ndarray] = None, xerr_right: Optional[np.ndarray] = None, capsize: Optional[float] = None, - yaxis: str = 'y', + yaxis: str = 'y', offset: float = 0.0, column_names: Optional[List[str]] = None): """ Add plot with error bars to graph. @@ -690,9 +717,15 @@ def add_errorbar(self, x: np.ndarray, y: np.ndarray, data_file: str, err_datasets['xerr_right'] = d_xerr_right self.lines_gle.append(data_cmd) - + + # A non-zero offset shifts the plotted centre vertically at plot time + # via a ``let`` on a fresh dataset; the raw .dat values are untouched + # and the error datasets (magnitudes) stay bound to the raw columns, so + # the bars ride along with the shifted centre. + display_name = self._apply_offset(d_main, offset) if offset else d_main + # Build the main dataset display command - line_cmd = f' {d_main}' + line_cmd = f' {display_name}' # Convert linewidth if linewidth == 0 or linewidth == 1: @@ -842,6 +875,7 @@ def add_plot_line_from_file( def add_fill_between(self, x: np.ndarray, y1: np.ndarray, y2: np.ndarray, data_file: str, color: str = 'LIGHTBLUE', alpha: float = 1.0, + offset: float = 0.0, column_names: Optional[List[str]] = None): """ Add fill between two curves. @@ -877,12 +911,21 @@ def add_fill_between(self, x: np.ndarray, y1: np.ndarray, y2: np.ndarray, cmd = f' data {_format_data_filename(data_file)} {d1_name}=c1,c2 {d2_name}=c1,c3' self.lines_gle.append(cmd) + # A non-zero offset shifts BOTH band edges vertically by the same amount + # at plot time (a waterfall band rides with its trace), leaving the .dat + # file raw. The displayed/keyed datasets are then the shifted ones. + if offset: + fill_a = self._apply_offset(d1_name, offset) + fill_b = self._apply_offset(d2_name, offset) + else: + fill_a, fill_b = d1_name, d2_name + # GLE fill between two datasets: fill d1,d2 color X - self.lines_gle.append(f' fill {d1_name},{d2_name} color {color}') + self.lines_gle.append(f' fill {fill_a},{fill_b} color {color}') if column_names: - self.lines_gle.append(f' {d1_name} key ""') - self.lines_gle.append(f' {d2_name} key ""') + self.lines_gle.append(f' {fill_a} key ""') + self.lines_gle.append(f' {fill_b} key ""') def add_text( self, diff --git a/tests/gui/test_panels.py b/tests/gui/test_panels.py index 7401c3e..ccf1d8c 100644 --- a/tests/gui/test_panels.py +++ b/tests/gui/test_panels.py @@ -315,6 +315,24 @@ def test_write_back_markersize_uses_gle_scale(self, document): scale = document.figure.marker_config.msize_scale assert ax.scatters[0]["markersize"] == pytest.approx(10.0 * 0.025 * scale) + def test_offset_field_enabled_and_populated_for_line(self, document): + ax = document.figure.gca() + ax.lines[0]["offset"] = 3.5 + panel = SeriesPanel(document) + panel.series_list.setCurrentRow(0) + assert panel.offset_spin.isEnabled() is True + assert panel.offset_spin.value() == pytest.approx(3.5) + + def test_write_back_offset(self, document): + panel = SeriesPanel(document) + panel.series_list.setCurrentRow(0) + before = document.notify_count + panel.offset_spin.setValue(-6.0) + panel.offset_spin.editingFinished.emit() + ax = document.figure.gca() + assert ax.lines[0]["offset"] == pytest.approx(-6.0) + assert document.notify_count == before + 1 + def test_write_back_label_refreshes_list_text(self, document): panel = SeriesPanel(document) panel.series_list.setCurrentRow(0) diff --git a/tests/parser/test_series_offset.py b/tests/parser/test_series_offset.py new file mode 100644 index 0000000..f778a58 --- /dev/null +++ b/tests/parser/test_series_offset.py @@ -0,0 +1,135 @@ +"""Recognizer round-trip for series vertical offsets (waterfall/overlay). + +The writer emits a non-zero offset as ``let dK = dJ+offset`` and displays the +shifted dataset ``dK`` (see ``tests/unit/test_offset.py``). The recognizer must +map ``dK`` back to ``dJ``'s data file/columns and recover the offset as an +editable series property, so an offset figure survives Open -> edit -> Save +without losing the trace (the pre-feature behaviour: the ``let`` line was +unrecognized and the series vanished). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import gleplot as glp +from gleplot import axes as _gleplot_axes +from gleplot.parser.recognizer import parse_gle_figure + + +@pytest.fixture(autouse=True) +def _fresh(): + _gleplot_axes._global_data_file_counter = 0 + glp.close() + yield + _gleplot_axes._global_data_file_counter = 0 + glp.close() + + +def _save(fig, tmp_path, name="f.gle"): + p = tmp_path / name + fig.savefig_gle(str(p)) + return p + + +def _only(seq): + assert len(seq) == 1, f"expected exactly one, got {len(seq)}" + return seq[0] + + +def test_line_offset_round_trips(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0.0, 1.0, 2.0], [1.0, 2.0, 3.0], offset=5.0, data_name="wave") + gle = _save(fig, tmp_path) + + rec = parse_gle_figure(gle) + assert rec.warnings == [] + line = _only(rec.figure.axes_list[0].lines) + assert line["offset"] == 5.0 + # Recovered y is the RAW column, not the shifted display values. + np.testing.assert_allclose(line["y"], [1.0, 2.0, 3.0]) + + +def test_errorbar_offset_round_trips_with_raw_errors(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.errorbar( + [0.0, 1.0, 2.0], [1.0, 2.0, 3.0], yerr=[0.1, 0.2, 0.3], + fmt="none", marker="o", offset=-4.0, data_name="eb", + ) + gle = _save(fig, tmp_path) + + rec = parse_gle_figure(gle) + assert rec.warnings == [] + eb = _only(rec.figure.axes_list[0].errorbars) + assert eb["offset"] == -4.0 + np.testing.assert_allclose(eb["y"], [1.0, 2.0, 3.0]) + np.testing.assert_allclose(eb["yerr_up"], [0.1, 0.2, 0.3]) + + +def test_fill_offset_round_trips(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.fill_between([0.0, 1.0], [0.0, 0.0], [1.0, 1.0], offset=3.0, data_name="band") + gle = _save(fig, tmp_path) + + rec = parse_gle_figure(gle) + assert rec.warnings == [] + fill = _only(rec.figure.axes_list[0].fills) + assert fill["offset"] == 3.0 + np.testing.assert_allclose(fill["y1"], [0.0, 0.0]) + np.testing.assert_allclose(fill["y2"], [1.0, 1.0]) + + +def test_zero_offset_absent_let_recovers_zero(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [1, 2, 3], data_name="plain") + gle = _save(fig, tmp_path) + assert "let " not in gle.read_text(encoding="utf-8") + + rec = parse_gle_figure(gle) + assert _only(rec.figure.axes_list[0].lines)["offset"] == 0.0 + + +def test_offset_is_idempotent_on_resave(tmp_path): + """Open -> Save must preserve the offset and the raw data.""" + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.errorbar([0.0, 1.0, 2.0], [1.0, 2.0, 3.0], yerr=[0.1, 0.1, 0.1], + fmt="none", marker="o", offset=7.0, data_name="eb") + first = _save(fig, tmp_path, "first.gle") + + rec = parse_gle_figure(first) + second = tmp_path / "second.gle" + rec.figure.savefig_gle(str(second)) + + rec2 = parse_gle_figure(second) + eb = _only(rec2.figure.axes_list[0].errorbars) + assert eb["offset"] == 7.0 + np.testing.assert_allclose(eb["y"], [1.0, 2.0, 3.0]) + # Every emitted script must define the shifted dataset via a 'let'. + assert "let " in second.read_text(encoding="utf-8") + + +def test_unrecognized_let_preserved_as_raw_gle(tmp_path): + """A general 'let' (not the offset shape) is kept verbatim, not dropped.""" + src = ( + "size 20 15\n" + "begin graph\n" + " data wave.dat d1=c1,c2\n" + " let d2 = 3*x^2\n" + " d1 line color black\n" + "end graph\n" + ) + (tmp_path / "wave.dat").write_text("0 1\n1 2\n2 3\n", encoding="utf-8") + p = tmp_path / "g.gle" + p.write_text(src, encoding="utf-8") + + rec = parse_gle_figure(p) + ax = rec.figure.axes_list[0] + # The offset-shape recognizer must not have claimed this let; it survives + # as passthrough so a re-save does not silently delete it. + assert any("let d2 = 3*x^2" in line for line in ax.passthrough) diff --git a/tests/unit/test_offset.py b/tests/unit/test_offset.py new file mode 100644 index 0000000..8fc5318 --- /dev/null +++ b/tests/unit/test_offset.py @@ -0,0 +1,133 @@ +"""Series vertical-offset (waterfall/overlay) emission and autoscale. + +A non-zero ``offset`` on a plot/errorbar/fill series shifts the trace +vertically *at plot time* -- the writer emits ``let dK = dJ+offset`` and +displays ``dK`` -- so the generated ``.dat`` file keeps its raw values. These +tests pin that the offset never reaches the data file, that the ``let`` is +shaped the way GLE requires (operator glued to its operands, ``-`` for a +negative offset), and that autoscale bounds the *shifted* trace so a stack +never falls off the auto-computed axis. The recognizer round-trip lives in +``tests/parser/test_series_offset.py``. +""" + +from __future__ import annotations + +import pytest + +import gleplot as glp + + +@pytest.fixture(autouse=True) +def _fresh(): + glp.close() + yield + glp.close() + + +def _script_and_data(fig, tmp_path): + gle_path = tmp_path / "f.gle" + fig.savefig_gle(str(gle_path)) + script = gle_path.read_text(encoding="utf-8") + dats = {p.name: p.read_text(encoding="utf-8") for p in tmp_path.glob("*.dat")} + return script, dats + + +def _numeric_rows(dat_text: str) -> list[list[float]]: + """Return the numeric data rows of a .dat file, skipping the column-name + header row and any ``!`` comment lines.""" + rows = [] + for ln in dat_text.splitlines(): + parts = ln.split() + if not parts or ln.startswith("!"): + continue + try: + rows.append([float(p) for p in parts]) + except ValueError: + continue # header row (column names) + return rows + + +def test_offset_stored_on_series_dict(): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [1, 2, 3], offset=5.0) + ax.errorbar([0, 1], [1, 2], yerr=[0.1, 0.1], offset=-2.5) + ax.fill_between([0, 1], [0, 0], [1, 1], offset=3.0) + assert ax.lines[0]["offset"] == 5.0 + assert ax.errorbars[0]["offset"] == -2.5 + assert ax.fills[0]["offset"] == 3.0 + + +def test_default_offset_is_zero_and_emits_no_let(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [1, 2, 3], data_name="plain") + assert ax.lines[0]["offset"] == 0.0 + script, _ = _script_and_data(fig, tmp_path) + assert "let " not in script + + +def test_line_offset_emits_let_and_keeps_data_raw(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0.0, 1.0, 2.0], [1.0, 2.0, 3.0], offset=5.0, data_name="wave") + script, dats = _script_and_data(fig, tmp_path) + + # A 'let dK = dJ+5' shifts a fresh dataset; the display command references + # the shifted dataset, not the raw one. + assert "let " in script + assert "+5" in script.replace(" ", "") + # The .dat file holds the RAW y values (1, 2, 3), never 6, 7, 8. + ys = [row[1] for row in _numeric_rows(dats["wave.dat"])] + assert ys == [1.0, 2.0, 3.0] + + +def test_negative_offset_emits_minus(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0, 1], [1, 2], offset=-2.0, data_name="neg") + script, _ = _script_and_data(fig, tmp_path) + glued = script.replace(" ", "") + assert "-2" in glued + # Never the ill-formed 'd?+-2' that GLE rejects. + assert "+-2" not in glued + + +def test_errorbar_offset_keeps_error_dataset_on_raw_column(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.errorbar( + [0, 1, 2], [1, 2, 3], yerr=[0.1, 0.1, 0.1], + fmt="none", marker="o", offset=5.0, data_name="eb", + ) + script, dats = _script_and_data(fig, tmp_path) + # Error magnitudes ride along with the shifted centre: the 'err dN' clause + # references a dataset bound to the raw error column, so the .dat error + # column is untouched. + errs = [row[2] for row in _numeric_rows(dats["eb.dat"])] + assert errs == [0.1, 0.1, 0.1] + assert " err d" in script + + +def test_fill_offset_shifts_both_edges(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.fill_between([0, 1, 2], [0, 0, 0], [1, 1, 1], offset=4.0, data_name="band") + script, dats = _script_and_data(fig, tmp_path) + # Two 'let' datasets (one per band edge), both shifted by +4. + assert script.replace(" ", "").count("+4") == 2 + rows = _numeric_rows(dats["band.dat"]) + # Raw edges are still 0 and 1, not 4 and 5. + assert [r[1] for r in rows] == [0.0, 0.0, 0.0] + assert [r[2] for r in rows] == [1.0, 1.0, 1.0] + + +def test_autoscale_bounds_the_shifted_trace(tmp_path): + fig = glp.figure(figsize=(8, 6)) + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [0.0, 1.0, 2.0], offset=10.0, data_name="hi") + script, _ = _script_and_data(fig, tmp_path) + # The autoscaled y-max must clear the shifted top (12), not the raw top (2). + ymax_line = next(ln for ln in script.splitlines() if "yaxis" in ln and "max" in ln) + ymax = float(ymax_line.split("max")[1].split()[0]) + assert ymax >= 12.0