majoplot 0.1.2__tar.gz → 0.1.3__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.
- {majoplot-0.1.2 → majoplot-0.1.3}/PKG-INFO +1 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/pyproject.toml +12 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/base.py +6 -6
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/muti_axes_spec.py +0 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/VSM/MT.py +1 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/VSM/MT_insert.py +1 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/VSM/MT_reliability_analysis.py +1 -1
- majoplot-0.1.3/tests/README.md +29 -0
- majoplot-0.1.3/tests/conftest.py +39 -0
- majoplot-0.1.3/tests/domain/test_data_ignore_outliers.py +97 -0
- majoplot-0.1.3/tests/domain/test_insert_axes_spec.py +89 -0
- majoplot-0.1.3/tests/domain/test_labeldict_group.py +162 -0
- majoplot-0.1.3/tests/domain/test_labelvalue.py +104 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/uv.lock +61 -1
- {majoplot-0.1.2 → majoplot-0.1.3}/.gitignore +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/LICENSE +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/README.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/README.zh-CN.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/doc.zh-CN/Label.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/doc.zh-CN/interactive_steps.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/doc.zh-CN/main_design.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/doc.zh-CN/note_of_labtalk.md +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/__init__.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/__main__.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/app/__init__.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/app/cli.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/app/gui.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/config.json +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/importers/PPMS_Resistivity.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/importers/VSM.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/importers/XRD.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/PPMS_Resistivity/RT.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/XRD/Compare.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/utils.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/gui/__init__.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/gui/main.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/infra/plotters/matplot.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/infra/plotters/origin.py +0 -0
- {majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/infra/plotters/origin_utils/originlab_type_library.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: majoplot
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Automates scenario-specific data preprocessing and plotting workflows for condensed-matter physics labs (OriginLab COM backend + Matplotlib for preview).
|
|
5
5
|
Project-URL: Homepage, https://github.com/ponyofshadows/majoplot
|
|
6
6
|
Project-URL: Source, https://github.com/ponyofshadows/majoplot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "majoplot"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Automates scenario-specific data preprocessing and plotting workflows for condensed-matter physics labs (OriginLab COM backend + Matplotlib for preview)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -58,3 +58,14 @@ build-backend = "hatchling.build"
|
|
|
58
58
|
|
|
59
59
|
[tool.hatch.build.targets.wheel]
|
|
60
60
|
packages = ["src/majoplot"]
|
|
61
|
+
|
|
62
|
+
[dependency-groups]
|
|
63
|
+
dev = [
|
|
64
|
+
"pytest>=9.0.2",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
addopts = "--import-mode=importlib"
|
|
70
|
+
norecursedirs = ["playground", "dist", "build", ".venv", "__pycache__"]
|
|
71
|
+
|
|
@@ -38,7 +38,7 @@ class LabelValue(NamedTuple):
|
|
|
38
38
|
elif other.unit is None:
|
|
39
39
|
return False
|
|
40
40
|
else:
|
|
41
|
-
return self.unit
|
|
41
|
+
return self.unit < other.unit
|
|
42
42
|
else:
|
|
43
43
|
return True
|
|
44
44
|
elif isinstance(other.value, Real):
|
|
@@ -216,7 +216,7 @@ class Data:
|
|
|
216
216
|
|
|
217
217
|
@property
|
|
218
218
|
def y_for_plot(self)->NDArray[np.floating]:
|
|
219
|
-
if self.
|
|
219
|
+
if self._y_for_plot is None:
|
|
220
220
|
self._y_for_plot = self.points_for_plot[:,1]
|
|
221
221
|
return self._y_for_plot
|
|
222
222
|
|
|
@@ -320,8 +320,8 @@ class AnnotationSpec(NamedTuple):
|
|
|
320
320
|
class ArrowSpec(NamedTuple):
|
|
321
321
|
point_x:float
|
|
322
322
|
point_y:float
|
|
323
|
-
arrowstyle:str = "->"
|
|
324
|
-
color:str = "black"
|
|
323
|
+
arrowstyle:str = "->"
|
|
324
|
+
color:str = "black"
|
|
325
325
|
linewidth:float = 1.0
|
|
326
326
|
|
|
327
327
|
class IgnoreOutlierSpec(NamedTuple):
|
|
@@ -350,10 +350,10 @@ class MutiAxesSpec(Protocol):
|
|
|
350
350
|
...
|
|
351
351
|
|
|
352
352
|
# ======== Proj & folder ========
|
|
353
|
-
class folder(dict[str
|
|
353
|
+
class folder(dict[str,Figure]):
|
|
354
354
|
pass
|
|
355
355
|
|
|
356
|
-
class Project(dict[str
|
|
356
|
+
class Project(dict[str,folder]):
|
|
357
357
|
pass
|
|
358
358
|
|
|
359
359
|
# ======== Scenario ========
|
|
@@ -76,7 +76,7 @@ class MT:
|
|
|
76
76
|
check_deque.append(point)
|
|
77
77
|
if len(check_deque) == 2:
|
|
78
78
|
# not the same H?
|
|
79
|
-
if abs(check_deque[1][iH] - H_stage) >
|
|
79
|
+
if abs(check_deque[1][iH] - H_stage) > 1.5:
|
|
80
80
|
append_data()
|
|
81
81
|
current_points = [check_deque.pop()]
|
|
82
82
|
H_stage = round(current_points[0][iH])
|
|
@@ -77,7 +77,7 @@ class MT_insert:
|
|
|
77
77
|
check_deque.append(point)
|
|
78
78
|
if len(check_deque) == 2:
|
|
79
79
|
# not the same H?
|
|
80
|
-
if abs(check_deque[1][iH] - H_stage) >
|
|
80
|
+
if abs(check_deque[1][iH] - H_stage) > 1.5:
|
|
81
81
|
append_data()
|
|
82
82
|
current_points = [check_deque.pop()]
|
|
83
83
|
H_stage = round(current_points[0][iH])
|
{majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/domain/scenarios/VSM/MT_reliability_analysis.py
RENAMED
|
@@ -88,7 +88,7 @@ class MT_reliability_analysis:
|
|
|
88
88
|
check_deque.append(point)
|
|
89
89
|
if len(check_deque) == 2:
|
|
90
90
|
# not the same H?
|
|
91
|
-
if abs(check_deque[1][iH] - H_stage) >
|
|
91
|
+
if abs(check_deque[1][iH] - H_stage) > 1.5:
|
|
92
92
|
append_data()
|
|
93
93
|
current_points = [check_deque.pop()]
|
|
94
94
|
H_stage = round(current_points[0][iH])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# majoplot tests
|
|
2
|
+
|
|
3
|
+
This folder contains **pytest** tests added without modifying any production code.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
If you use `uv`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv sync --extra test
|
|
11
|
+
uv run pytest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If you use `pip`:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e .
|
|
18
|
+
pip install pytest
|
|
19
|
+
pytest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Test scope
|
|
23
|
+
|
|
24
|
+
- `tests/domain/`: Pure-logic tests (no GUI, no Origin COM). These should run on any OS.
|
|
25
|
+
|
|
26
|
+
## Notes
|
|
27
|
+
|
|
28
|
+
- We intentionally avoid testing Origin COM and GUI here because they are environment-dependent.
|
|
29
|
+
A recommended next step is to add optional smoke tests behind a marker, e.g. `@pytest.mark.origin`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Pytest configuration for majoplot.
|
|
2
|
+
|
|
3
|
+
Important constraints
|
|
4
|
+
---------------------
|
|
5
|
+
- We do NOT edit or patch any production source files.
|
|
6
|
+
- We only add tests.
|
|
7
|
+
|
|
8
|
+
Why we touch sys.path
|
|
9
|
+
---------------------
|
|
10
|
+
This repository uses a `src/` layout (package code lives under `src/majoplot`).
|
|
11
|
+
When running tests from a plain source checkout, Python may not automatically
|
|
12
|
+
find `src/` on the import path.
|
|
13
|
+
|
|
14
|
+
Adding `src/` to `sys.path` inside tests is a common, minimal approach that
|
|
15
|
+
keeps tests runnable without requiring an editable install.
|
|
16
|
+
|
|
17
|
+
If you prefer, you can remove this file and run tests after an editable install:
|
|
18
|
+
uv pip install -e .
|
|
19
|
+
uv run pytest
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pytest_configure() -> None:
|
|
29
|
+
"""Add the repository's `src/` directory to `sys.path`.
|
|
30
|
+
|
|
31
|
+
English comment: This makes `import majoplot` work in a fresh checkout.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
35
|
+
src_dir = repo_root / "src"
|
|
36
|
+
|
|
37
|
+
# English comment: Insert at the front so local sources win over any
|
|
38
|
+
# globally-installed `majoplot` package the developer might have.
|
|
39
|
+
sys.path.insert(0, str(src_dir))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tests for `Data.ignore_outliers` behavior.
|
|
2
|
+
|
|
3
|
+
The `Data` class can optionally filter outliers using a sliding-window heuristic.
|
|
4
|
+
This is a pure, deterministic transformation of the `points` array.
|
|
5
|
+
|
|
6
|
+
Why this matters
|
|
7
|
+
----------------
|
|
8
|
+
Outlier filtering changes plotted points and therefore impacts:
|
|
9
|
+
- axis limits
|
|
10
|
+
- marker/line appearance (dense vs sparse)
|
|
11
|
+
- derived downstream computations
|
|
12
|
+
|
|
13
|
+
These tests intentionally use small arrays so expected outputs are easy to verify.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from majoplot.domain.base import Data, IgnoreOutlierSpec, LabelDict
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mk_data(points: np.ndarray, *, ignore: IgnoreOutlierSpec | None = None) -> Data:
|
|
24
|
+
"""Helper: build a Data object with minimal required fields."""
|
|
25
|
+
|
|
26
|
+
return Data(
|
|
27
|
+
labels=LabelDict({}),
|
|
28
|
+
_headers=("x", "y"),
|
|
29
|
+
points=points.astype(float),
|
|
30
|
+
ignore_outliers=ignore,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_points_for_plot_no_ignore_outliers_returns_original_points() -> None:
|
|
35
|
+
"""If ignore_outliers is None, points_for_plot should be the original array."""
|
|
36
|
+
|
|
37
|
+
pts = np.array([[0, 0], [1, 1], [2, 2]], dtype=float)
|
|
38
|
+
d = _mk_data(pts, ignore=None)
|
|
39
|
+
|
|
40
|
+
# English comment: We expect identity or at least identical values.
|
|
41
|
+
assert np.allclose(d.points_for_plot, pts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_ignore_outliers_drops_middle_spike() -> None:
|
|
45
|
+
"""A large spike at the middle element should be dropped.
|
|
46
|
+
|
|
47
|
+
The algorithm (in production code) inspects triples (p0, p1, p2) and checks
|
|
48
|
+
whether the middle y-value is an outlier compared to endpoints.
|
|
49
|
+
|
|
50
|
+
We craft points where:
|
|
51
|
+
- p0 -> p2 is small change
|
|
52
|
+
- p0 -> p1 is huge change
|
|
53
|
+
|
|
54
|
+
Under those conditions, p1 is removed.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
pts = np.array(
|
|
58
|
+
[
|
|
59
|
+
[0.0, 0.0],
|
|
60
|
+
[1.0, 100.0], # outlier
|
|
61
|
+
[2.0, 1.0],
|
|
62
|
+
[3.0, 2.0],
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
d = _mk_data(pts, ignore=IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=20.0))
|
|
67
|
+
|
|
68
|
+
filtered = d.points_for_plot
|
|
69
|
+
|
|
70
|
+
# English comment: The outlier point [1, 100] should not appear.
|
|
71
|
+
assert filtered.shape[0] == 3
|
|
72
|
+
assert not np.any((filtered == np.array([1.0, 100.0])).all(axis=1))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_ignore_outliers_cache_refreshes_when_spec_changes() -> None:
|
|
76
|
+
"""Changing ignore_outliers spec should refresh the cached filtered points."""
|
|
77
|
+
|
|
78
|
+
pts = np.array(
|
|
79
|
+
[
|
|
80
|
+
[0.0, 0.0],
|
|
81
|
+
[1.0, 100.0],
|
|
82
|
+
[2.0, 1.0],
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
d = _mk_data(pts, ignore=IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=20.0))
|
|
87
|
+
|
|
88
|
+
first = d.points_for_plot.copy()
|
|
89
|
+
|
|
90
|
+
# English comment: Make the filter less aggressive so it may keep the middle.
|
|
91
|
+
d.ignore_outliers = IgnoreOutlierSpec(min_gap_base=1.0, min_gap_multiple=2000.0)
|
|
92
|
+
|
|
93
|
+
second = d.points_for_plot
|
|
94
|
+
|
|
95
|
+
# English comment: We don't mandate the exact output; we only require that
|
|
96
|
+
# the cache was recomputed (i.e., values can differ).
|
|
97
|
+
assert not np.array_equal(first, second) or second.shape != first.shape
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tests for `InsertAxesSpec.default`.
|
|
2
|
+
|
|
3
|
+
`InsertAxesSpec.default` computes a rectangle (in figure-relative coordinates)
|
|
4
|
+
where an inset axes can be placed without overlapping existing data points.
|
|
5
|
+
|
|
6
|
+
Why this matters
|
|
7
|
+
----------------
|
|
8
|
+
Inset placement is a visually sensitive feature. If the algorithm returns
|
|
9
|
+
nonsensical coordinates (negative, >1, zero size) you will see glitches like:
|
|
10
|
+
- inset axes outside the figure
|
|
11
|
+
- inset axes covering data points
|
|
12
|
+
- random-looking inset placement
|
|
13
|
+
|
|
14
|
+
These tests focus on *invariants* rather than exact numeric outputs.
|
|
15
|
+
Exact coordinates may change as the algorithm is improved.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from majoplot.domain.base import Axes, AxesSpec, Data, IgnoreOutlierSpec, LabelDict, fail_signal
|
|
23
|
+
from majoplot.domain.muti_axes_spec import InsertAxesSpec
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _mk_data(points: np.ndarray) -> Data:
|
|
27
|
+
"""Helper: create a minimal Data instance."""
|
|
28
|
+
|
|
29
|
+
return Data(
|
|
30
|
+
labels=LabelDict({}),
|
|
31
|
+
_headers=("x", "y"),
|
|
32
|
+
points=points.astype(float),
|
|
33
|
+
ignore_outliers=None,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mk_axes(points: np.ndarray) -> Axes:
|
|
38
|
+
"""Helper: create a minimal Axes instance holding exactly one Data."""
|
|
39
|
+
|
|
40
|
+
spec = AxesSpec(x_axis_title="x", y_axis_title="y")
|
|
41
|
+
return Axes(spec=spec, labels=LabelDict({}), data_pool=[_mk_data(points)])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_insert_axes_spec_returns_fail_when_more_than_two_axes() -> None:
|
|
45
|
+
"""Design rule: current implementation refuses len(axes_pool) > 2."""
|
|
46
|
+
|
|
47
|
+
axes_pool = [_mk_axes(np.array([[0.0, 0.0], [1.0, 1.0]])) for _ in range(3)]
|
|
48
|
+
|
|
49
|
+
res = InsertAxesSpec.default(figure_size=(3.4, 2.6), axes_pool=axes_pool)
|
|
50
|
+
assert res is fail_signal
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_insert_axes_spec_basic_invariants() -> None:
|
|
54
|
+
"""Basic invariants for a valid inset rectangle.
|
|
55
|
+
|
|
56
|
+
English comment:
|
|
57
|
+
- x, y are left-bottom anchor in normalized figure coordinates.
|
|
58
|
+
- width, height are normalized sizes.
|
|
59
|
+
- They should lie within [0, 1] and be positive.
|
|
60
|
+
|
|
61
|
+
We place a few points near the bottom-left corner so there is free space
|
|
62
|
+
elsewhere for the algorithm to pick.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
pts = np.array(
|
|
66
|
+
[
|
|
67
|
+
[0.0, 0.0],
|
|
68
|
+
[0.1, 0.05],
|
|
69
|
+
[0.2, 0.1],
|
|
70
|
+
[0.3, 0.2],
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
axes_pool = [_mk_axes(pts)]
|
|
75
|
+
|
|
76
|
+
res = InsertAxesSpec.default(figure_size=(3.4, 2.6), axes_pool=axes_pool)
|
|
77
|
+
|
|
78
|
+
assert res is not fail_signal
|
|
79
|
+
assert isinstance(res, InsertAxesSpec)
|
|
80
|
+
|
|
81
|
+
# English comment: Geometry must be meaningful.
|
|
82
|
+
assert 0.0 <= res.x <= 1.0
|
|
83
|
+
assert 0.0 <= res.y <= 1.0
|
|
84
|
+
assert res.width > 0.0
|
|
85
|
+
assert res.height > 0.0
|
|
86
|
+
|
|
87
|
+
# English comment: The inset should not start beyond the figure.
|
|
88
|
+
assert res.x + res.width <= 1.0 + 1e-6
|
|
89
|
+
assert res.y + res.height <= 1.0 + 1e-6
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for `LabelDict.group`.
|
|
2
|
+
|
|
3
|
+
This is one of the most important pure-logic utilities in majoplot.
|
|
4
|
+
It groups arbitrary elements based on a subset of labels.
|
|
5
|
+
|
|
6
|
+
Why this matters
|
|
7
|
+
----------------
|
|
8
|
+
Downstream plotting logic often assumes:
|
|
9
|
+
- Grouping is deterministic.
|
|
10
|
+
- Sorting within a group is deterministic.
|
|
11
|
+
- Group member limiting (subgrouping) produces stable subgroup ids.
|
|
12
|
+
|
|
13
|
+
If any of these assumptions break, you may observe symptoms like:
|
|
14
|
+
- Styles seemingly "randomly" applied to curves.
|
|
15
|
+
- Legend items not matching curve order.
|
|
16
|
+
|
|
17
|
+
We keep tests small and explicit so a failure immediately pinpoints the issue.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from majoplot.domain.base import LabelDict, LabelValue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ld(**kwargs: object) -> LabelDict:
|
|
26
|
+
"""Helper: build a LabelDict from simple key=value inputs.
|
|
27
|
+
|
|
28
|
+
English comment:
|
|
29
|
+
- Values are wrapped into LabelValue.
|
|
30
|
+
- This keeps test data compact.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
return LabelDict({k: (v if isinstance(v, LabelValue) else LabelValue(v)) for k, v in kwargs.items()})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_group_basic_two_groups() -> None:
|
|
37
|
+
"""Elements should be partitioned by the group label values."""
|
|
38
|
+
|
|
39
|
+
pairs = [
|
|
40
|
+
(_ld(a=1, b=10), "e1"),
|
|
41
|
+
(_ld(a=1, b=11), "e2"),
|
|
42
|
+
(_ld(a=2, b=20), "e3"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
groups, remains = LabelDict.group(
|
|
46
|
+
pairs,
|
|
47
|
+
group_label_names=["a"],
|
|
48
|
+
summary_label_names=["a"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert remains == []
|
|
52
|
+
|
|
53
|
+
# English comment: `LabelDict.group` returns pairs of
|
|
54
|
+
# (group_label_dict: LabelDict, members: list[Any]).
|
|
55
|
+
# The group_label_dict directly stores the grouping labels.
|
|
56
|
+
by_a = {g["a"].value: members for g, members in groups}
|
|
57
|
+
assert by_a[1] == ["e1", "e2"]
|
|
58
|
+
assert by_a[2] == ["e3"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_group_missing_label_goes_to_remains() -> None:
|
|
62
|
+
"""If a LabelDict is missing any group label, the element goes to remains."""
|
|
63
|
+
|
|
64
|
+
pairs = [
|
|
65
|
+
(_ld(a=1), "ok"),
|
|
66
|
+
(_ld(b=2), "missing"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
groups, remains = LabelDict.group(
|
|
70
|
+
pairs,
|
|
71
|
+
group_label_names=["a"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert [m for m in remains] == ["missing"]
|
|
75
|
+
assert len(groups) == 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_group_sorting_within_group_is_deterministic() -> None:
|
|
79
|
+
"""sort_label_names should sort group members by the specified label(s)."""
|
|
80
|
+
|
|
81
|
+
pairs = [
|
|
82
|
+
(_ld(g=1, s=3), "e3"),
|
|
83
|
+
(_ld(g=1, s=1), "e1"),
|
|
84
|
+
(_ld(g=1, s=2), "e2"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
groups, remains = LabelDict.group(
|
|
88
|
+
pairs,
|
|
89
|
+
group_label_names=["g"],
|
|
90
|
+
sort_label_names=("s",),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert remains == []
|
|
94
|
+
assert len(groups) == 1
|
|
95
|
+
|
|
96
|
+
(_group_labels, members) = groups[0]
|
|
97
|
+
assert members == ["e1", "e2", "e3"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_group_member_limit_creates_subgroups_with_ids() -> None:
|
|
101
|
+
"""group_member_limit should split a group into multiple subgroups.
|
|
102
|
+
|
|
103
|
+
English comment:
|
|
104
|
+
- subgroup id is derived from position // limit.
|
|
105
|
+
- the returned LabelDict should carry that subgroup id.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
pairs = [(_ld(g=1, s=i), f"e{i}") for i in range(5)]
|
|
109
|
+
|
|
110
|
+
groups, remains = LabelDict.group(
|
|
111
|
+
pairs,
|
|
112
|
+
group_label_names=["g"],
|
|
113
|
+
sort_label_names=("s",),
|
|
114
|
+
group_member_limit=2,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert remains == []
|
|
118
|
+
|
|
119
|
+
# NOTE: The current implementation has a known behavior/bug:
|
|
120
|
+
# it only cuts the first subgroup at exactly the limit, and then puts the
|
|
121
|
+
# remaining members into a second subgroup.
|
|
122
|
+
#
|
|
123
|
+
# We assert the CURRENT behavior here so the test suite is stable and can
|
|
124
|
+
# catch unintentional changes.
|
|
125
|
+
assert [members for (_lbl, members) in groups] == [["e0", "e1"], ["e2", "e3", "e4"]]
|
|
126
|
+
# English comment:
|
|
127
|
+
# The current implementation assigns subgroup id based on the *last* element
|
|
128
|
+
# index of the subgroup, because the subgroup LabelDict is re-created on
|
|
129
|
+
# each iteration and the last created one is stored when the subgroup is
|
|
130
|
+
# appended. For members [2,3,4], the last index is 4, and 4//2 == 2.
|
|
131
|
+
assert [lbl.subgroup for (lbl, _members) in groups] == [0, 2]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_group_member_limit_expected_chunking_documented_as_xfail() -> None:
|
|
135
|
+
"""Desired behavior for subgrouping (documented as xfail).
|
|
136
|
+
|
|
137
|
+
English comment:
|
|
138
|
+
The more intuitive behavior is to chunk each group into blocks of size
|
|
139
|
+
`group_member_limit`:
|
|
140
|
+
[0,1], [2,3], [4]
|
|
141
|
+
|
|
142
|
+
The current production implementation does not do this yet.
|
|
143
|
+
We mark this as xfail to record the intended contract for future fixes.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
import pytest
|
|
147
|
+
|
|
148
|
+
pairs = [(_ld(g=1, s=i), f"e{i}") for i in range(5)]
|
|
149
|
+
|
|
150
|
+
groups, remains = LabelDict.group(
|
|
151
|
+
pairs,
|
|
152
|
+
group_label_names=["g"],
|
|
153
|
+
sort_label_names=("s",),
|
|
154
|
+
group_member_limit=2,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert remains == []
|
|
158
|
+
|
|
159
|
+
pytest.xfail("Known issue: subgroup chunking does not fully respect the limit.")
|
|
160
|
+
|
|
161
|
+
assert [members for (_lbl, members) in groups] == [["e0", "e1"], ["e2", "e3"], ["e4"]]
|
|
162
|
+
assert [lbl.subgroup for (lbl, _members) in groups] == [0, 1, 2]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the `LabelValue` type.
|
|
2
|
+
|
|
3
|
+
These tests focus on *ordering* and *string formatting* because those behaviors
|
|
4
|
+
feed directly into grouping, sorting, and legend/label generation.
|
|
5
|
+
|
|
6
|
+
Why this matters
|
|
7
|
+
----------------
|
|
8
|
+
In plotting pipelines, subtle ordering bugs tend to appear as "random" or
|
|
9
|
+
"inverted" style application (e.g., legend labels swapped, colors applied to the
|
|
10
|
+
wrong curve). Deterministic ordering in foundational types like `LabelValue`
|
|
11
|
+
reduces such systemic issues.
|
|
12
|
+
|
|
13
|
+
We do NOT attempt to change production behavior here.
|
|
14
|
+
If a test fails, that's a signal that the current implementation is inconsistent
|
|
15
|
+
with the behavior we want to rely on.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from majoplot.domain.base import LabelValue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_labelvalue_str_no_unit() -> None:
|
|
26
|
+
"""If unit is None, __str__ should be just the value."""
|
|
27
|
+
|
|
28
|
+
lv = LabelValue(3.14)
|
|
29
|
+
assert str(lv) == "3.14"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_labelvalue_str_unit_postfix() -> None:
|
|
33
|
+
"""If unit is set and unit_as_postfix=True, unit should be appended."""
|
|
34
|
+
|
|
35
|
+
lv = LabelValue(10, "K", True)
|
|
36
|
+
assert str(lv) == "10K"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_labelvalue_str_unit_prefix() -> None:
|
|
40
|
+
"""If unit_as_postfix=False, unit should be placed before the value."""
|
|
41
|
+
|
|
42
|
+
lv = LabelValue(10, "K", False)
|
|
43
|
+
assert str(lv) == "K10"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_labelvalue_numeric_vs_numeric_same_unit_orders_by_value() -> None:
|
|
47
|
+
"""Numeric values with the same unit should compare by numeric value."""
|
|
48
|
+
|
|
49
|
+
a = LabelValue(1, "K")
|
|
50
|
+
b = LabelValue(2, "K")
|
|
51
|
+
assert a < b
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_labelvalue_numeric_unit_none_sorts_before_with_unit() -> None:
|
|
55
|
+
"""Implementation detail: unit=None is considered "smaller" than unit!=None.
|
|
56
|
+
|
|
57
|
+
English comment:
|
|
58
|
+
This matches the current implementation (unit=None returns True in the
|
|
59
|
+
ordering logic when comparing numeric values with different units).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
a = LabelValue(1, None)
|
|
63
|
+
b = LabelValue(1, "K")
|
|
64
|
+
assert a < b
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_labelvalue_numeric_unit_lexicographic_when_units_differ() -> None:
|
|
68
|
+
"""When both are numeric but units differ, ordering falls back to unit string."""
|
|
69
|
+
|
|
70
|
+
a = LabelValue(1, "A")
|
|
71
|
+
b = LabelValue(1, "B")
|
|
72
|
+
assert a < b
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_labelvalue_numeric_always_less_than_non_numeric() -> None:
|
|
76
|
+
"""Current rule: numeric values compare as smaller than non-numeric values."""
|
|
77
|
+
|
|
78
|
+
a = LabelValue(1, "K")
|
|
79
|
+
b = LabelValue("foo", None)
|
|
80
|
+
assert a < b
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_labelvalue_non_numeric_compares_by_string() -> None:
|
|
84
|
+
"""Non-numeric values are compared by their string representation."""
|
|
85
|
+
|
|
86
|
+
a = LabelValue("A")
|
|
87
|
+
b = LabelValue("B")
|
|
88
|
+
assert a < b
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.parametrize(
|
|
92
|
+
"value",
|
|
93
|
+
[True, False],
|
|
94
|
+
)
|
|
95
|
+
def test_labelvalue_bool_is_unsupported_and_ordering_is_undefined(value: bool) -> None:
|
|
96
|
+
"""The docstring says bool is unsupported.
|
|
97
|
+
|
|
98
|
+
English comment:
|
|
99
|
+
We don't enforce a hard failure (production code does not raise).
|
|
100
|
+
We only document the boundary by asserting that the type is bool.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
lv = LabelValue(value)
|
|
104
|
+
assert isinstance(lv.value, bool)
|
|
@@ -2,6 +2,15 @@ version = 1
|
|
|
2
2
|
revision = 3
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
5
14
|
[[package]]
|
|
6
15
|
name = "contourpy"
|
|
7
16
|
version = "1.3.3"
|
|
@@ -142,6 +151,15 @@ wheels = [
|
|
|
142
151
|
{ url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" },
|
|
143
152
|
]
|
|
144
153
|
|
|
154
|
+
[[package]]
|
|
155
|
+
name = "iniconfig"
|
|
156
|
+
version = "2.3.0"
|
|
157
|
+
source = { registry = "https://pypi.org/simple" }
|
|
158
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
159
|
+
wheels = [
|
|
160
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
161
|
+
]
|
|
162
|
+
|
|
145
163
|
[[package]]
|
|
146
164
|
name = "kiwisolver"
|
|
147
165
|
version = "1.4.9"
|
|
@@ -234,7 +252,7 @@ wheels = [
|
|
|
234
252
|
|
|
235
253
|
[[package]]
|
|
236
254
|
name = "majoplot"
|
|
237
|
-
version = "0.1.
|
|
255
|
+
version = "0.1.3"
|
|
238
256
|
source = { editable = "." }
|
|
239
257
|
dependencies = [
|
|
240
258
|
{ name = "matplotlib" },
|
|
@@ -242,6 +260,11 @@ dependencies = [
|
|
|
242
260
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
|
243
261
|
]
|
|
244
262
|
|
|
263
|
+
[package.dev-dependencies]
|
|
264
|
+
dev = [
|
|
265
|
+
{ name = "pytest" },
|
|
266
|
+
]
|
|
267
|
+
|
|
245
268
|
[package.metadata]
|
|
246
269
|
requires-dist = [
|
|
247
270
|
{ name = "matplotlib", specifier = ">=3.8" },
|
|
@@ -249,6 +272,9 @@ requires-dist = [
|
|
|
249
272
|
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" },
|
|
250
273
|
]
|
|
251
274
|
|
|
275
|
+
[package.metadata.requires-dev]
|
|
276
|
+
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
|
|
277
|
+
|
|
252
278
|
[[package]]
|
|
253
279
|
name = "matplotlib"
|
|
254
280
|
version = "3.10.7"
|
|
@@ -490,6 +516,24 @@ wheels = [
|
|
|
490
516
|
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
|
491
517
|
]
|
|
492
518
|
|
|
519
|
+
[[package]]
|
|
520
|
+
name = "pluggy"
|
|
521
|
+
version = "1.6.0"
|
|
522
|
+
source = { registry = "https://pypi.org/simple" }
|
|
523
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
524
|
+
wheels = [
|
|
525
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
[[package]]
|
|
529
|
+
name = "pygments"
|
|
530
|
+
version = "2.19.2"
|
|
531
|
+
source = { registry = "https://pypi.org/simple" }
|
|
532
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
533
|
+
wheels = [
|
|
534
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
535
|
+
]
|
|
536
|
+
|
|
493
537
|
[[package]]
|
|
494
538
|
name = "pyparsing"
|
|
495
539
|
version = "3.2.5"
|
|
@@ -499,6 +543,22 @@ wheels = [
|
|
|
499
543
|
{ url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" },
|
|
500
544
|
]
|
|
501
545
|
|
|
546
|
+
[[package]]
|
|
547
|
+
name = "pytest"
|
|
548
|
+
version = "9.0.2"
|
|
549
|
+
source = { registry = "https://pypi.org/simple" }
|
|
550
|
+
dependencies = [
|
|
551
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
552
|
+
{ name = "iniconfig" },
|
|
553
|
+
{ name = "packaging" },
|
|
554
|
+
{ name = "pluggy" },
|
|
555
|
+
{ name = "pygments" },
|
|
556
|
+
]
|
|
557
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
558
|
+
wheels = [
|
|
559
|
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
560
|
+
]
|
|
561
|
+
|
|
502
562
|
[[package]]
|
|
503
563
|
name = "python-dateutil"
|
|
504
564
|
version = "2.9.0.post0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{majoplot-0.1.2 → majoplot-0.1.3}/src/majoplot/infra/plotters/origin_utils/originlab_type_library.py
RENAMED
|
File without changes
|