sidescanpy.processing.bottom#

Nadir and first-bottom-return detection, and the masks built on them.

Along each ping, a sidescan record runs through three zones: a leading nadir-noise burst (transmit ring-down; some hardware zeros it before writing the file, some doesn’t), a quiet span the beam never illuminates, and the real bottom return. The functions here find those boundaries per (ping, channel) and blank or mask the data before them.

All detectors are vectorized over (ping, channel, sample) arrays and return (ping, channel) float sample indices (NaN = not found).

Contents

detect_first_bottom_return

Per-(ping, channel) sample index where the real bottom return starts.

smooth_boundary_across_pings

NaN-aware rolling median of a (ping, channel) boundary along pings.

detect_nadir_noise_extent

Per-(ping, channel) sample index where the leading nadir-noise burst ends.

blank_nadir_noise

Return a copy of ds with each ping's leading nadir-noise burst set to 0.

mask_before_sample

Copy of values (..., sample) with samples before boundary set to fill_value.

altitude_sample_index

First finite sample of range_var per (ping, channel), as float.

uniform_exclusion_boundary

Collapse a per-(ping, channel) boundary to one conservative value.

reject_late_boundaries

NaN out first-return boundaries far beyond their channel's usual one.

mask_nadir_gap

Return a copy of ds with the whole nadir gap set to NaN.

BOTTOM_TRACK_REPLACE

Accepted replace values of altitude_from_bottom_track().

altitude_from_bottom_track

Set altitude_m from the first bottom return (the "bottom_track" step).

sidescanpy.processing.bottom.detect_first_bottom_return(value_arr, *, smooth_window=10, threshold_fraction=0.2, min_run=10, min_sample=None, min_sample_buffer=0.0, peak_percentile=100.0, min_run_fraction=1.0)#

Per-(ping, channel) sample index where the real bottom return starts.

Smooths each ping with a smooth_window-sample rolling mean, then finds the first point where the smoothed value stays at or above threshold_fraction of that ping’s own peak for min_run consecutive samples. The threshold is relative per ping and channel, since amplitude scale varies by channel and survey.

This is not the altitude crossing (slant_range_m > altitude_m): a real beam’s near edge is tilted away from vertical, so the first bottom return lands further out than altitude alone predicts.

Parameters:
value_arrnp.ndarray

(ping, channel, sample) linear amplitude (not dB).

smooth_windowint, default 10
threshold_fractionfloat, default 0.2

Fraction of the ping’s peak smoothed value.

min_runint, default 10

Samples the rise must be sustained for.

min_samplearray-like or float, optional

Per-(ping, channel) or scalar floor. Samples before it are ignored for both the peak and the onset search. Normally the altitude sample: the bottom can’t return before it. NaN entries mean “no floor” for that row.

min_sample_bufferfloat, default 0.0

Added to min_sample before use, to step past residual near-nadir noise.

peak_percentilefloat, default 100.0

Percentile of the smoothed values used as the peak reference (100 = max). Lower it to stop one bright residual sample from setting the threshold.

min_run_fractionfloat, default 1.0

Fraction of the min_run samples that must be at or above the threshold (the onset sample itself always must be). Below 1 a long run tolerates gaps, such as shadows in a rough seabed return.

Returns:
np.ndarray

(ping, channel) float sample index, NaN where no sustained rise was found. Accurate to about smooth_window samples.

sidescanpy.processing.bottom.smooth_boundary_across_pings(boundary, *, window=15)#

NaN-aware rolling median of a (ping, channel) boundary along pings.

Per-ping detection is independent, so occasional pings jump far from their neighbours. A median (not mean) removes those without smearing real multi-ping trends.

Parameters:
boundarynp.ndarray

(ping, channel).

windowint, default 15

Window in pings, centred; an even value is bumped up by one.

Returns:
np.ndarray

Same shape. A ping whose whole window is NaN stays NaN.

sidescanpy.processing.bottom.detect_nadir_noise_extent(value_arr, *, search_window=100, smooth_window=5, threshold_fraction=0.2, min_run=10, margin_samples=0, min_extent_samples=0.0)#

Per-(ping, channel) sample index where the leading nadir-noise burst ends.

Mirror image of detect_first_bottom_return(): finds where an initially strong signal falls to a sustained low level. Some hardware (e.g. EdgeTech JSF) zeros this burst before writing the file; others (e.g. Klein SDF) leave it in, where it can fool the first-bottom-return detector.

