Skip to content

Commit 8105c0e

Browse files
committed
test(echodata): add tests for ECS, local storage, cal_params; fix stale tests
- New test_ecs.py: 18 tests covering _normalize_ecs_text header rewriting, _coerce_to_path input handling, parse_ecs with mocked echopype, build_cal_params_from_ecs single/dual-pulse and error cases - New TestLocalStorage in test_storage.py: 14 tests for use_local_storage toggle, env var fallback, get_azure_zarr_store/get_zarr_store_uri in local mode, save+open roundtrip, ensure_container_exists, upload_file_to_blob, _LocalListFS, module exports - New cal_params forwarding tests in test_compute.py: verify compute_sv_from_echodata forwards cal_params/env_params to compute_Sv and omits None values from kwargs - Fix test_calibrate.py: remove tests for deleted parse_ecs_file and parse_json_calibration; add tests for .ecs ValueError, JSON loading, TypeError on bad input, auto-detect failure - Fix test_config.py: update attenuation_threshold assertion from 0.8 to 6.0 matching current DenoiseConfig defaults (Ryan et al. 2015) - Fix test_compute.py: replace module-level pytest.importorskip(echopype) with class-level @skipif so non-echopype tests are no longer skipped
1 parent 8ddb31f commit 8105c0e

5 files changed

Lines changed: 669 additions & 46 deletions

File tree

oceanstream/tests/unit/echodata/test_calibrate.py

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -148,51 +148,52 @@ def test_invalid_frequency_type(self):
148148

149149

150150
class TestECSCalibration:
151-
"""Tests for ECS (Simrad Calibration Software) format."""
151+
"""Tests for ECS calibration format handling.
152+
153+
The old ``parse_ecs_file`` / ``parse_json_calibration`` functions were
154+
removed from ``calibration.py``. ECS parsing now lives in
155+
``oceanstream.echodata.calibrate.ecs`` (tested in ``test_ecs.py``).
156+
"""
157+
158+
def test_load_calibration_ecs_raises(self, tmp_path: Path):
159+
"""load_calibration should raise ValueError for .ecs files.
160+
161+
Users are directed to ``ecs.parse_ecs()`` instead.
162+
"""
163+
from oceanstream.echodata.calibrate.calibration import load_calibration
152164

153-
def test_parse_ecs_file(self, tmp_path: Path):
154-
"""Should parse ECS XML format."""
155-
from oceanstream.echodata.calibrate.calibration import parse_ecs_file
156-
157-
# Create minimal ECS file
158-
ecs_content = """<?xml version="1.0" encoding="utf-8"?>
159-
<CalibrationResults>
160-
<Calibration Frequency="38000">
161-
<Gain>25.5</Gain>
162-
<SaCorrection>-0.5</SaCorrection>
163-
</Calibration>
164-
</CalibrationResults>
165-
"""
166165
ecs_file = tmp_path / "calibration.ecs"
167-
ecs_file.write_text(ecs_content)
168-
169-
try:
170-
params = parse_ecs_file(ecs_file)
171-
assert 38000 in params
172-
except (NotImplementedError, AttributeError):
173-
# Function may not be implemented yet
174-
pass
166+
ecs_file.write_text("SourceCal T1\n Frequency = 38000\n")
175167

176-
def test_parse_json_calibration(self, tmp_path: Path):
177-
"""Should parse JSON calibration format."""
178-
from oceanstream.echodata.calibrate.calibration import parse_json_calibration
179-
168+
with pytest.raises(ValueError, match=r"\.ecs"):
169+
load_calibration(ecs_file)
170+
171+
def test_load_calibration_json(self, tmp_path: Path):
172+
"""load_calibration should still load JSON calibration files."""
180173
import json
181-
cal_data = {
182-
"frequencies": {
183-
"38000": {
184-
"gain": 25.5,
185-
"sa_correction": -0.5,
186-
"equivalent_beam_angle": -20.7,
187-
}
188-
}
189-
}
190-
174+
from oceanstream.echodata.calibrate.calibration import load_calibration
175+
176+
cal_data = {"38000": {"gain": 25.5, "sa_correction": -0.5}}
191177
json_file = tmp_path / "calibration.json"
192178
json_file.write_text(json.dumps(cal_data))
193-
194-
try:
195-
params = parse_json_calibration(json_file)
196-
assert 38000 in params or "38000" in params
197-
except (NotImplementedError, AttributeError):
198-
pass
179+
180+
params = load_calibration(json_file)
181+
assert isinstance(params, dict)
182+
assert "38000" in params
183+
184+
def test_apply_calibration_bad_type_raises(self):
185+
"""apply_calibration should raise TypeError for non-Path/non-dict."""
186+
from oceanstream.echodata.calibrate.calibration import apply_calibration
187+
188+
with pytest.raises(TypeError, match="must be a Path or dict"):
189+
apply_calibration(MagicMock(), 42)
190+
191+
def test_apply_calibration_auto_detect_failure(self):
192+
"""apply_calibration should raise when provider can't be inferred."""
193+
from oceanstream.echodata.calibrate.calibration import apply_calibration
194+
195+
# Dict without Saildrone-specific keys
196+
generic_cal = {"38kHz": {"gain": 25.0}}
197+
198+
with pytest.raises(ValueError, match="Cannot infer calibration provider"):
199+
apply_calibration(MagicMock(), generic_cal, provider="auto")

oceanstream/tests/unit/echodata/test_compute.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,70 @@ def test_enrich_sv_with_location_exists(self):
100100
assert "sv_dataset" in params
101101
assert "campaign_dir" in params or "campaign_id" in params
102102

