sidescanpy.targets.correlate#

Resolve target positions and clock offsets against converted .ss.h5 files.

Catalog formats that store no absolute position (the Klein .tgt reader in sidescanpy.io.sdf.targets) are located here against per-ping times of converted surveys. For Klein catalogs use resolve_targets_by_catalog_ping(), which matches the catalog’s own ping time and number exactly; the pick-timestamp functions (with optional constant clock-offset calibration) match the later save time and are only a fallback for catalogs without those fields. Also provides position-based correlation of any target to a ping in a directory of converted surveys, and projection of a target onto another frequency channel.

2026 rshomberg@uri.edu

Contents

resolve_target_position

Approximate target's position from the nearest ping (by timestamp) in survey's freq_key channel.

calibrate_clock_offset

Derive the constant epoch-shift between each .ss.h5's (possibly corrupted) ping clock and its filename-embedded true local start time.

resolve_targets_offset_corrected

Resolve position (plus altitude/speed) for every target in targets using a known constant clock offset (see calibrate_clock_offset()).

resolve_targets_nearest_ping

Resolve position for every target in targets via plain (no clock-offset) nearest-ping matching, checking every .ss.h5 file and every frequency channel in ss_h5_dir and keeping whichever match lands closest in time.

resolve_targets_by_catalog_ping

Place targets at the ping their catalog names (Klein .tgt).

project_target_to_frequency

Re-express target's position on a different frequency channel of the same survey, using the one thing both channels share exactly: the physical world.

PositionMatch

Where a target falls in a set of converted surveys.

build_position_index

Cache per-ping positions of every .ss.h5 in ss_h5_dir.

match_frequency

Pick the frequency key in channels whose sample interval is closest to target_mps (meters per sample); the first key if target_mps is None.

correlate_target

Locate target in the surveys described by index.

CatalogPlacement

A correlated target and its position on every frequency of its file.

correlate_catalog

correlate_target() every target, projected onto every frequency of its file.

place_catalog

Group correlate_catalog() placements by file, frequency and channel.

sidescanpy.targets.correlate.resolve_target_position(target, survey, freq_key, *, max_time_delta_s=300.0)#

Approximate target’s position from the nearest ping (by timestamp) in survey’s freq_key channel.

This is a coarse nearest-ping lookup, not a stored pick position (no absolute lat/lon was found in .tgt — see module docstring) and does not upgrade position_confidence above "unknown".

Parameters:
targetTarget

Must have pick_timestamp set.

surveySideScanSurvey
freq_keystr
max_time_delta_sfloat, default 300.0

If the nearest ping’s timestamp is farther than this from target.pick_timestamp, position is left unset (target returned unchanged) rather than guessed from a clearly-wrong file.

Returns:
Target

Copy of target with latitude/longitude set if a close-enough ping was found, otherwise unchanged.

sidescanpy.targets.correlate.calibrate_clock_offset(ss_h5_dir, *, local_utc_offset_hours)#

Derive the constant epoch-shift between each .ss.h5’s (possibly corrupted) ping clock and its filename-embedded true local start time.

Filenames are expected to embed <YYMMDD><HHMMSS> local start time (matches Klein SDF’s own sonar_data<YYMMDD><HHMMSS>.sdf naming, preserved through conversion). Returns the median offset (seconds, corrupted minus true) across every file found in ss_h5_dir, logging each file’s individual estimate so a bad calibration (large spread => the offset isn’t actually constant for this survey) is visible rather than silently trusted.

Parameters:
ss_h5_dirstr or Path
local_utc_offset_hoursfloat

Required keyword. Local-time-to-UTC offset (hours to add to the filename’s embedded local time to get UTC). Use the offset that was actually in effect at the survey’s location/date – there is deliberately no default.

Returns:
float

Median offset in seconds (corrupted ping time - true start time).

Raises:
RuntimeError

If no file in ss_h5_dir matched the expected filename pattern.

sidescanpy.targets.correlate.resolve_targets_offset_corrected(targets, ss_h5_dir, offset_s, *, max_time_delta_s=300.0)#

