Skip to content

feat: empirical angular-response (ARA) backscatter correction in the CUBE estimator (#81)#84

Merged
rolker merged 17 commits into
jazzyfrom
feature/issue-81
Jun 28, 2026
Merged

feat: empirical angular-response (ARA) backscatter correction in the CUBE estimator (#81)#84
rolker merged 17 commits into
jazzyfrom
feature/issue-81

Conversation

@rolker

@rolker rolker commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an empirical angular-response (ARA/AVG) backscatter correction to the
shared CUBE estimator (Node::extractNodeRecord), so both the live tile (#78) and
the offline store (#80) can be de-nadir-striped from one place — the correction
lands once in the estimator both paths run.

Default OFF (BackscatterAngleCorrection::None) — zero behavior change on
merge; a sonar's correction only happens when a config provides a curve.

Why empirical (not cos²θ Lambert)

Offline analysis of M3 data (m3_dryrun, 2025 pings / 445k beams) showed the M3
reflectivity_db is raw dB with a ~24 dB nadir-to-edge falloff — ~4×
steeper than cos²θ Lambert
(which would remove only ~¼ of the stripe). The
falloff is dominated by sonar-equation terms cos²θ ignores (insonified area,
beam-pattern, TVG residual). The empirical curve absorbs all of them at once. See
the issue's research comment. The full radiometric GeoCoder chain (model each term)
stays the deferred follow-up.

What it does

  • derive_angular_response.py (offline tool): reads a bag's SonarDetections,
    bins intensities by |rx_angles| (2° bins), writes a per-sonar curve CSV.
  • Estimator correction: corrected_dB = raw_dB − curveRel(|beam_angle|),
    linearly interpolated; nadir → identity; beyond the curve's last bin → clamped
    to the edge value
    (continuous, no swath-edge ring); NaN angle / None mode /
    empty curve → identity.
  • Parameter on both entry points: live node ROS params
    (backscatter_angle_correction, backscatter_curve_file) + offline
    --backscatter-correction / --backscatter-curve. Threaded into the estimator
    via GeoMapSheet::setBackscatterCorrection; a loud warning fires if
    empirical is set but no curve loads (no silent no-op).
  • Rename BeamIntensitySample::grazing_anglebeam_angle (it holds the
    incidence/steering angle from nadir, not a true grazing angle).

Gate (verified)

rx_angles sign/zero convention verified against the producer
(kongsberg_em_bridge: Kongsberg pointing +PORT → negated → +STARBOARD; nadir=0;
radians; intensity dB). Documented at the correction site.

Testing

  • colcon build clean; colcon test green: 378 tests, 0 failures (6 ARA
    cases + 4 curve-loader cases new). Lint clean. Deep pre-push review (2 adversarial
    passes) — approved, 0 must-fix.

Out of scope / follow-ups (will file)

ADR

Adds docs/decisions/0007-mbes-backscatter-store-addendum-phase-b-transition.md
(addendum; the full ADR-0007 doc is a tracked follow-up).

Closes #81


Authored-By: Claude Code Agent
Model: Claude Opus 4.8 (1M context)

Claude Code Agent added 17 commits June 28, 2026 09:56
Replace Phase B no-op with first-cut flat-bottom Lambert correction
using 10*log10(cos(|beam_angle|)); rename misnamed grazing_angle field;
add ADR-0007 transition note; update affected tests.
plan-task wrote it under the package subdir; normalize to the repo-root
.agent/work-plans/issue-81/ location (matching progress.md and the #80
convention) so review-plan and future agents find it.

Part of #81
Reshape after review-plan (changes-requested) + operator direction:
- Sonar-agnostic: the angle-correction becomes an operator startup
  parameter (BackscatterAngleCorrection None|Lambert, default None=identity)
  on both estimator entry points (live node ROS param + import_bag CLI) --
  the estimator cannot know a sonar's internal normalization, so the
  operator declares it per sonar. Verified host-side that kongsberg_em_bridge
  reads raw EM 'Raw Range and Angle 78' reflectivity with no bridge-side
  angle compensation.
- Formula: cos^2 theta => corrected_dB = raw_dB - 20*log10(cos theta)
  (classic Lambert; operator-confirmed; the prior x10 justification was
  physically unsound per review).
- Near-grazing: single rule -- identity beyond MAX_INCIDENCE (no clamp).
- Tests: enumerate all 3 broken non-nadir assertions; add a default-off
  identity test; #80 offline test stays valid on the default path.
- ADR-0007 transition note renamed to an addendum (no 0007 slot collision)
  + follow-up to author the full ADR.

Part of #81
Supersedes the prior plan: empirical analysis showed cos²θ explains ~¼ of
the M3 nadir-to-edge falloff. New plan: offline derivation tool + configured
curve + ARA interpolation at node-output, sonar-agnostic, default OFF.
The field stores the receive/steering (beam/incidence) angle from nadir,
not a true grazing angle (90deg - theta). Rename it everywhere -- struct
field, recordBeam param, docs, and tests -- to match what it holds.

Part of #81
Add BackscatterAngleCorrection {None, Empirical} and an angular_response_curve
to Parameters, a GeoMapSheet::setBackscatterCorrection() setter (grids hold a
const ref to the sheet Parameters, so the setting reaches them), and a shared
loadAngularResponseCurve()/parseBackscatterAngleCorrection() CSV loader.

Plumb both estimator entry points: the live node declares
backscatter_angle_correction + backscatter_curve_file ROS params; the offline
importer adds --backscatter-correction + --backscatter-curve. Empirical mode
with an empty/missing curve logs a loud no-op warning rather than failing
silently. Default none preserves the Phase B identity behavior.

Part of #81
Replace the Phase B node-output no-op (corrected == raw) with the empirical
angular-response correction: corrected_dB = raw_dB - curveRel(|beam_angle|),
where curveRel is the per-sonar curve's db_relative_to_nadir column, linearly
interpolated between bin centres by |beam_angle| in degrees. Nadir, beyond-max
angle, NaN angle, mode None, or an empty curve all fall back to identity. The
curve is keyed on |angle| so port/starboard beams of equal magnitude match.
The rx_angles sign/zero gate (kongsberg_em_bridge negates +PORT to +STARBOARD,
nadir 0, radians, dB) is documented at the correction site.

Add scripts/derive_angular_response.py: reads a SonarDetections bag, bins
intensities by |rx_angles| (2-deg bins), writes the curve CSV the correction
consumes. Installed to lib/cube_bathymetry.

Part of #81
Add 6 ARA cases at extractNodeRecord (interpolation at a mid angle, nadir
identity, beyond-max identity, NaN-angle identity, mode-None identity, and
port/starboard symmetry) plus curve-loader/mode-parse tests
(test_angular_response_curve). The 3 pre-existing default-path intensity
assertions are unchanged: default None means corrected == raw.

Update stale 'uncorrected' comments in test_store_import to note the surface
is uncorrected by default (BackscatterAngleCorrection::None).

Part of #81
Record the end of the Phase B identity no-op and the empirical
angular-response correction: the configured-curve + default-off rationale,
the offline derivation tool, the verified rx_angles gate, and what is
deferred (full radiometric GeoCoder -- area/beam-pattern/TVG/slope, ADR-0007
D3 / #15 / #59). Notes follow-ups to author the canonical ADR-0007 document
and to model intensity domain (dB vs linear) as a sonar property. Addendum
name only -- does not claim the canonical 0007 slot.

Part of #81
#81 review)

Pre-push review-code suggestions:
- curveRelativeDb: beyond the curve's last bin, clamp to the outermost bin's
  correction instead of returning identity (0). The empirical curve is bounded
  (unlike a diverging cos/log model), so continuing the edge value keeps the
  correction continuous and avoids a ~24 dB swath-edge discontinuity / bright
  ring. Test renamed ARABeyondMaxAngleClampsToEdge, asserts the clamped value.
- angular_response_curve loader: correct the comment overstating the used==0
  guard (std::stof accepts a numeric prefix like "1deg"=1.0; not rejected).

378 tests green.

Part of #81
Copilot AI review requested due to automatic review settings June 28, 2026 14:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@rolker rolker merged commit 791e49d into jazzy Jun 28, 2026
1 of 2 checks passed
@rolker rolker deleted the feature/issue-81 branch June 28, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M3 backscatter correction: empirical angular-response (ARA) normalization in the shared CUBE estimator

2 participants