Parameters:
value_arrnp.ndarray

(ping, channel, sample) linear amplitude.

search_windowint, default 100

Only the first search_window samples set the burst’s peak reference, so a bright bottom return further out can’t inflate the threshold.

smooth_window, threshold_fraction, min_run

As in detect_first_bottom_return(), applied to a fall below the threshold.

margin_samplesint, default 0

Added to each detected extent (NaN rows stay NaN).

min_extent_samplesfloat, default 0.0

Hard floor on the result, applied last; unlike margin_samples it also replaces NaN rows.

Returns:
np.ndarray

(ping, channel) float sample index. 0 where the leading samples are already quiet. NaN for pings too short to evaluate or with no sustained drop (unless min_extent_samples is set).

sidescanpy.processing.bottom.blank_nadir_noise(ds, *, value_vars=('backscatter_intensity',), detector_value_var='backscatter_intensity', search_window=100, smooth_window=5, threshold_fraction=0.2, min_run=10, margin_samples=0, min_extent_samples=0.0)#

Return a copy of ds with each ping’s leading nadir-noise burst set to 0.

Uses detect_nadir_noise_extent() on detector_value_var and zeros the same span in every value_vars entry present in ds (others are skipped). A no-op where the burst is already zero. Contrast mask_nadir_gap(), which NaNs the much larger span out to the first bottom return for display.

The remaining parameters are passed to detect_nadir_noise_extent().

sidescanpy.processing.bottom.mask_before_sample(values, boundary, *, fill_value=nan)#

Copy of values (..., sample) with samples before boundary set to fill_value.

boundary matches the leading dims of values. A NaN boundary entry masks nothing for that row. Returns float64.

sidescanpy.processing.bottom.altitude_sample_index(ds, range_var='ground_range_m')#

First finite sample of range_var per (ping, channel), as float.

ground_range_m goes finite where slant range first exceeds altitude, so this is altitude expressed in sample units. NaN where a row has no finite value.

sidescanpy.processing.bottom.uniform_exclusion_boundary(per_ping_boundary, percentile=90.0, *, warn_factor=3.0)#

Collapse a per-(ping, channel) boundary to one conservative value.

For aggregate range binning, a per-ping boundary biases the near bins: only the pings whose boundary is earliest (usually the shallowest, brightest ones) contribute there. A single cutoff keeps every bin’s contributing pings the same. A tenth of pings with a far detection moves a 90th-percentile cutoff for all of them, so a WARNING is logged when the value is more than warn_factor times the median boundary.

Parameters:
per_ping_boundarynp.ndarray

(ping, channel), e.g. a first-bottom-return array.

percentilefloat, default 90.0

Percentile across all finite entries.

warn_factorfloat, default 3.0
Returns:
np.ndarray

Same shape, filled with that one value; all-NaN if the input has no finite entries.

sidescanpy.processing.bottom.reject_late_boundaries(boundary, altitude_sample, *, factor=3.0)#

NaN out first-return boundaries far beyond their channel’s usual one.

With altitude, a channel’s usual boundary is the median ratio of boundary to altitude sample, times each ping’s altitude sample; without it, the channel’s median boundary. A boundary more than factor times the usual one is rejected. Only late boundaries are rejected: the failure this guards against is a bright far-range return setting the detection threshold, which pushes the boundary out, not in.

Parameters:
boundarynp.ndarray

(ping, channel) sample index, NaN where nothing was detected.

altitude_samplenp.ndarray or None

(ping, channel) altitude in samples (altitude_sample_index()), or None.

factorfloat, default 3.0
Returns:
boundarynp.ndarray

Copy with rejected entries set to NaN.

usualnp.ndarray

(ping, channel) usual boundary, NaN where it can’t be formed (a channel with no detection, or a ping with no altitude).

sidescanpy.processing.bottom.mask_nadir_gap(ds, *, value_vars=('backscatter_intensity',), detector_value_var='backscatter_intensity', range_var='ground_range_m', smooth_window=10, threshold_fraction=0.2, min_run=10, min_sample_buffer=0.0, peak_percentile=100.0, min_first_bottom_return_sample=None, boundary_smoothing_window=15, max_boundary_factor=3.0)#

Return a copy of ds with the whole nadir gap set to NaN.