Resolve position (plus altitude/speed) for every target in targets using a known constant clock offset (see calibrate_clock_offset()).

Enriches each matched target with altitude_m/speed_mps/ meters_per_ping/ground_range_m in extra_attributes — all approximations from the nearest matched ping, not per-sample measurements, and constant across a target’s ~256-ping window. position_confidence stays "unknown" regardless of match quality, matching resolve_target_position()’s convention. Targets with no pick_timestamp, or whose nearest match (across every .ss.h5 file in ss_h5_dir) is farther than 20 * max_time_delta_s away, are returned unchanged.

sidescanpy.targets.correlate.resolve_targets_nearest_ping(targets, ss_h5_dir, *, max_time_delta_s=300.0)#

Resolve position for every target in targets via plain (no clock-offset) nearest-ping matching, checking every .ss.h5 file and every frequency channel in ss_h5_dir and keeping whichever match lands closest in time.

Thin batch wrapper around resolve_target_position() — use resolve_targets_offset_corrected() instead when the survey is known to need clock-offset correction (see that function and calibrate_clock_offset()).

sidescanpy.targets.correlate.resolve_targets_by_catalog_ping(targets, ss_h5_dir, *, max_time_delta_s=1.5)#

Place targets at the ping their catalog names (Klein .tgt).

Klein catalogs store the target ping’s own time and ping number (extra_attributes["catalog_ping_time"] / ["catalog_ping_number"], see sidescanpy.io.sdf.targets) on the sonar’s clock, the same clock as the converted pings. Matching them needs no clock offset and, unlike pick_timestamp (the later save time), identifies the target’s ping exactly.

For each target, the pings of every .ss.h5 in ss_h5_dir within max_time_delta_s of the catalog time are searched for the catalog ping number (ping counters restart after recording gaps, so the time window is what makes the number unique). If the number is not there, the nearest ping in time within the window is used and recorded as position_source="catalog_ping_time".

Sets latitude/longitude to that ping’s position (the vehicle position at the target ping, not the target’s own seafloor position) and position_confidence="approximate", and records ss_h5 (file stem), ping_index (file-local), ping_number, position_source ("catalog_ping"), position_time_delta_s, altitude_m, ground_range_m/ground_range_clipped (from range_m), and speed_mps/meters_per_ping in extra_attributes.

Parameters:
targetslist of Target
ss_h5_dirstr or Path

Directory of converted .ss.h5 files.

max_time_delta_sfloat, default 1.5

Half-width of the time window around the catalog time (the catalog time has 1 s resolution).

Returns:
list of Target

Same order as targets. Targets without catalog fields, or whose catalog time is in no file (for example picked while not logging), are returned unchanged.

sidescanpy.targets.correlate.project_target_to_frequency(target, source_mps, target_mps, *, n_samples=None)#

Re-express target’s position on a different frequency channel of the same survey, using the one thing both channels share exactly: the physical world.

A source catalog (EdgeTech Discover, Klein SonarWiz) only ever marks a contact against the one frequency channel it was picked on — there is no “also visible on the other channel” field. But ping (both channels are pinged in lockstep — the same physical vessel position, identical ping count per channel within one file) and the physical slant range a sample represents (sample * meters_per_sample) are both real, channel-independent quantities. So the target’s true position on the untagged channel is recoverable: convert its sample index to a physical slant range using the source channel’s own meters/sample, then back to a sample index using the target channel’s — same ping, same side, no catalog entry needed for that channel at all.

ping/channel/every other field are carried through unchanged (replace) — only sample moves, plus extra_attributes["projected_from_mps"] recording the source resolution, so a caller/renderer can tell a real catalog mark apart from one recovered this way (e.g. to draw it with a different box style) without losing the fact that it’s the same real contact.

Returns None (not a clamped/guessed value) if the projected sample would land at or past n_samples — a real, physical consequence of the two channels recording different maximum ranges, not a placement bug: the contact genuinely sits beyond the other channel’s recorded window and has no honest position to report there. Only checked when n_samples is given.

