feat: empirical angular-response (ARA) backscatter correction in the CUBE estimator (#81)#84
Merged
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an empirical angular-response (ARA/AVG) backscatter correction to the
shared CUBE estimator (
Node::extractNodeRecord), so both the live tile (#78) andthe 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 onmerge; 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 M3reflectivity_dbis 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'sSonarDetections,bins
intensitiesby|rx_angles|(2° bins), writes a per-sonar curve CSV.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 /
Nonemode /empty curve → identity.
(
backscatter_angle_correction,backscatter_curve_file) + offline--backscatter-correction/--backscatter-curve. Threaded into the estimatorvia
GeoMapSheet::setBackscatterCorrection; a loud warning fires ifempiricalis set but no curve loads (no silent no-op).BeamIntensitySample::grazing_angle→beam_angle(it holds theincidence/steering angle from nadir, not a true grazing angle).
Gate (verified)
rx_anglessign/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 buildclean;colcon testgreen: 378 tests, 0 failures (6 ARAcases + 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)
— needs M3 calibration + bathymetry coupling. ADR-0007 D3 / Slope correction is disabled (commented out) in Node::insert #15 / Port the CUBE predicted-surface producer (external-prior load + per-sounding touchdown interpolation) to make slope correction active #59.
dB/TVG-applied assumption is itself sonar-specific.
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 AgentModel:
Claude Opus 4.8 (1M context)