103+
def test_compute_sv_from_echodata_forwards_cal_params(self):
104+
"""compute_sv_from_echodata should forward cal_params to compute_Sv."""
105+
mock_compute_Sv = MagicMock()
106+
mock_add_depth = MagicMock()
107+
mock_add_location = MagicMock()
108+
109+
sentinel_ds = xr.Dataset({"Sv": (["x"], [1, 2, 3])})
110+
mock_compute_Sv.return_value = sentinel_ds
111+
mock_add_depth.return_value = sentinel_ds
112+
mock_add_location.return_value = sentinel_ds
113+
114+
mock_ep = MagicMock()
115+
mock_ep.calibrate.compute_Sv = mock_compute_Sv
116+
mock_ep.consolidate.add_depth = mock_add_depth
117+
mock_ep.consolidate.add_location = mock_add_location
118+
119+
mock_ed = MagicMock()
120+
mock_ed.sonar_model = "EK80"
121+
122+
cal = {"gain": [25.0]}
123+
env = {"speed_of_sound": 1500}
124+
125+
with patch.dict("sys.modules", {"echopype": mock_ep}):
126+
# Re-import so the local `import echopype as ep` picks up the mock
127+
import importlib
128+
import oceanstream.echodata.compute.sv as sv_mod
129+
importlib.reload(sv_mod)
130+
131+
sv_mod.compute_sv_from_echodata(mock_ed, env_params=env, cal_params=cal)
132+
133+
call_kwargs = mock_compute_Sv.call_args.kwargs
134+
assert call_kwargs["cal_params"] == cal
135+
assert call_kwargs["env_params"] == env
136+
137+
def test_compute_sv_from_echodata_omits_none_cal_params(self):
138+
"""cal_params=None should not be passed as a kwarg."""
139+
mock_compute_Sv = MagicMock()
140+
mock_add_depth = MagicMock()
141+
mock_add_location = MagicMock()
142+
143+
sentinel_ds = xr.Dataset({"Sv": (["x"], [1, 2, 3])})
144+
mock_compute_Sv.return_value = sentinel_ds
145+
mock_add_depth.return_value = sentinel_ds
146+
mock_add_location.return_value = sentinel_ds
147+
148+
mock_ep = MagicMock()
149+
mock_ep.calibrate.compute_Sv = mock_compute_Sv
150+
mock_ep.consolidate.add_depth = mock_add_depth
151+
mock_ep.consolidate.add_location = mock_add_location
152+
153+
mock_ed = MagicMock()
154+
mock_ed.sonar_model = "EK80"
155+
156+
with patch.dict("sys.modules", {"echopype": mock_ep}):
157+
import importlib
158+
import oceanstream.echodata.compute.sv as sv_mod
159+
importlib.reload(sv_mod)
160+
161+
sv_mod.compute_sv_from_echodata(mock_ed, cal_params=None, env_params=None)
162+
163+
call_kwargs = mock_compute_Sv.call_args.kwargs
164+
assert "cal_params" not in call_kwargs
165+
assert "env_params" not in call_kwargs
166+
103167

104168
class TestComputeMVBS:
105169
"""Tests for MVBS computation."""
@@ -308,10 +372,19 @@ def test_zarr_roundtrip(self, tmp_path: Path):
308372

309373
# ──────────────────────────────────────────────────────────────────────────
310374
# Strict xarray-2026 regression tests (no exception swallowing)
375+
# These require echopype + dask.array; skipped individually when missing.
311376
# ──────────────────────────────────────────────────────────────────────────
312377

313-
echopype = pytest.importorskip("echopype")
314-
dask_array = pytest.importorskip("dask.array")
378+
_has_echopype = bool(pytest.importorskip.__module__) # always True, just a placeholder
379+
try:
380+
import echopype # noqa: F401
381+
import dask.array # noqa: F401
382+
except ImportError:
383+
_has_echopype = False
384+
385+
_skip_no_echopype = pytest.mark.skipif(
386+
not _has_echopype, reason="echopype + dask.array required for regression tests"
387+
)
315388

316389

317390
def _make_sv_dataset(
@@ -371,6 +444,7 @@ def _make_sv_dataset(
371444
return ds
372445

373446

447+
@_skip_no_echopype
374448
class TestMVBSXarray2026:
375449
"""Strict MVBS regression tests — no broad except blocks."""
376450

@@ -414,6 +488,7 @@ def test_compute_mvbs_bin_count(self):
414488
assert mvbs.sizes["echo_range"] in (5, 6)
415489

416490

491+
@_skip_no_echopype
417492
class TestNASCXarray2026:
418493
"""Strict NASC regression tests — no broad except blocks."""
419494

oceanstream/tests/unit/echodata/test_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,13 @@ def test_impulse_defaults(self):
101101
assert config.impulse_num_lags == 3
102102

103103
def test_attenuation_defaults(self):
104-
"""Attenuation detection defaults."""
104+
"""Attenuation detection defaults (Ryan et al. 2015 values)."""
105105
config = DenoiseConfig()
106106

107-
assert config.attenuation_threshold == 0.8
107+
assert config.attenuation_threshold == 6.0
108+
assert config.attenuation_upper_limit == 180.0
109+
assert config.attenuation_lower_limit == 280.0
110+
assert config.attenuation_side_pings == 15
108111

109112
def test_custom_methods(self):
110113
"""Config should accept custom method list."""

0 commit comments

Comments
 (0)