Skip to content

Commit c84f9f6

Browse files
committed
tests: add direct coverage for biot_savart_3d, sim3d, moments, fieldline_classify
- test_biot_savart_3d.py: 16 tests for Coil3D/Grid3D/BField3D + compute_bfield_3d (linearity, zero-current, shape, mirror_ratio, on_axis) - test_sim3d.py: 9 tests for Sim3DConfig, Sim3DResult, _make_grid_params, run_3d_simulation - test_moments.py: 8 tests for MomentData dataclass + numpy bin-index logic + error path - test_fieldline_classify.py: 8 tests for ParticleClassification, _navigate_openpmd, error path - test_scan.py: +1 test for ScanResult.plot_pareto() (spec §6.2) Total: 1300 tests (was 1258). All non-Streamlit source modules now have direct imports in tests.
1 parent 40683d1 commit c84f9f6

6 files changed

Lines changed: 428 additions & 1 deletion

File tree

docs_src/changelog.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@
4242
`helicon.Metrics`, `helicon.DetachmentMetrics`)
4343
- Added `tests/unit/test_mission_cli.py`: 14 tests for throttle-map + mission + regression CLI
4444
- Added `tests/unit/test_diagnostics.py`: 9 tests for `DiagnosticSchedule` / `resolve_schedule`
45-
- Total: 1258 tests (was 1198)
45+
- Added `tests/unit/test_moments.py`: 8 tests for `MomentData` dataclass + bin-index logic
46+
- Added `tests/unit/test_fieldline_classify.py`: 8 tests for `ParticleClassification`, `_navigate_openpmd`
47+
- Added `tests/unit/test_biot_savart_3d.py`: 16 tests for 3D Biot-Savart solver (`Coil3D`, `Grid3D`, `BField3D`, `compute_bfield_3d`)
48+
- Added `tests/unit/test_sim3d.py`: 9 tests for `Sim3DConfig`, `Sim3DResult`, `_make_grid_params`, `run_3d_simulation`
49+
- Added `ScanResult.plot_pareto()` test in `test_scan.py` (spec §6.2)
50+
- Total: 1300 tests (was 1258)
4651

4752
---
4853

