Skip to content

Commit 37bfd81

Browse files
committed
Fix config import-time binding: read SONAR_MODEL/ENCODE_MODE/WAVEFORM_MODE at runtime
The from-import captured default values at import time, before load_survey_config() could override them. Now reads from config module at call time so survey-specific overrides take effect.
1 parent f88615a commit 37bfd81

15 files changed

Lines changed: 2093 additions & 515 deletions

File tree

oceanstream/echodata/__init__.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
# Config
103103
"EchodataConfig",
104104
"DenoiseConfig",
105+
"ShoalConfig",
105106
"MVBSConfig",
106107
"NASCConfig",
107108
# Pydantic denoise parameter models
@@ -128,6 +129,15 @@
128129
"SeabedDetectionResult",
129130
"get_bathymetry",
130131
"estimate_seabed_depth",
132+
# Shoal detection
133+
"detect_shoals",
134+
"detect_shoals_weill",
135+
"detect_shoals_echoview",
136+
"mask_shoals",
137+
"ShoalDetectionResult",
138+
# Multi-frequency analysis
139+
"db_difference",
140+
"FrequencyDifferencingResult",
131141
# Cloud Storage
132142
"get_azure_zarr_store",
133143
"save_echodata_to_azure",
@@ -160,10 +170,11 @@
160170
def __getattr__(name: str):
161171
"""Lazy import attributes to avoid loading heavy dependencies on import."""
162172
# Config classes
163-
if name in ("EchodataConfig", "DenoiseConfig", "MVBSConfig", "NASCConfig"):
173+
if name in ("EchodataConfig", "DenoiseConfig", "ShoalConfig", "MVBSConfig", "NASCConfig"):
164174
from oceanstream.echodata.config import (
165175
EchodataConfig,
166176
DenoiseConfig,
177+
ShoalConfig,
167178
MVBSConfig,
168179
NASCConfig,
169180
)
@@ -293,6 +304,31 @@ def __getattr__(name: str):
293304
)
294305
return locals()[name]
295306

