Skip to content

Add atl03_signal_class photon-selection parameter (ATL03 R007 signal_class_ph) to atl03x - #651

Open
dshean wants to merge 7 commits into
SlideRuleEarth:mainfrom
dshean:yapc-score-signal-class
Open

Add atl03_signal_class photon-selection parameter (ATL03 R007 signal_class_ph) to atl03x#651
dshean wants to merge 7 commits into
SlideRuleEarth:mainfrom
dshean:yapc-score-signal-class

Conversation

@dshean

@dshean dshean commented Aug 23, 2026

Copy link
Copy Markdown
Member

Stacked on #649 — the first two commits here are that PR (the fix and its review-response commit); the feature is the last two commits. If #649 merges first, this PR rebases to feature-only; happy to do that on request.

Summary

ATL03 R007 ships an experimental per-photon classification /gtx/heights/signal_class_ph, derived from the YAPC weight_ph weights (−1 ignored, 0 likely_noise, 1 likely_signal, 2 signal_below, 3 signal_above, 4 primary_signal, 5 fitted_signal; primary/fitted mark the highest reflecting surface found per ~5 m fit segment). atl03_ph_fields can already deliver the column, but nothing can select on it. This PR adds an opt-in atl03_signal_class parameter mirroring the atl08_class machinery on atl03x — inert unless supplied, no change to any default behavior — so that e.g.

{"atl03_signal_class": ["primary_signal", "fitted_signal"], "fit": {}}

produces a server-side surface fit to the granule's own first-reflecting-surface photon selection, with no ATL08 dependency and no threshold to tune.

Motivation and quantitative evaluation — why a scalar yapc.score threshold cannot cover brightness-varying scenes, and how this selection performed in a 383-fit-window seasonal-snow study — are in the companion issue #650.