tests/unit/test_biot_savart_3d.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for helicon.fields.biot_savart_3d — 3D Biot-Savart solver."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
8+
from helicon.fields.biot_savart_3d import BField3D, Coil3D, Grid3D, compute_bfield_3d
9+
10+
11+
def _small_grid() -> Grid3D:
12+
"""A small 8×8×16 grid suitable for fast unit tests."""
13+
return Grid3D(
14+
x_min=-0.2, x_max=0.2,
15+
y_min=-0.2, y_max=0.2,
16+
z_min=-0.3, z_max=1.5,
17+
nx=8, ny=8, nz=16,
18+
)
19+
20+
21+
def _single_coil(z: float = 0.0, r: float = 0.1, I: float = 10000.0) -> Coil3D:
22+
return Coil3D(z=z, r=r, I=I)
23+
24+
25+
class TestCoil3DDataclass:
26+
def test_frozen(self):
27+
coil = _single_coil()
28+
with pytest.raises(AttributeError):
29+
coil.I = 999 # type: ignore[misc]
30+
31+
def test_fields(self):
32+
coil = Coil3D(z=0.5, r=0.15, I=5000.0)
33+
assert coil.z == 0.5
34+
assert coil.r == 0.15
35+
assert coil.I == 5000.0
36+
37+
38+
class TestGrid3DDataclass:
39+
def test_fields(self):
40+
g = _small_grid()
41+
assert g.nx == 8
42+
assert g.ny == 8
43+
assert g.nz == 16
44+
45+
def test_frozen(self):
46+
g = _small_grid()
47+
with pytest.raises(AttributeError):
48+
g.nx = 32 # type: ignore[misc]
49+
50+
51+
class TestComputeBfield3D:
52+
def test_returns_bfield3d(self):
53+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
54+
assert isinstance(bfield, BField3D)
55+
56+
def test_output_shape(self):
57+
grid = _small_grid()
58+
bfield = compute_bfield_3d([_single_coil()], grid, backend="numpy")
59+
assert bfield.Bx.shape == (grid.nx, grid.ny, grid.nz)
60+
assert bfield.By.shape == (grid.nx, grid.ny, grid.nz)
61+
assert bfield.Bz.shape == (grid.nx, grid.ny, grid.nz)
62+
63+
def test_coordinate_arrays(self):
64+
grid = _small_grid()
65+
bfield = compute_bfield_3d([_single_coil()], grid, backend="numpy")
66+
assert bfield.x.shape == (grid.nx,)
67+
assert bfield.y.shape == (grid.ny,)
68+
assert bfield.z.shape == (grid.nz,)
69+
70+
def test_field_nonzero(self):
71+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
72+
assert np.any(bfield.Bz != 0.0)
73+
74+
def test_zero_current_gives_zero_field(self):
75+
coil = Coil3D(z=0.0, r=0.1, I=0.0)
76+
bfield = compute_bfield_3d([coil], _small_grid(), backend="numpy")
77+
assert np.allclose(bfield.Bx, 0.0)
78+
assert np.allclose(bfield.By, 0.0)
79+
assert np.allclose(bfield.Bz, 0.0)
80+
81+
def test_backend_stored(self):
82+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
83+
assert bfield.backend == "numpy"
84+
85+
def test_coils_stored(self):
86+
coil = _single_coil()
87+
bfield = compute_bfield_3d([coil], _small_grid(), backend="numpy")
88+
assert len(bfield.coils) == 1
89+
assert bfield.coils[0] is coil
90+
91+
def test_linearity_two_coils(self):
92+
"""Field from 2 identical coils should be ~2× field from 1 coil."""
93+
grid = _small_grid()
94+
coil = _single_coil(I=1000.0)
95+
b1 = compute_bfield_3d([coil], grid, backend="numpy")
96+
b2 = compute_bfield_3d([coil, coil], grid, backend="numpy")
97+
np.testing.assert_allclose(b2.Bz, 2.0 * b1.Bz, rtol=1e-10)
98+
99+
100+
class TestBField3DProperties:
101+
def test_bmag_shape(self):
102+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
103+
assert bfield.Bmag.shape == bfield.Bx.shape
104+
105+
def test_bmag_nonnegative(self):
106+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
107+
assert np.all(bfield.Bmag >= 0.0)
108+
109+
def test_on_axis_shape(self):
110+
grid = _small_grid()
111+
bfield = compute_bfield_3d([_single_coil()], grid, backend="numpy")
112+
assert bfield.on_axis().shape == (grid.nz,)
113+
114+
def test_mirror_ratio_positive(self):
115+
bfield = compute_bfield_3d([_single_coil()], _small_grid(), backend="numpy")
116+
assert bfield.mirror_ratio() >= 1.0
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for helicon.postprocess.fieldline_classify — particle topology classification."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
8+
from helicon.postprocess.fieldline_classify import ParticleClassification, _navigate_openpmd
9+
10+
11+
class TestParticleClassificationDataclass:
12+
def test_fields_accessible(self):
13+
n = 100
14+
rng = np.random.default_rng(7)
15+
labels = rng.integers(0, 3, size=n).astype(np.int32)
16+
pc = ParticleClassification(
17+
species="D_plus",
18+
n_open=int(np.sum(labels == 0)),
19+
n_closed=int(np.sum(labels == 1)),
20+
n_separatrix=int(np.sum(labels == 2)),
21+
n_total=n,
22+
labels=labels,
23+
positions_r=rng.uniform(0, 0.1, n),
24+
positions_z=rng.uniform(-0.3, 1.0, n),
25+
)
26+
assert pc.n_total == n
27+
assert pc.n_open + pc.n_closed + pc.n_separatrix == n
28+
assert pc.labels.dtype == np.int32
29+
assert pc.positions_r.shape == (n,)
30+
assert pc.positions_z.shape == (n,)
31+
32+
def test_all_open(self):
33+
n = 50
34+
pc = ParticleClassification(
35+
species="He_2plus",
36+
n_open=50,
37+
n_closed=0,
38+
n_separatrix=0,
39+
n_total=50,
40+
labels=np.zeros(n, dtype=np.int32),
41+
positions_r=np.ones(n) * 0.05,
42+
positions_z=np.linspace(0, 1, n),
43+
)
44+
assert pc.n_open == n
45+
assert np.all(pc.labels == 0)
46+
47+
def test_species_name_preserved(self):
48+
pc = ParticleClassification(
49+
species="alpha",
50+
n_open=10, n_closed=5, n_separatrix=1, n_total=16,
51+
labels=np.zeros(16, dtype=np.int32),
52+
positions_r=np.zeros(16),
53+
positions_z=np.zeros(16),
54+
)
55+
assert pc.species == "alpha"
56+
57+
58+
class TestNavigateOpenPMD:
59+
def test_flat_dict_returned_as_is(self):
60+
"""If no 'data' key, return the dict unchanged."""
61+
d = {"particles": "mock"}
62+
result = _navigate_openpmd(d)
63+
assert result is d
64+
65+
def test_navigates_data_key(self):
66+
"""With 'data' key, returns the last iteration's subtree."""
67+
d = {"data": {"100": {"particles": "step100"}, "200": {"particles": "step200"}}}
68+
result = _navigate_openpmd(d)
69+
assert result == {"particles": "step200"}
70+
71+
def test_navigates_single_iteration(self):
72+
d = {"data": {"0": {"particles": "step0"}}}
73+
result = _navigate_openpmd(d)
74+
assert result == {"particles": "step0"}
75+
76+
def test_sorts_iterations_numerically(self):
77+
"""Iterations must be sorted as integers, not lexicographically."""
78+
d = {"data": {"9": {"step": 9}, "10": {"step": 10}, "2": {"step": 2}}}
79+
result = _navigate_openpmd(d)
80+
assert result == {"step": 10}
81+
82+
83+
class TestClassifyParticlesErrors:
84+
def test_raises_on_missing_h5_files(self, tmp_path):
85+
"""classify_particles raises FileNotFoundError if no HDF5 files present."""
86+
import helicon
87+
from helicon.postprocess.fieldline_classify import classify_particles
88+
89+
config = helicon.Config.from_preset("sunbird")
90+
bfield = helicon.fields.compute(config.nozzle)
91+
with pytest.raises(FileNotFoundError, match="No HDF5 files"):
92+
classify_particles(tmp_path, bfield)

