bernardyn 0.0.1b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. bernardyn-0.0.1b1/LICENSE +21 -0
  2. bernardyn-0.0.1b1/PKG-INFO +129 -0
  3. bernardyn-0.0.1b1/README.md +90 -0
  4. bernardyn-0.0.1b1/bernardyn/__init__.py +23 -0
  5. bernardyn-0.0.1b1/bernardyn/app.py +31 -0
  6. bernardyn-0.0.1b1/bernardyn/core/__init__.py +6 -0
  7. bernardyn-0.0.1b1/bernardyn/core/controller.py +319 -0
  8. bernardyn-0.0.1b1/bernardyn/core/models.py +436 -0
  9. bernardyn-0.0.1b1/bernardyn/core/registry.py +57 -0
  10. bernardyn-0.0.1b1/bernardyn/core/transforms.py +309 -0
  11. bernardyn-0.0.1b1/bernardyn/data/__init__.py +5 -0
  12. bernardyn-0.0.1b1/bernardyn/diagnostics.py +96 -0
  13. bernardyn-0.0.1b1/bernardyn/export/__init__.py +6 -0
  14. bernardyn-0.0.1b1/bernardyn/gui/__init__.py +5 -0
  15. bernardyn-0.0.1b1/bernardyn/gui/dialogs.py +620 -0
  16. bernardyn-0.0.1b1/bernardyn/gui/graph_page.py +126 -0
  17. bernardyn-0.0.1b1/bernardyn/gui/inspector.py +921 -0
  18. bernardyn-0.0.1b1/bernardyn/gui/main_window.py +917 -0
  19. bernardyn-0.0.1b1/bernardyn/io/__init__.py +10 -0
  20. bernardyn-0.0.1b1/bernardyn/io/container.py +600 -0
  21. bernardyn-0.0.1b1/bernardyn/io/curve_export.py +83 -0
  22. bernardyn-0.0.1b1/bernardyn/io/file_browser.py +133 -0
  23. bernardyn-0.0.1b1/bernardyn/io/igor.py +50 -0
  24. bernardyn-0.0.1b1/bernardyn/io/sources.py +636 -0
  25. bernardyn-0.0.1b1/bernardyn/main.py +8 -0
  26. bernardyn-0.0.1b1/bernardyn/plot/__init__.py +5 -0
  27. bernardyn-0.0.1b1/bernardyn/py.typed +0 -0
  28. bernardyn-0.0.1b1/bernardyn/renderers/__init__.py +20 -0
  29. bernardyn-0.0.1b1/bernardyn/renderers/opengl.py +267 -0
  30. bernardyn-0.0.1b1/bernardyn/renderers/plot2d.py +618 -0
  31. bernardyn-0.0.1b1/bernardyn/renderers/registry.py +58 -0
  32. bernardyn-0.0.1b1/bernardyn/schemas/graph-package-v1.md +111 -0
  33. bernardyn-0.0.1b1/bernardyn/state.py +44 -0
  34. bernardyn-0.0.1b1/bernardyn/template/__init__.py +5 -0
  35. bernardyn-0.0.1b1/bernardyn/template/graph_templates.py +118 -0
  36. bernardyn-0.0.1b1/bernardyn/utils/__init__.py +1 -0
  37. bernardyn-0.0.1b1/bernardyn.egg-info/PKG-INFO +129 -0
  38. bernardyn-0.0.1b1/bernardyn.egg-info/SOURCES.txt +50 -0
  39. bernardyn-0.0.1b1/bernardyn.egg-info/dependency_links.txt +1 -0
  40. bernardyn-0.0.1b1/bernardyn.egg-info/entry_points.txt +5 -0
  41. bernardyn-0.0.1b1/bernardyn.egg-info/requires.txt +14 -0
  42. bernardyn-0.0.1b1/bernardyn.egg-info/top_level.txt +1 -0
  43. bernardyn-0.0.1b1/pyproject.toml +71 -0
  44. bernardyn-0.0.1b1/setup.cfg +4 -0
  45. bernardyn-0.0.1b1/tests/test_container.py +228 -0
  46. bernardyn-0.0.1b1/tests/test_gui.py +401 -0
  47. bernardyn-0.0.1b1/tests/test_models_and_transforms.py +202 -0
  48. bernardyn-0.0.1b1/tests/test_performance.py +85 -0
  49. bernardyn-0.0.1b1/tests/test_real_fixtures.py +27 -0
  50. bernardyn-0.0.1b1/tests/test_sources.py +111 -0
  51. bernardyn-0.0.1b1/tests/test_state.py +12 -0
  52. bernardyn-0.0.1b1/tests/test_templates.py +49 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Ilavsky and Bernardyn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: bernardyn