307+
# Shoal detection functions
308+
if name in (
309+
"detect_shoals",
310+
"detect_shoals_weill",
311+
"detect_shoals_echoview",
312+
"mask_shoals",
313+
"ShoalDetectionResult",
314+
):
315+
from oceanstream.echodata.shoal import (
316+
detect_shoals,
317+
detect_shoals_weill,
318+
detect_shoals_echoview,
319+
mask_shoals,
320+
ShoalDetectionResult,
321+
)
322+
return locals()[name]
323+
324+
# Multi-frequency analysis functions
325+
if name in ("db_difference", "FrequencyDifferencingResult"):
326+
from oceanstream.echodata.multifrequency import (
327+
db_difference,
328+
FrequencyDifferencingResult,
329+
)
330+
return locals()[name]
331+
296332
# Denoise functions
297333
if name in ("apply_denoising", "build_noise_mask", "build_full_mask", "apply_noise_mask", "create_multichannel_mask"):
298334
from oceanstream.echodata.denoise import (

oceanstream/echodata/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,42 @@ def to_frequency_keyed_params(self, method: str) -> dict[str, dict]:
392392
return result
393393

394394

395+
@dataclass
396+
class ShoalConfig:
397+
"""Configuration for shoal/school detection."""
398+
399+
method: str = "weill"
400+
thr: float = -70.0
401+
# Weill-specific
402+
maxvgap: int = 5
403+
maxhgap: int = 0
404+
minvlen: int = 0
405+
minhlen: int = 0
406+
# Echoview-specific
407+
mincan: tuple[float, float] = (3.0, 10.0)
408+
maxlink: tuple[float, float] = (3.0, 15.0)
409+
minsho: tuple[float, float] = (3.0, 15.0)
410+
411+
def to_kwargs(self) -> dict:
412+
"""Return kwargs for detect_shoals()."""
413+
if self.method == "weill":
414+
return {
415+
"method": "weill",
416+
"thr": self.thr,
417+
"maxvgap": self.maxvgap,
418+
"maxhgap": self.maxhgap,
419+
"minvlen": self.minvlen,
420+
"minhlen": self.minhlen,
421+
}
422+
return {
423+
"method": "echoview",
424+
"thr": self.thr,
425+
"mincan": self.mincan,
426+
"maxlink": self.maxlink,
427+
"minsho": self.minsho,
428+
}
429+
430+
395431
@dataclass
396432
class MVBSConfig:
397433
"""Configuration for MVBS (Mean Volume Backscattering Strength) computation."""
@@ -449,6 +485,7 @@ class EchodataConfig:
449485

450486
# Sub-configurations
451487
denoise: DenoiseConfig = field(default_factory=DenoiseConfig)
488+
shoal: ShoalConfig = field(default_factory=ShoalConfig)
452489
mvbs: MVBSConfig = field(default_factory=MVBSConfig)
453490
nasc: NASCConfig = field(default_factory=NASCConfig)
454491

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Multi-frequency analysis module for echosounder data.
2+
3+
Provides frequency-differencing algorithms for classifying acoustic targets
4+
based on their frequency response. The dB-difference method identifies targets
5+
by comparing Sv at two frequencies and masking regions where the difference
6+
falls within a specified threshold range.
7+
8+
Based on algorithms from:
9+
- echopy library (Alejandro Ariza et al.)
10+
- Kloser et al. (2002) — Species identification using dB differencing
11+
12+
Example usage:
13+
>>> from oceanstream.echodata.multifrequency import db_difference
14+
>>> mask = db_difference(sv_dataset, freq_low="38000", freq_high="120000", thr=(-12, -2))
15+
"""
16+
17+
from .frequency_differencing import (
18+
db_difference,
19+
FrequencyDifferencingResult,
20+
)
21+
22+
__all__ = [
23+
"db_difference",
24+
"FrequencyDifferencingResult",
25+
]
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""
2+
Multi-frequency dB-differencing for acoustic target classification.
3+
4+
The dB-difference method computes Sv_1 − Sv_2 for a pair of frequencies
5+
and masks regions where the difference falls within a specified range.
6+
This is commonly used to discriminate species groups (e.g. krill vs fish)
7+
based on their known frequency response.
8+
9+
Ported from echopy (Ariza et al., 2020) with bug fixes and xarray support.
10+
11+
References:
12+
- Kloser et al. (2002), ICES Journal of Marine Science
13+
- De Robertis & Higginbottom (2007)
14+
- echopy: https://github.com/open-ocean-sounding/echopy
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from dataclasses import dataclass
21+
from typing import Optional
22+
23+
import numpy as np
24+
import xarray as xr
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
@dataclass
30+
class FrequencyDifferencingResult:
31+
"""Result of frequency differencing analysis.
32+
33+
Attributes:
34+
mask: Boolean DataArray where True = within threshold range.
35+
difference: DataArray of Sv_low − Sv_high values (dB).
36+
freq_low: Channel/frequency used as minuend.
37+
freq_high: Channel/frequency used as subtrahend.
38+
threshold: (low, high) dB threshold tuple applied.
39+
pixels_in_range: Number of pixels within threshold.
40+
pixels_total: Total non-NaN pixels.
41+
fraction_in_range: Fraction of pixels within threshold.
42+
"""
43+
44+
mask: xr.DataArray
45+
difference: xr.DataArray
46+
freq_low: str
47+
freq_high: str
48+
threshold: tuple[float, float]
49+
pixels_in_range: int
50+
pixels_total: int
51+
fraction_in_range: float
52+
53+
54+
def _resolve_channel(
55+
ds: xr.Dataset,
56+
channel: str,
57+
) -> str:
58+
"""Resolve a channel identifier to a matching channel label in the dataset.
59+
60+
Supports exact match or substring match (e.g. "38" matches
61+
"WBT 742057-15 ES38-18").
62+
63+
Args:
64+
ds: Dataset with a ``channel`` dimension.
65+
channel: Exact or partial channel label.
66+
67+
Returns:
68+
Matched channel label string.
69+
70+
Raises:
71+
ValueError: If the channel cannot be found.
72+
"""
73+
channels = [str(c) for c in ds.channel.values]
74+
75+
# Exact match
76+
if channel in channels:
77+
return channel
78+
79+
# Substring/frequency match
80+
matches = [c for c in channels if channel in c]
81+
if matches:
82+
return matches[0]
83+
84+
raise ValueError(
85+
f"Channel '{channel}' not found. Available: {channels}"
86+
)
87+
88+
89+
def db_difference(
90+
ds: xr.Dataset,
91+
freq_low: str,
92+
freq_high: str,
93+
thr: tuple[float, float] = (-12.0, -2.0),
94+
sv_var: str = "Sv",
95+
) -> FrequencyDifferencingResult:
96+
"""Compute dB difference between two frequencies and mask within threshold.
97+
98+
Calculates ``Sv(freq_low) − Sv(freq_high)`` and returns a boolean mask
99+
where the difference falls within the inclusive range ``[thr[0], thr[1]]``.
100+
101+
Args:
102+
ds: xarray Dataset with Sv data and a ``channel`` dimension.
103+
freq_low: Channel label (or substring like ``"38000"``) for the
104+
minuend frequency (lower frequency typically).
105+
freq_high: Channel label (or substring like ``"120000"``) for the
106+
subtrahend frequency (higher frequency typically).
107+
thr: Tuple of ``(lower_bound, upper_bound)`` in dB. Pixels where
108+
``thr[0] <= (Sv_low - Sv_high) <= thr[1]`` are masked True.
109+
Default ``(-12, -2)`` is commonly used for krill identification.
110+
sv_var: Name of the Sv variable in the dataset. Default ``"Sv"``.
111+
112+
Returns:
113+
FrequencyDifferencingResult with mask, difference array, and diagnostics.
114+
115+
Raises:
116+
ValueError: If the dataset has no ``channel`` dimension, or if the
117+
requested channels cannot be found, or if ``thr[0] > thr[1]``.
118+
119+
Example:
120+
>>> result = db_difference(sv_ds, "38000", "120000", thr=(-12, -2))
121+
>>> masked_sv = sv_ds[sv_var].where(~result.mask)
122+
"""
123+
if thr[0] > thr[1]:
124+
raise ValueError(
125+
f"Lower threshold ({thr[0]}) must be <= upper threshold ({thr[1]}). "
126+
f"Supply thr as (lower_bound, upper_bound)."
127+
)
128+
129+
if "channel" not in ds.dims:
130+
raise ValueError(
131+
"Dataset must have a 'channel' dimension for multi-frequency analysis. "
132+
"Got dimensions: " + str(list(ds.dims))
133+
)
134+
135+
# Resolve channel labels
136+
ch_low = _resolve_channel(ds, freq_low)
137+
ch_high = _resolve_channel(ds, freq_high)
138+
139+
if ch_low == ch_high:
140+
raise ValueError(
141+
f"freq_low and freq_high resolved to the same channel: '{ch_low}'. "
142+
f"Provide two distinct frequency channels."
143+
)
144+
145+
logger.info(
146+
"Computing dB difference: %s (low) − %s (high), threshold=[%.1f, %.1f]",
147+
ch_low,
148+
ch_high,
149+
thr[0],
150+
thr[1],
151+
)
152+
153+
# Extract single-channel Sv arrays
154+
sv_low = ds[sv_var].sel(channel=ch_low)
155+
sv_high = ds[sv_var].sel(channel=ch_high)
156+
157+
# Compute difference
158+
difference = sv_low - sv_high
159+
160+
# Build mask: True where difference is within [thr[0], thr[1]]
161+
# NOTE: echopy original had a bug where the second condition overwrote
162+
# the first. We fix this by properly combining both conditions.
163+
mask_low = difference >= thr[0]
164+
mask_high = difference <= thr[1]
165+
mask = mask_low & mask_high
166+
167+
# Diagnostics
168+
valid = np.isfinite(difference.values)
169+
pixels_total = int(np.sum(valid))
170+
pixels_in_range = int(np.sum(mask.values & valid))
171+
fraction = pixels_in_range / pixels_total if pixels_total > 0 else 0.0
172+
173+
logger.info(
174+
"dB difference: %d/%d pixels (%.1f%%) within threshold",
175+
pixels_in_range,
176+
pixels_total,
177+
fraction * 100,
178+
)
179+
180+
return FrequencyDifferencingResult(
181+
mask=mask,
182+
difference=difference,
183+
freq_low=ch_low,
184+
freq_high=ch_high,
185+
threshold=thr,
186+
pixels_in_range=pixels_in_range,
187+
pixels_total=pixels_total,
188+
fraction_in_range=fraction,
189+
)

0 commit comments

Comments
 (0)