tests/unit/test_moments.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Tests for helicon.postprocess.moments — moment computation from particle data."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
8+
from helicon.postprocess.moments import MomentData
9+
10+
11+
class TestMomentDataclass:
12+
def test_fields_accessible(self):
13+
nr, nz = 8, 16
14+
m = MomentData(
15+
species="D_plus",
16+
density=np.zeros((nr, nz)),
17+
vr_mean=np.zeros((nr, nz)),
18+
vz_mean=np.zeros((nr, nz)),
19+
pressure_rr=np.zeros((nr, nz)),
20+
pressure_zz=np.zeros((nr, nz)),
21+
r_grid=np.linspace(0, 0.1, nr),
22+
z_grid=np.linspace(-0.3, 1.0, nz),
23+
)
24+
assert m.species == "D_plus"
25+
assert m.density.shape == (nr, nz)
26+
assert m.vr_mean.shape == (nr, nz)
27+
assert m.vz_mean.shape == (nr, nz)
28+
assert m.pressure_rr.shape == (nr, nz)
29+
assert m.pressure_zz.shape == (nr, nz)
30+
assert m.r_grid.shape == (nr,)
31+
assert m.z_grid.shape == (nz,)
32+
33+
def test_with_nonzero_values(self):
34+
nr, nz = 4, 8
35+
density = np.random.default_rng(42).random((nr, nz)) * 1e18
36+
m = MomentData(
37+
species="He_2plus",
38+
density=density,
39+
vr_mean=np.zeros((nr, nz)),
40+
vz_mean=np.ones((nr, nz)) * 50000.0,
41+
pressure_rr=np.zeros((nr, nz)),
42+
pressure_zz=np.zeros((nr, nz)),
43+
r_grid=np.linspace(0, 0.2, nr),
44+
z_grid=np.linspace(0.0, 2.0, nz),
45+
)
46+
assert np.all(m.density >= 0)
47+
assert np.allclose(m.vz_mean, 50000.0)
48+
49+
50+
class TestBinIndexNumpyPath:
51+
"""Test the numpy bin-index logic that underpins moment binning."""
52+
53+
def _bin_indices(self, pos, edges, n_bins):
54+
"""Replicate the numpy path from compute_moments."""
55+
return np.clip(np.searchsorted(edges, pos) - 1, 0, n_bins - 1)
56+
57+
def test_center_of_first_bin(self):
58+
edges = np.linspace(0.0, 1.0, 11) # 10 bins
59+
pos = np.array([0.05]) # midpoint of first bin
60+
idx = self._bin_indices(pos, edges, 10)
61+
assert idx[0] == 0
62+
63+
def test_center_of_last_bin(self):
64+
edges = np.linspace(0.0, 1.0, 11)
65+
pos = np.array([0.95]) # midpoint of last bin
66+
idx = self._bin_indices(pos, edges, 10)
67+
assert idx[0] == 9
68+
69+
def test_below_minimum_clips_to_zero(self):
70+
edges = np.linspace(0.0, 1.0, 11)
71+
pos = np.array([-0.5])
72+
idx = self._bin_indices(pos, edges, 10)
73+
assert idx[0] == 0
74+
75+
def test_above_maximum_clips_to_last(self):
76+
edges = np.linspace(0.0, 1.0, 11)
77+
pos = np.array([2.0])
78+
idx = self._bin_indices(pos, edges, 10)
79+
assert idx[0] == 9
80+
81+
def test_all_particles_binned_in_range(self):
82+
rng = np.random.default_rng(0)
83+
n = 1000
84+
edges = np.linspace(0.0, 5.0, 101) # 100 bins
85+
pos = rng.uniform(0.0, 5.0, size=n)
86+
idx = self._bin_indices(pos, edges, 100)
87+
assert np.all(idx >= 0)
88+
assert np.all(idx < 100)
89+
90+
91+
class TestComputeMomentsErrors:
92+
def test_raises_on_missing_h5_files(self, tmp_path):
93+
"""compute_moments raises FileNotFoundError if no HDF5 files present."""
94+
from helicon.postprocess.moments import compute_moments
95+
96+
with pytest.raises(FileNotFoundError, match="No HDF5 files"):
97+
compute_moments(tmp_path)

tests/unit/test_scan.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,10 @@ def test_to_json_summary_each_result_has_params(self, base_config, tmp_path):
337337
assert "params" in entry
338338
assert "coils.0.I" in entry["params"]
339339
assert "screened_out" in entry
340+
341+
def test_plot_pareto_returns_tuple(self, base_config):
342+
"""plot_pareto() returns (fig, ax) or (None, None) without crashing."""
343+
result = self._make_result(base_config)
344+
out = result.plot_pareto()
345+
assert isinstance(out, tuple)
346+
assert len(out) == 2

0 commit comments

Comments
 (0)