3
+ Version: 0.0.1b1
4
+ Summary: Publication-oriented plotting workbench for small-angle scattering data
5
+ Author: Jan Ilavsky
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jilavsky/Bernardyn
8
+ Project-URL: Repository, https://github.com/jilavsky/Bernardyn
9
+ Project-URL: Issues, https://github.com/jilavsky/Bernardyn/issues
10
+ Keywords: SAXS,SANS,USAXS,WAXS,scattering,visualization
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Physics
22
+ Requires-Python: <3.14,>=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=2.0
26
+ Requires-Dist: h5py>=3.0
27
+ Requires-Dist: PyOpenGL>=3.1.7
28
+ Requires-Dist: pyirena[qtplot]<2,>=1.1.0b12
29
+ Requires-Dist: pyqtgraph>=0.13.7
30
+ Requires-Dist: PySide6!=6.9.1,>=6.8
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: pytest-cov; extra == "dev"
34
+ Requires-Dist: pytest-qt>=4.2; extra == "dev"
35
+ Requires-Dist: ruff; extra == "dev"
36
+ Requires-Dist: build; extra == "dev"
37
+ Requires-Dist: twine; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # Bernardyn
41
+
42
+ Bernardyn is a PySide6/PyQtGraph workbench for publication-oriented plotting of
43
+ small-angle scattering and diffraction curves. It keeps scientific arrays out
44
+ of widgets and stores complete, source-independent graphs in portable HDF5
45
+ packages.
46
+
47
+ ## Current capabilities
48
+
49
+ - Browse all discoverable NXcanSAS/simple-HDF5 entries and 2–4 column text data.
50
+ - Keep a workspace-wide dataset catalog and independent graph tabs.
51
+ - Plot General I(Q), Guinier, rod/sheet Guinier, Kratky, dimensionless Kratky,
52
+ Porod, modified Porod, Zimm, and Debye–Bueche views.
53
+ - Style each curve, errors, labels, axes, legends, typography, and annotations.
54
+ - Render 3D line waterfalls and common-grid surfaces with an isolated,
55
+ lazily-loaded PyQtGraph OpenGL backend and 2D fallback.
56
+ - Save one graph or a deduplicated multi-graph workspace as
57
+ `*.bernardyn.h5`, including canonical data, resolved snapshots, configuration,
58
+ checksums, previews, and 3D renderer data.
59
+ - Export PNG/JPEG/SVG, CSV, Igor ITX, and canonical data through PyIrena's H5XP
60
+ writer. H5XP export is data interoperability, not Bernardyn graph persistence.
61
+
62
+ The native schema is documented in
63
+ [`bernardyn/schemas/graph-package-v1.md`](bernardyn/schemas/graph-package-v1.md).
64
+ User-facing workflow and file-format documentation is in
65
+ [`docs/`](docs/README.md).
66
+
67
+ ## Install and run
68
+
69
+ Bernardyn targets Python 3.10–3.13 and shares its Qt plotting dependencies with
70
+ PyIrena. For development, keep the `Bernardyn` and `pyirena` repositories next
71
+ to each other and create the pinned Conda environment from the Bernardyn root:
72
+
73
+ ```bash
74
+ conda env create -f environment.yml
75
+ conda activate bernardyn
76
+ bernardyn-doctor
77
+ bernardyn
78
+ ```
79
+
80
+ The environment file installs both repositories in editable mode. If an older
81
+ `bernardyn` environment already exists with Python 3.14, remove and recreate it:
82
+
83
+ ```bash
84
+ conda deactivate
85
+ conda env remove -n bernardyn
86
+ conda env create -f environment.yml
87
+ conda activate bernardyn
88
+ ```
89
+
90
+ For an already compatible Python 3.10–3.13 environment, installation can also
91
+ be performed manually:
92
+
93
+ ```bash
94
+ python -m pip install -e "../pyirena[qtplot]"
95
+ python -m pip install -e ".[dev]"
96
+ bernardyn
97
+ ```
98
+
99
+ To diagnose an installation:
100
+
101
+ ```bash
102
+ bernardyn-doctor
103
+ bernardyn-doctor --json
104
+ ```
105
+
106
+ The OpenGL renderer needs a usable graphics context. If it cannot initialize,
107
+ Bernardyn displays a 2D offset-waterfall fallback and retains the 3D document
108
+ configuration.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ pytest
114
+ ruff check bernardyn tests
115
+ python -m build
116
+ twine check dist/*
117
+ ```
118
+
119
+ Extension entry points are `bernardyn.sources`, `bernardyn.transforms`, and
120
+ `bernardyn.renderers`; see [`bernardyn/EXTENDING.md`](bernardyn/EXTENDING.md).
121
+
122
+ ## Scope
123
+
124
+ Version 1.0 is a plotting and presentation application. Model fitting,
125
+ reduction, destructive source modification, movies, and calibrated 2D detector
126
+ workflows are intentionally outside 1.0. Existing experimental 0.1 containers
127
+ are not treated as compatible graph packages.
128
+
129
+ Bernardyn is distributed under the MIT License.
@@ -0,0 +1,90 @@
1
+ # Bernardyn
2
+
3
+ Bernardyn is a PySide6/PyQtGraph workbench for publication-oriented plotting of
4
+ small-angle scattering and diffraction curves. It keeps scientific arrays out
5
+ of widgets and stores complete, source-independent graphs in portable HDF5
6
+ packages.
7
+
8
+ ## Current capabilities
9
+
10
+ - Browse all discoverable NXcanSAS/simple-HDF5 entries and 2–4 column text data.
11
+ - Keep a workspace-wide dataset catalog and independent graph tabs.
12
+ - Plot General I(Q), Guinier, rod/sheet Guinier, Kratky, dimensionless Kratky,
13
+ Porod, modified Porod, Zimm, and Debye–Bueche views.
14
+ - Style each curve, errors, labels, axes, legends, typography, and annotations.
15
+ - Render 3D line waterfalls and common-grid surfaces with an isolated,
16
+ lazily-loaded PyQtGraph OpenGL backend and 2D fallback.
17
+ - Save one graph or a deduplicated multi-graph workspace as
18
+ `*.bernardyn.h5`, including canonical data, resolved snapshots, configuration,
19
+ checksums, previews, and 3D renderer data.
20
+ - Export PNG/JPEG/SVG, CSV, Igor ITX, and canonical data through PyIrena's H5XP
21
+ writer. H5XP export is data interoperability, not Bernardyn graph persistence.
22
+
23
+ The native schema is documented in
24
+ [`bernardyn/schemas/graph-package-v1.md`](bernardyn/schemas/graph-package-v1.md).
25
+ User-facing workflow and file-format documentation is in
26
+ [`docs/`](docs/README.md).
27
+
28
+ ## Install and run
29
+
30
+ Bernardyn targets Python 3.10–3.13 and shares its Qt plotting dependencies with
31
+ PyIrena. For development, keep the `Bernardyn` and `pyirena` repositories next
32
+ to each other and create the pinned Conda environment from the Bernardyn root:
33
+
34
+ ```bash
35
+ conda env create -f environment.yml
36
+ conda activate bernardyn
37
+ bernardyn-doctor
38
+ bernardyn
39
+ ```
40
+
41
+ The environment file installs both repositories in editable mode. If an older
42
+ `bernardyn` environment already exists with Python 3.14, remove and recreate it:
43
+
44
+ ```bash
45
+ conda deactivate
46
+ conda env remove -n bernardyn
47
+ conda env create -f environment.yml
48
+ conda activate bernardyn
49
+ ```
50
+
51
+ For an already compatible Python 3.10–3.13 environment, installation can also
52
+ be performed manually:
53
+
54
+ ```bash
55
+ python -m pip install -e "../pyirena[qtplot]"
56
+ python -m pip install -e ".[dev]"
57
+ bernardyn
58
+ ```
59
+
60
+ To diagnose an installation:
61
+
62
+ ```bash
63
+ bernardyn-doctor
64
+ bernardyn-doctor --json
65
+ ```
66
+
67
+ The OpenGL renderer needs a usable graphics context. If it cannot initialize,
68
+ Bernardyn displays a 2D offset-waterfall fallback and retains the 3D document
69
+ configuration.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pytest
75
+ ruff check bernardyn tests
76
+ python -m build
77
+ twine check dist/*
78
+ ```
79
+
80
+ Extension entry points are `bernardyn.sources`, `bernardyn.transforms`, and
81
+ `bernardyn.renderers`; see [`bernardyn/EXTENDING.md`](bernardyn/EXTENDING.md).
82
+
83
+ ## Scope
84
+
85
+ Version 1.0 is a plotting and presentation application. Model fitting,
86
+ reduction, destructive source modification, movies, and calibrated 2D detector
87
+ workflows are intentionally outside 1.0. Existing experimental 0.1 containers
88
+ are not treated as compatible graph packages.
89
+
90
+ Bernardyn is distributed under the MIT License.
@@ -0,0 +1,23 @@
1
+ """Bernardyn scientific plotting workbench."""
2
+
3
+ from bernardyn.core.models import (
4
+ Annotation,
5
+ Dataset,
6
+ DatasetKind,
7
+ GraphDocument,
8
+ PlotSeries,
9
+ SeriesView,
10
+ Workspace,
11
+ )
12
+
13
+ __all__ = [
14
+ "Annotation",
15
+ "Dataset",
16
+ "DatasetKind",
17
+ "GraphDocument",
18
+ "PlotSeries",
19
+ "SeriesView",
20
+ "Workspace",
21
+ ]
22
+
23
+ __version__ = "0.0.1b1"
@@ -0,0 +1,31 @@
1
+ """GUI application entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ from PySide6.QtWidgets import QApplication
10
+
11
+ from bernardyn.gui.main_window import MainWindow
12
+
13
+
14
+ def main() -> int:
15
+ # BERNARDYN_LOG_LEVEL=DEBUG turns on the per-annotation placement trace
16
+ # and the renderer's coordinate diagnostics.
17
+ level = os.environ.get("BERNARDYN_LOG_LEVEL", "INFO").upper()
18
+ logging.basicConfig(
19
+ level=getattr(logging, level, logging.INFO),
20
+ format="%(levelname)s %(name)s: %(message)s",
21
+ )
22
+ app = QApplication.instance() or QApplication(sys.argv)
23
+ app.setApplicationName("Bernardyn")
24
+ app.setOrganizationName("Bernardyn")
25
+ window = MainWindow()
26
+ window.show()
27
+ return app.exec()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Scientific domain models, transforms, registries, and workspace logic."""
2
+
3
+ from bernardyn.core.models import * # noqa: F403
4
+ from bernardyn.core.transforms import TransformRegistry, builtin_transforms
5
+
6
+ __all__ = ["TransformRegistry", "builtin_transforms"]
@@ -0,0 +1,319 @@
1
+ """Qt-independent application controller for workspaces and graph packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Mapping
8
+
9
+ from bernardyn.core.models import (
10
+ Dataset,
11
+ GraphDocument,
12
+ PlotSeries,
13
+ SeriesStyle,
14
+ SeriesView,
15
+ Workspace,
16
+ )
17
+ from bernardyn.core.transforms import TransformRegistry, builtin_transforms, resolve_series
18
+ from bernardyn.io.container import LoadedPackage, import_graphs, load_package, save_package
19
+ from bernardyn.io.sources import ScatteringLocation, SourceRegistry, builtin_sources
20
+
21
+ PALETTE = (
22
+ (31, 119, 180, 255),
23
+ (214, 39, 40, 255),
24
+ (44, 160, 44, 255),
25
+ (148, 103, 189, 255),
26
+ (255, 127, 14, 255),
27
+ (23, 190, 207, 255),
28
+ (140, 86, 75, 255),
29
+ (227, 119, 194, 255),
30
+ )
31
+
32
+
33
+ class ApplicationController:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ transforms: TransformRegistry | None = None,
38
+ sources: SourceRegistry | None = None,
39
+ ) -> None:
40
+ self.transforms = transforms or builtin_transforms()
41
+ self.sources = sources or builtin_sources()
42
+ self.workspace = Workspace()
43
+ self.snapshots: dict[str, dict[str, PlotSeries]] = {}
44
+ self.previews: dict[str, bytes] = {}
45
+ self.renderer_data: dict[str, dict[str, Any]] = {}
46
+ self.read_only_graphs: set[str] = set()
47
+ self.preview_only_graphs: set[str] = set()
48
+ self._graph_warnings: dict[str, list[str]] = {}
49
+ self.package_path: Path | None = None
50
+ self.warnings: list[str] = []
51
+ self.new_graph()
52
+
53
+ def new_workspace(self, title: str = "Untitled workspace") -> None:
54
+ self.workspace = Workspace(title=title)
55
+ self.snapshots.clear()
56
+ self.previews.clear()
57
+ self.renderer_data.clear()
58
+ self.read_only_graphs.clear()
59
+ self.preview_only_graphs.clear()
60
+ self._graph_warnings.clear()
61
+ self.package_path = None
62
+ self.warnings.clear()
63
+ self.new_graph()
64
+
65
+ def new_graph(self, renderer_id: str = "plot2d", title: str | None = None) -> GraphDocument:
66
+ number = len(self.workspace.graphs) + 1
67
+ graph = GraphDocument(
68
+ title=title or f"Scattering plot {number}",
69
+ renderer_id=renderer_id,
70
+ renderer_config={
71
+ "mode": "surface" if renderer_id == "opengl_surface" else "waterfall",
72
+ "renderer_version": "1.0",
73
+ "spacing": 1.0,
74
+ "normalization": "none",
75
+ "surface_samples": 512,
76
+ "series_axis_key": "",
77
+ "series_positions": {},
78
+ "projection": "perspective",
79
+ "show_grid": True,
80
+ "camera": {"distance": 40.0, "elevation": 25.0, "azimuth": -45.0},
81
+ }
82
+ if renderer_id.startswith("opengl")
83
+ else {"renderer_version": "1.0"},
84
+ )
85
+ self.workspace.add_graph(graph)
86
+ self.snapshots[graph.id] = {}
87
+ return graph
88
+
89
+ def close_graph(self, graph_id: str) -> None:
90
+ self.workspace.graphs = [graph for graph in self.workspace.graphs if graph.id != graph_id]
91
+ self.snapshots.pop(graph_id, None)
92
+ self.previews.pop(graph_id, None)
93
+ self.renderer_data.pop(graph_id, None)
94
+ self.read_only_graphs.discard(graph_id)
95
+ self.preview_only_graphs.discard(graph_id)
96
+ self._graph_warnings.pop(graph_id, None)
97
+ if not self.workspace.graphs:
98
+ self.new_graph()
99
+ self.workspace.active_graph_id = self.workspace.graphs[-1].id
100
+ self.workspace.dirty = True
101
+
102
+ def add_dataset(self, dataset: Dataset, *, graph_id: str | None = None) -> SeriesView:
103
+ self.workspace.add_dataset(dataset)
104
+ graph = self.workspace.graph(graph_id or self.workspace.active_graph_id or "")
105
+ series = SeriesView(
106
+ dataset_id=dataset.id,
107
+ legend_label=dataset.label,
108
+ style=SeriesStyle(color=PALETTE[len(graph.series) % len(PALETTE)]),
109
+ )
110
+ self.workspace.replace_graph(graph.replace_series((*graph.series, series)))
111
+ self.recompute_graph(graph.id)
112
+ return series
113
+
114
+ def load_location(
115
+ self,
116
+ location: ScatteringLocation,
117
+ *,
118
+ graph_id: str | None = None,
119
+ q_unit: str = "1/A",
120
+ error_fraction: float = 0.05,
121
+ ) -> Dataset:
122
+ record = self.sources.load_location(
123
+ location, q_unit=q_unit, error_fraction=error_fraction
124
+ )
125
+ dataset = record.to_dataset()
126
+ self.add_dataset(dataset, graph_id=graph_id)
127
+ return dataset
128
+
129
+ def update_graph(self, graph: GraphDocument, *, recompute: bool = False) -> None:
130
+ if graph.id in self.read_only_graphs:
131
+ raise PermissionError("this graph is read-only because its canonical data are invalid")
132
+ self.workspace.replace_graph(graph)
133
+ if recompute:
134
+ self.recompute_graph(graph.id)
135
+
136
+ def recompute_graph(self, graph_id: str) -> dict[str, PlotSeries]:
137
+ if graph_id in self.read_only_graphs:
138
+ raise PermissionError("cannot recompute a graph with invalid canonical data")
139
+ graph = self.workspace.graph(graph_id)
140
+ resolved: dict[str, PlotSeries] = {}
141
+ for view in graph.series:
142
+ dataset = self.workspace.datasets[view.dataset_id]
143
+ resolved[view.id] = resolve_series(
144
+ dataset,
145
+ view,
146
+ self.transforms,
147
+ x_log=graph.x_axis.log,
148
+ y_log=graph.y_axis.log,
149
+ )
150
+ self.snapshots[graph_id] = resolved
151
+ self._graph_warnings[graph_id] = [
152
+ warning
153
+ for warning in self._graph_warnings.get(graph_id, [])
154
+ if "current version" not in warning and "was recomputed" not in warning
155
+ ]
156
+ self.workspace.dirty = True
157
+ return resolved
158
+
159
+ def set_transform(
160
+ self,
161
+ graph_id: str,
162
+ transform_id: str,
163
+ parameters: Mapping[str, float] | None = None,
164
+ ) -> None:
165
+ graph = self.workspace.graph(graph_id)
166
+ transform = self.transforms.get(transform_id)
167
+ series = tuple(
168
+ replace(
169
+ item,
170
+ transform_id=transform_id,
171
+ transform_parameters=dict(parameters or {}),
172
+ )
173
+ for item in graph.series
174
+ )
175
+ graph = replace(
176
+ graph,
177
+ series=series,
178
+ x_axis=replace(
179
+ graph.x_axis,
180
+ label=transform.default_x_label,
181
+ log=transform.default_x_log,
182
+ auto_range=True,
183
+ ),
184
+ y_axis=replace(
185
+ graph.y_axis,
186
+ label=transform.default_y_label,
187
+ log=transform.default_y_log,
188
+ auto_range=True,
189
+ ),
190
+ )
191
+ self.update_graph(graph, recompute=True)
192
+
193
+ def remove_series(self, graph_id: str, series_id: str) -> None:
194
+ graph = self.workspace.graph(graph_id)
195
+ self.update_graph(
196
+ graph.replace_series(item for item in graph.series if item.id != series_id),
197
+ recompute=True,
198
+ )
199
+
200
+ def move_series(self, graph_id: str, series_id: str, offset: int) -> None:
201
+ graph = self.workspace.graph(graph_id)
202
+ items = list(graph.series)
203
+ old = next(index for index, item in enumerate(items) if item.id == series_id)
204
+ new = max(0, min(len(items) - 1, old + offset))
205
+ items.insert(new, items.pop(old))
206
+ self.update_graph(graph.replace_series(items), recompute=False)
207
+
208
+ def open_package(self, path: str | Path) -> LoadedPackage:
209
+ loaded = load_package(path)
210
+ self.workspace = loaded.workspace
211
+ self.snapshots = loaded.snapshots
212
+ self.previews = loaded.previews
213
+ self.renderer_data = loaded.renderer_data
214
+ self.read_only_graphs = loaded.read_only_graphs
215
+ self.preview_only_graphs.clear()
216
+ self._graph_warnings.clear()
217
+ self.package_path = loaded.path
218
+ self.warnings = loaded.warnings
219
+ recovered = False
220
+ for graph in self.workspace.graphs:
221
+ graph_warnings = self._graph_warnings.setdefault(graph.id, [])
222
+ for view in graph.series:
223
+ archived = self.snapshots.get(graph.id, {}).get(view.id)
224
+ try:
225
+ transform = self.transforms.get(view.transform_id)
226
+ except KeyError:
227
+ graph_warnings.append(
228
+ f"Unknown transform {view.transform_id!r}; showing the archived preview when available"
229
+ )
230
+ if graph.id in self.previews:
231
+ self.preview_only_graphs.add(graph.id)
232
+ continue
233
+ if archived is not None and archived.transform_version != transform.version:
234
+ graph_warnings.append(
235
+ f"{transform.name} snapshot uses version {archived.transform_version}; "
236
+ f"current version is {transform.version}. Use Recompute with Current Version explicitly."
237
+ )
238
+ continue
239
+ if archived is None:
240
+ dataset = self.workspace.datasets[view.dataset_id]
241
+ self.snapshots.setdefault(graph.id, {})[view.id] = resolve_series(
242
+ dataset,
243
+ view,
244
+ self.transforms,
245
+ x_log=graph.x_axis.log,
246
+ y_log=graph.y_axis.log,
247
+ )
248
+ graph_warnings.append(
249
+ f"Missing or corrupt snapshot for {dataset.label!r} was recomputed from embedded data"
250
+ )
251
+ recovered = True
252
+ if recovered:
253
+ self.workspace.dirty = True
254
+ return loaded
255
+
256
+ def import_from_package(
257
+ self, path: str | Path, graph_ids: Iterable[str] | None = None
258
+ ) -> dict[str, str]:
259
+ loaded = load_package(path)
260
+ selected = list(graph_ids) if graph_ids is not None else [g.id for g in loaded.workspace.graphs]
261
+ graph_map, snapshots = import_graphs(self.workspace, loaded, selected)
262
+ self.snapshots.update(snapshots)
263
+ for old_id, new_id in graph_map.items():
264
+ if old_id in loaded.previews:
265
+ self.previews[new_id] = loaded.previews[old_id]
266
+ if old_id in loaded.renderer_data:
267
+ self.renderer_data[new_id] = loaded.renderer_data[old_id]
268
+ if old_id in loaded.read_only_graphs:
269
+ self.read_only_graphs.add(new_id)
270
+ self.warnings.extend(loaded.warnings)
271
+ return graph_map
272
+
273
+ def save(
274
+ self,
275
+ path: str | Path | None = None,
276
+ *,
277
+ graph_ids: Iterable[str] | None = None,
278
+ previews: Mapping[str, bytes] | None = None,
279
+ renderer_data: Mapping[str, Mapping[str, Any]] | None = None,
280
+ ) -> Path:
281
+ destination = Path(path) if path is not None else self.package_path
282
+ if destination is None:
283
+ raise ValueError("a destination path is required for the first save")
284
+ chosen = list(graph_ids) if graph_ids is not None else None
285
+ selected_graph_ids = chosen or [graph.id for graph in self.workspace.graphs]
286
+ read_only = self.read_only_graphs.intersection(selected_graph_ids)
287
+ if read_only:
288
+ raise PermissionError(
289
+ "cannot save graphs whose canonical arrays failed validation; "
290
+ "retain the original package as the archival copy"
291
+ )
292
+ for graph_id in selected_graph_ids:
293
+ graph = self.workspace.graph(graph_id)
294
+ missing = [
295
+ series.id
296
+ for series in graph.series
297
+ if series.id not in self.snapshots.get(graph_id, {})
298
+ ]
299
+ if missing and graph_id not in self.read_only_graphs:
300
+ self.recompute_graph(graph_id)
301
+ saved = save_package(
302
+ destination,
303
+ self.workspace,
304
+ self.snapshots,
305
+ graph_ids=chosen,
306
+ previews=previews or self.previews,
307
+ renderer_data=renderer_data or self.renderer_data,
308
+ )
309
+ if chosen is None:
310
+ self.package_path = saved
311
+ self.workspace.dirty = False
312
+ return saved
313
+
314
+ def graph_warnings(self, graph_id: str) -> list[str]:
315
+ return self._graph_warnings.get(graph_id, []) + [
316
+ warning
317
+ for snapshot in self.snapshots.get(graph_id, {}).values()
318
+ for warning in snapshot.warnings
319
+ ]