Everything before detect_first_bottom_return()’s per-ping boundary (noise burst plus un-illuminated span) is masked in each value_vars entry present. For display and export copies; uses the exact per-ping boundary.

Parameters:
value_varstuple of str, default (“backscatter_intensity”,)

Variables to mask; absent ones are skipped.

detector_value_varstr, default “backscatter_intensity”

Variable the boundary is detected on (backscatter_intensity is read uncorrected, so masking doesn’t depend on TVG state).

range_varstr, default “ground_range_m”

If present, its altitude sample floors the detection (the bottom can’t return before altitude).

smooth_window, threshold_fraction, min_run, min_sample_buffer, peak_percentile

Passed to detect_first_bottom_return().

min_first_bottom_return_samplefloat, optional

Extra scalar floor, combined with the altitude floor by element-wise max.

boundary_smoothing_windowint, default 15

smooth_boundary_across_pings() window; <= 1 disables.

max_boundary_factorfloat or None, default 3.0

Reject a ping’s boundary when it lies more than this factor beyond its channel’s usual one (see reject_late_boundaries()), before smoothing. A bright far-range return can set a ping’s peak so high that the near seabed never reaches threshold_fraction of it; runs of such pings are too long for the rolling median alone. None disables.

Returns:
xr.Dataset

Copy of ds; unmodified if none of value_vars are present.

sidescanpy.processing.bottom.BOTTOM_TRACK_REPLACE = ('missing', 'all')#

Accepted replace values of altitude_from_bottom_track().

sidescanpy.processing.bottom.altitude_from_bottom_track(ds, *, replace='missing', max_diff_m=None, min_altitude_m=0.5, min_return_m=5.0, min_return_fraction=0.7, smooth_window=10, threshold_fraction=0.2, min_run=10, smooth_pings=1)#

Set altitude_m from the first bottom return (the "bottom_track" step).

Many recordings carry no usable altitude: no altimeter, no bottom lock, or a recorder that writes 0. Without it the geometric step cannot compute ground range (NaN altitude) or computes it as if the towfish sat on the seabed (altitude 0). This step measures altitude from the data instead: per ping and channel, the onset of the bottom return (detect_first_bottom_return() on the uncorrected amplitude, refined to the first sample reaching half the return level that follows it, since the detector’s rolling mean fires up to smooth_window samples early), the nearer of the two channels, converted to metres of slant range.

The first return comes from the beam’s near edge, which is tilted away from vertical, so on sloping or rough seabed the tracked value is the slant range to the nearest bottom point, at or slightly above the true altitude. Recorded altitude, when valid, is the better value; the default only fills pings that lack it.

Parameters:
dsxr.Dataset or SideScanSurvey

One frequency channel. Radiometric steps already applied are undone for detection only; backscatter_intensity is not changed. A SideScanSurvey is corrected frequency by frequency with the same arguments; a new survey is returned and the input is not modified.

replace{“missing”, “all”}, default “missing”

"missing": replace pings whose altitude_m is NaN or <= 0. "all": replace every ping with a detection.

max_diff_mfloat, optional

With "missing", also replace pings whose recorded altitude differs from the tracked one by more than this (a lost bottom lock that still writes a value).

min_altitude_mfloat, default 0.5

Detection ignores samples nearer than this.

min_return_mfloat, default 5.0

Slant distance the return must stay above threshold, converted to samples and used as the detector’s min_run when longer. A transmit burst (some recorders keep it; it can start after a quiet lead-in and be as bright as the seabed) lasts a metre or two; the seabed return lasts tens of metres.

min_return_fractionfloat, default 0.7

Fraction of that distance that must be above threshold (min_run_fraction of the detector), so shadows and gaps in a rough seabed return do not push the onset further out.

smooth_window, threshold_fraction, min_run

Passed to detect_first_bottom_return() (min_run in samples, raised to min_return_m when that is longer).

smooth_pingsint, default 1

Rolling median of the tracked altitude over this many pings (smooth_boundary_across_pings()); 1 = none. Smoothing looks across pings, so a chunked correct_file() run can differ from an unchunked one within smooth_pings // 2 pings of each chunk edge.

Returns:
xr.Dataset or SideScanSurvey

Same type as ds: ds with altitude_m replaced where selected and a "bottom_track" history record. Pings with no detection keep their recorded value. fathometer_reading_m is not changed.