Semantics and limitations (measured; details in #650)

The selection is first-surface by construction. Over exposed ground a fit on it is simply a surface fit; over snow-free dense conifer forest the first surface is the canopy, so a ground-style fit is biased high there (+0.89 m median in the evaluation's snow-off steep-forest windows — users wanting snow-off forest terrain should use atl08_class: ["atl08_ground"] instead), and conversely a robust fit on this selection is not a canopy-top (DSM) product. signal_class_ph is new in R007 and marked experimental, so the parameter fails loudly rather than silently: requests against pre-R007 granules raise a clear alert, and the legacy reader rejects the parameter as unsupported.

Implementation

  • accepts classification names ("primary_signal", ...) or the numeric values −1..5;
  • signal_class_ph is read only when the parameter is supplied (it is photon-rate, so the read is conditional, like the ATL08 companion reads);
  • the filter applies in the photon selection loop alongside cnf, quality_ph, atl08_class, and yapc, i.e. before fit/phoreal;
  • the selected photon's classification is returned in a new atl03_signal_class column (int8);
  • requests against pre-R007 granules raise a clear alert (the dataset does not exist there), and the legacy Atl03Reader rejects the parameter as unsupported instead of silently ignoring it;
  • a follow-up commit enables the optional column in the representative OpenAPI schema-generation request, so the generated schema documents it (the parameter itself is picked up automatically from addParameter metadata).

Files changed

File Change
apps/node/datasets/icesat2/package/Icesat2Parameters.{h,cpp} atl03_signal_class_t enum + Lua/JSON/index converters (pattern of atl08_class_t; index offset like signal_conf_t for the −1 value)
apps/node/datasets/icesat2/package/Atl03Parameters.{h,cpp} atl03SignalClass field + atl03_signal_class parameter registration
apps/node/datasets/icesat2/package/Atl03DataFrame.{h,cpp} conditional signal_class_ph read; release guard; selection-loop filter; atl03_signal_class column
apps/node/datasets/icesat2/package/Atl03Reader.cpp reject atl03_signal_class on legacy endpoints
apps/node/scripts/openapi.lua enable the optional column in the representative schema-generation request
apps/node/datasets/icesat2/selftests/parameters.lua parse tests: names + numeric values; invalid name/value falls back to an empty (inert) selection with a server-side warning, matching the FieldMap convention (offline)
apps/node/datasets/icesat2/selftests/atl03_dataframe.lua cloud tests: selection reduces rows, returns only selected classes, adds column; pre-R007 rejected; fit-on-selection returns ≤ unfiltered fit rows
clients/python/tests/test_atl03x.py same behaviors against a live endpoint
docs/rtd/source/user_guide/icesat2.md new §1.2.5 ATL03 Signal Classification; column tables

Testing

Built and tested locally in the project buildenv container (arm64, amazonlinux:2023, per the ARM dev-environment setup): release build compiles clean with no warnings or errors, and make selftest passes with 0 errors (76 tests, 907 asserts across the offline suite, including the new parameters.lua tests; the cloud-gated atl03_dataframe.lua tests for this feature skip locally by design and need an in-cloud run). The live-endpoint Python tests are written but require a deployed server carrying this branch. The evaluation numbers referenced in #650 were produced by emulating the selection client-side on atl03_ph_fields ["weight_ph","signal_class_ph"] pulls and applying the identical fit — i.e., they validate the selection semantics this PR exposes, on real R007 granules.

Related

dshean and others added 5 commits August 7, 2026 22:34
…ail loudly

The yapc score photon-selection threshold was silently inert: the filter
comparisons in Atl03DataFrame (atl03x) and Atl03Reader (legacy) compared
the photon score against the FieldElement object rather than its value,
which resolved through FieldElement's implicit operator bool, so any
non-zero score behaved as a threshold of 1 and the parameter had no
effect.  The comparisons now use the parameter value directly.

Also made previously silent failure modes fail loudly:
- yapc versions outside 0-3 are rejected when the parameters are parsed
- yapc versions 1-3 on atl03x (not implemented there) now raise an alert
  instead of silently returning all-zero scores
- yapc version 0 against pre-release-006 granules (no weight_ph dataset)
  now raises an alert on both atl03x and the legacy reader instead of
  silently returning zero scores (which, with the fixed comparison,
  would have silently filtered out every photon)

Documented the score scale: scores read from release 007 granules are
the granule weight_ph values on the 0-65535 saturation-normalized scale;
release 006 granule scores and server-computed (version 1-3) scores are
0-255.  The threshold is compared against the raw values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t in unsupported-version test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qQW14tuisiZycmpdCigeP
The selftest asserted that parms03() returns false for yapc.version=9,
but FieldMap catches the parse-time throw, logs a warning, and leaves
yapc un-provided; construction succeeds. Surfaced by the first local
build and selftest execution of this branch (buildenv container,
make selftest). Versions 1-3 on atl03x are still rejected loudly at
runtime, which the atl03_dataframe.lua selftests cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qQW14tuisiZycmpdCigeP
Release 007 of ATL03 includes an experimental per-photon signal
classification (signal_class_ph) derived from the weight_ph photon
weights, labelling photons as -1:ignored, 0:likely_noise,
1:likely_signal, 2:signal_below, 3:signal_above, 4:primary_signal,
5:fitted_signal, where primary/fitted identify the highest reflecting
surface.

The new atl03_signal_class parameter mirrors atl08_class: a list of
classification names (or numeric values) that drives photon selection
in the atl03x dataframe.  When requested, signal_class_ph is read from
the ATL03 granule (only then; the dataset is photon rate), the selection
is applied in the photon selection loop alongside the other criteria
(so it composes with cnf, quality_ph, atl08_class, yapc, and the fit
and phoreal algorithms), and the classification of each selected photon
is returned in the atl03_signal_class column.

Requests against pre-007 granules raise an alert since the dataset does
not exist there, and the legacy endpoints (which do not implement the
selection) reject the parameter instead of silently ignoring it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…03x dataframe

The Atl03DataFrame openapi schema is generated from a representative
request that enables every optional column family so that all optional
columns appear in the published schema.  The atl03_signal_class column
is only added to the dataframe when at least one classification is
enabled (an empty list enables nothing), so a classification is enabled
here.  The atl03_signal_class request parameter itself needs no schema
edits; it is picked up automatically from the addParameter metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jpswinski

Copy link
Copy Markdown
Member

@dshean Is there a driving need to a server side signal_class_ph filter? Could this be accomplished via supplying it as an ancillary field in the request and then filtering on the client (web or Python)?

@dshean

dshean commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

The idea was to use these during the server-side ATL06 fits, instead of the 'ground' classified photons. Basically another way to customize which photons are going into the ATL06 processor.

@jpswinski

Copy link
Copy Markdown
Member

@dshean I see, that makes sense. Then the change I would want to see in how it is coded is for the enumeration field to default to everything being true so that when the request parameters are represented in the returned dataframe metadata, the filter is correctly captured. Then the code should check to see if anything is disabled and if anything is disabled, then it can conditionally read the signal class ph variable in the hdf5 file.

The ATL06 Surface Fitter - Signal Classification selftest asserted that
the fit on signal-class-selected photons returns no more elevations than
the fit on all photons. Noise photons make segments fail the robust fit,
so a cleaner selection can yield more rows: on the test granule/polygon
cnf=4 alone gives 527 rows vs 415 for cnf=0, and the signal class
selection gives 524. Assert instead that the selection changes the fit.

Verified on a us-west-2 instance against NSIDC data: atl03_dataframe.lua
selftests pass with 1111 asserts and 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCwmYXCh5D3zRAdpE4X1Df
Copilot AI lite review requested due to automatic review settings August 24, 2026 23:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new atl03_signal_class photon-selection parameter to the ICESat-2 atl03x path, enabling server-side filtering on ATL03 R007’s experimental signal_class_ph classifications (and returning the selected class as an output column). This PR also includes the stacked fix work that makes yapc.score filtering effective and ensures unsupported YAPC configurations fail loudly instead of silently degrading results.

Changes:

  • Add atl03_signal_class parameter parsing/serialization (Lua/JSON/index) and integrate selection into the ATL03 photon subsetting loop with R007 release guards.
  • Add atl03_signal_class as an opt-in output column and update OpenAPI schema generation to include it.
  • Add/extend offline + cloud-gated Lua selftests and live-endpoint Python tests; update user guide documentation for new selection and clarified YAPC score scaling.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
docs/rtd/source/user_guide/icesat2.md Documents atl03_signal_class semantics/limits and clarifies yapc_score scale by ATL03 release/source.
clients/python/tests/test_atl03x.py Adds live-endpoint tests for YAPC score filtering, YAPC unsupported versions, and signal-class filtering/fit behavior.
apps/node/scripts/openapi.lua Enables the optional atl03_signal_class column in the representative schema-generation request.
apps/node/datasets/icesat2/selftests/parameters.lua Adds offline parameter-parse tests for atl03_signal_class (names, numeric values, invalid handling).
apps/node/datasets/icesat2/selftests/atl03_dataframe.lua Adds cloud-gated behavioral tests for YAPC score filtering, unsupported YAPC versions, and signal-class filtering + fit interaction.
apps/node/datasets/icesat2/package/Icesat2Parameters.h Introduces atl03_signal_class_t enum and converter declarations/encoding hook.
apps/node/datasets/icesat2/package/Icesat2Parameters.cpp Implements enum converters for atl03_signal_class_t (Lua/JSON/index).
apps/node/datasets/icesat2/package/Atl03Parameters.h Adds atl03SignalClass field (enumeration) to ATL03 parameters.
apps/node/datasets/icesat2/package/Atl03Parameters.cpp Registers atl03_signal_class; adds parse-time validation for yapc.version.
apps/node/datasets/icesat2/package/Atl03DataFrame.h Adds atl03_signal_class column, signal_class_ph dataset handle, and useSignalClass flag.
apps/node/datasets/icesat2/package/Atl03DataFrame.cpp Conditionally reads signal_class_ph, guards pre-R007 usage, filters photons by class, and fixes YAPC score comparisons to use .value.
apps/node/datasets/icesat2/package/Atl03Reader.cpp Rejects atl03_signal_class on legacy endpoints and fixes YAPC score comparisons to use .value (plus pre-R006 guard).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +391 to +392
assert 0 < len(gdf_sel) <= len(gdf_all)
assert gdf_sel.n_fit_photons.sum() < gdf_all.n_fit_photons.sum() # fit runs on selected photons only
@dshean

dshean commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

In-cloud verification of this branch on a us-west-2 Graviton instance (Amazon Linux 2023, sliderule-buildenv:latest container, instance-role auth): full selftest suite = 76 tests, 20 skipped, 4029 asserts. All icesat2 cloud selftests pass, including the new atl03_signal_class cases in atl03_dataframe.lua: on ATL03_20200304065203_10470605_007_01 gt1l over the test polygon the selection reduces 15187 photons to 14161, only classes 4/5 come back, and the pre-007 granule is rejected with zero photons.

On the question above: the new ATL06 surface-fitter selftest exercises exactly that server-side use, the fitter consuming the signal-class-selected photons. On this granule/polygon it produces 524 elevations from the 14161 selected photons, versus 415 from all 15187 photons at cnf=0 and 527 from the 13317 photons at cnf=4.

One assertion in that test was wrong and is fixed in 258591b: it assumed the fitter returns no more elevations from the signal-class-selected photons than from the unselected set. Fit row counts are not monotonic in photon count (noise photons make segments fail the robust fit), as the cnf=4 vs cnf=0 numbers above show for the existing filter. The test now checks that the selection changes the fit result instead of asserting a direction.

…ph only when a subset is selected

Per review on PR SlideRuleEarth#651: the enumeration now defaults to every
classification enabled, so the request parameters recorded in the
dataframe metadata always capture the filter. The dataframe reads
signal_class_ph (and adds the atl03_signal_class column) only when
anyDisabled() is true, i.e. when a subset of classifications is
selected; the legacy Atl03Reader check uses the same test so the
default no longer trips it. This matches the ATL24 flag fields
(default on, anyDisabled() triggers the conditional read).

Selecting all seven classifications explicitly is equivalent to the
default and is covered by a new selftest case; the parameter
description and user guide describe the default. An invalid
classification leaves the enumeration empty (fromLua clears it before
converting), which excludes every photon with a server-side warning,
the same behavior as quality_ph.

Verified on a us-west-2 instance against NSIDC data: parameters.lua
(37 asserts), atl03_dataframe.lua (1114 asserts) and atl03_reader.lua
(7 asserts) pass with 0 errors; full selftest suite 76 tests, 4035
asserts, 1 error (pre-existing 3DEP aspect tolerance, environmental).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCwmYXCh5D3zRAdpE4X1Df
@dshean

dshean commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Done in d624900: atl03_signal_class now defaults to every classification enabled, so the request parameters recorded in the dataframe metadata carry the full list when no filter is in effect. The reader checks anyDisabled() and only then reads signal_class_ph (and adds the atl03_signal_class column); the legacy atl03s check uses the same test, so the default no longer trips it. Selecting all seven classes explicitly is equivalent to the default (covered by a new selftest case), and the parameter description and user guide say so.

One consequence worth a look: an invalid classification name now leaves the enumeration empty (fromLua clears it before converting, and FieldMap's catch keeps that state), so the filter is active and excludes every photon with only a server-side WARNING. That is the same behavior quality_ph has today, so I left it consistent rather than special-casing it; happy to make it a hard rejection instead if you prefer.

Verified on the us-west-2 instance: parameters.lua, atl03_dataframe.lua and atl03_reader.lua pass in cloud context, and the full selftest suite is unchanged apart from the environmental cases noted earlier.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

apps/node/datasets/icesat2/selftests/atl03_dataframe.lua:224

  • The test’s min_score_* assertions become vacuous if count_rows returns 0 rows for score=1000 or score=8000: min_score stays at the sentinel value (65535) and still satisfies min_score >= threshold. To make this test actually validate the filtering behavior, add explicit assertions that rows_mid > 0 and rows_high > 0 (or compute min_score only when df:numrows() > 0 and assert accordingly).
        local min_score = 65535
        for i = 1, math.min(df:numrows(), 1000) do
            if df["yapc_score"][i] < min_score then
                min_score = df["yapc_score"][i]
            end
        end
        return df:numrows(), min_score
    end

    local rows_all, min_score_all = count_rows(0)
    local rows_mid, min_score_mid = count_rows(1000)
    local rows_high, min_score_high = count_rows(8000)

    runner.assert(rows_all > 0, "no photons returned for score 0")
    runner.assert(rows_mid <= rows_all, string.format("score 1000 did not filter photons: %d > %d", rows_mid, rows_all))
    runner.assert(rows_high < rows_all, string.format("score 8000 did not filter photons: %d >= %d", rows_high, rows_all))
    runner.assert(rows_high <= rows_mid, string.format("score threshold not monotonic: %d > %d", rows_high, rows_mid))
    runner.assert(min_score_mid >= 1000, string.format("photon below score threshold returned: %d < 1000", min_score_mid))
    runner.assert(min_score_high >= 8000, string.format("photon below score threshold returned: %d < 8000", min_score_high))

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.

Feature: atl03_signal_class photon-selection parameter for atl03x (select on ATL03 R007 signal_class_ph)

3 participants