class sidescanpy.targets.correlate.PositionMatch(path, freq_key, ping_idx, dist_m)#

Bases: NamedTuple

Where a target falls in a set of converted surveys.

Attributes:
pathPath

The .ss.h5 file containing the matched ping.

freq_keystr

Frequency channel the match was made on.

ping_idxint

Index along that file’s own ping axis (file-local, not the mission-global ping number).

dist_mfloat

Distance from the target’s recorded position to the ping, in meters; 0.0 for a match made from Target.ping alone.

path#

Alias for field number 0

freq_key#

Alias for field number 1

ping_idx#

Alias for field number 2

dist_m#

Alias for field number 3

sidescanpy.targets.correlate.build_position_index(ss_h5_dir)#

Cache per-ping positions of every .ss.h5 in ss_h5_dir.

Parameters:
ss_h5_dirstr, Path, or sequence of them

Directory searched (non-recursively) for *.ss.h5, or an explicit list of .ss.h5 files (indexed in sorted order).

Returns:
dict

{path: {freq_key: info}} where info has lat, lon (per-ping arrays), sample_interval_m (meters per sample, using 1500 m/s when sound_vel_ms is not a positive finite value), n_samples, n_pings and ping_start (the file’s first value on the mission-global ping axis; nonzero for files that continue a global numbering). Surveys are read lazily so only the small position arrays are loaded.

sidescanpy.targets.correlate.match_frequency(channels, target_mps)#

Pick the frequency key in channels whose sample interval is closest to target_mps (meters per sample); the first key if target_mps is None.

sidescanpy.targets.correlate.correlate_target(target, index, max_dist_m)#

Locate target in the surveys described by index.

The frequency channel is chosen per file with match_frequency() using target.extra_attributes["ping_meters_per_sample"] when present.

Strategies are tried in order:

  1. Resolved ping: a target placed by resolve_targets_by_catalog_ping() records its file stem (extra_attributes["ss_h5"]) and file-local ping_index; when that file is in index and the ping in range, it is used as is (distance 0). Navigation fixes often repeat over several pings, so the nearest position alone can be several pings off.

  2. Position: the nearest ping by geodesic distance on the WGS84 ellipsoid (pyproj.Geod.inv()) over all files, accepted if within max_dist_m. Pings without a position fix are skipped.

  3. Ping number (only if step 1 fails or the target has no lat/lon): target.ping is a mission-global ping number, converted to a file-local index via each channel’s ping_start. Accepted only when exactly one file’s range contains it; zero or several candidates return None rather than guess. This recovers catalogs that carry only a pixel-derived ping and no (or a less precise) GPS position.

Parameters:
targetTarget
indexdict

From build_position_index().

max_dist_mfloat

Maximum distance for a position match.

Returns:
PositionMatch or None
class sidescanpy.targets.correlate.CatalogPlacement(target, match, on_frequency)#

Bases: NamedTuple

A correlated target and its position on every frequency of its file.

Attributes:
targetTarget
matchPositionMatch

Where correlate_target() placed it (its native frequency).

on_frequencydict

{freq_key: Target} – the target itself on its native frequency, and a project_target_to_frequency() copy on every other frequency of the file whose recorded range contains it.

target#

Alias for field number 0

match#

Alias for field number 1

on_frequency#

Alias for field number 2

sidescanpy.targets.correlate.correlate_catalog(targets, index, max_dist_m)#

correlate_target() every target, projected onto every frequency of its file.

Uncorrelated targets are omitted. Order follows targets.

Parameters:
targetssequence of Target
indexdict

From build_position_index().

max_dist_mfloat

Maximum distance for a position match.

Returns:
list of CatalogPlacement
sidescanpy.targets.correlate.place_catalog(catalog, index, max_dist_m)#

Group correlate_catalog() placements by file, frequency and channel.

Targets without a sample are left out (they can’t be placed in a window).

Returns:
dict

{(path, freq_key): {channel: [(target, file_ping_idx), ...]}}.