feat(pipelines): channel_attrs Phase-A pipeline — corridor scaling, API, 69 tests, bug fixes#12
Open
taddyb wants to merge 19 commits into
Open
feat(pipelines): channel_attrs Phase-A pipeline — corridor scaling, API, 69 tests, bug fixes#12taddyb wants to merge 19 commits into
taddyb wants to merge 19 commits into
Conversation
…ema recon
Task 0 of Phase-A channel-corridor attribute extraction pipeline.
- pipelines/channel_attrs/paths.py: single source of truth for all paths/constants
(corrected MERIT_RIV to ddr/data/merit/ vs plan draft's ddr/data/)
- pipelines/channel_attrs/tests/test_paths.py: path existence + staging dir tests (pass)
- pipelines/channel_attrs/README.md: env setup, schema recon, download manifest
Schema recon findings recorded in README:
- global.nc: dim COMID=2939404, 29 float64 attrs + COMID int64 coord
- stats JSON: {var: {min,max,mean,std,p10,p90}} per finite values
- MERIT shapefile: 15 fields (COMID,lengthkm,uparea,...), EPSG:4326, 346327 features
Downloads started (large in background): MERIT-SWORD, Zarrabi, Fan2013,
GFPLAIN250m, BFI, Principal Aquifers complete; SWORD/NHDPlusV2/ZS_WTD/StreamCat in progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
conus_MF6_SS_Unconfined_250_dtw.tif confirmed inside Output_CONUS_trans_dtw.zip (876 MB, valid zip) — directly usable 250m DTW GeoTIFF, ZS primary WTD source confirmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds corridors.py with build_corridors() (buffer in EPSG:5070, preserve COMID) and main() that writes derived/corridors_100m.parquet and derived/corridors_wide.parquet. Both files have 346,327 features (matches the recorded shapefile count). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idth-scaled hinge (lit-grounded) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… rule, width priority chain, single reprojection)
Implements Task 2 of Phase-A channel-attributes pipeline. - transfer.py: length-weighted attribute transfer helper (shared by Tasks 2/4/5) - sword_width.py: melts Wade et al. 2025 translation tables -> MERIT COMIDs, applies weighted_transfer with SWORD v16 NA widths - tests/test_transfer.py: TDD tests for weighted_transfer Adaptation notes from on-disk inspection: - Translation table MERIT reach variable is 'mb' (not 'COMID' as plan assumed) - SWORD reads via netCDF4 (xarray decode fails on string var in this file) - CONUS = pfaf-2 regions 71-78 (8 files confirmed) Sanity (Step 4 criteria, all pass): - Coverage: 28,529 / 346,327 CONUS reaches = 8.2% (within 5-15% target) - Median width of covered reaches: 127.3 m (> 30 m threshold) - Mississippi mainstem near Vicksburg: 826-1218 m (> 500 m threshold) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c quality flags) Implements Task 3 of Phase-A: builds nhd_merit_crosswalk.parquet by buffering each MERIT reach 300 m in EPSG:5070, spatial-joining intersecting NHDFlowline_Network reaches, computing exact clipped lengths per pair, and computing per-COMID match_frac quality flags. CONUS build: 8x8 tile grid, 4.0 min wall time, 1.82M pairs, 162k matched COMIDs. Archive: NHDPlusV21 FileGDB extracted with py7zr (15.5 GB uncompressed, 78s). Tests: 3 synthetic geometry tests pass (part_len exact, match_frac correct, no-overlap returns empty). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…N API Reads all 21 pctimp2019_Region*.json files via streaming ijson parse (handles truncated Region07 gracefully), filters NHD->MERIT crosswalk to match_frac >= 0.3, runs weighted_transfer, divides by 100 to fraction. Writes derived/corridor_impervious.parquet. Coverage: 151,503/156,002 MERIT COMIDs (97.1%). Sanity: LA River bbox median 0.543; rural Montana median 0.009. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reads Bankfull_Meanflow_CONUS.txt (comma-separated, index_col=0), extracts bnk_depth/bnk_width, renames COMID->foreign_id, runs weighted_transfer per column via NHD->MERIT crosswalk (match_frac>=0.3). Writes derived/bankfull.parquet. Coverage: 155,831/156,002 (99.9%). Median depth 1.43 m (in [0.3,3] range). McManamay confinement skipped (not in download manifest; recorded in README). Spearman(depth,uparea)=0.469 (marginally below 0.5; correct direction). Spearman(width,channel_width_obs)=0.005 — outlier issue in Task-2 SWORD data (up to 61k m); flagged in README, not a Zarrabi transfer bug. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BFI: reads BFI_CONUS.zip directly (NHD-indexed table, CAT_BFI in [0,100]% divided by 100), filters negative pseudo-COMIDs, transfers via NHD->MERIT crosswalk (match_frac>=0.3). Deviation from plan's raster path: BFI_CONUS.zip is a pre-aggregated NHD table, not the raw bfi48grd raster; recorded in README. Coverage: 156,002/156,002 (100%). BFI in [0,0.9]; East > SW sanity passed. Drainage density: lengthkm/catchsize (km^-1) from MERIT shp + global NC; catchsize<=0 guarded to NaN. Median 0.197 km^-1 (in [0.1,2] range). Writes derived/basin_extras.parquet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mismatch The Wade et al. 2025 mb_to_sword translation tables were built from a pre-bugfix1 MERIT-Basins COMID ordering. The same integer COMID refers to completely different reaches in that version vs the bugfix1 shapefile used by this project (distances of 100–1100 km within a single pfaf-2 region). Joining these COMIDs against the NHDPlus→MERIT bugfix1 bankfull dataset produced zero rank correlation (spearman 0.012). Fix: replace the translation-table join with a spatial join of SWORD NA reach centerpoints into MERIT bugfix1 catchment polygons, using SWORD reach_length as the weight. Only river reaches (lakeflag == 0) are used; lake/reservoir widths are not representative of channel width. After fix: spearman(channel_width_obs, bankfull_width) = 0.596, n = 7,103 (cap ≤ 2 km), median obs/bankfull = 1.17, p95 = 4.5. Coverage: 13,470 MERIT COMIDs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r sensitivity, ZS + Fan) - sample_along_lines(): batched vectorised xarray.sel() across all tile vertices for ~10x speedup vs per-reach loop; optional bed_depths→frac_below column - ZS (Zell & Sanford 250 m): positive-down DTW confirmed (95.2% ≥ 0, midCONUS p50=8 m); artesian negatives clipped to 0; frac_below_zs persisted for Task 7 - Fan 2013: all-negative raw WTD negated to positive below-surface; p50=0.69 m - Processes 38 pfaf-4 tiles in 3 min; 31 tiles have ZS coverage (7 in Canada) - Cross-checks: spearman(ZS, Fan)=0.360 (below 0.4 — warning surfaced, models differ as expected: CONUS transient vs global equilibrium); spearman(nearest-cell ZS, corridor ZS)=0.936 (>>0.8, OK) - Coverage: ZS 173,506/346,327 (CONUS only); Fan 346,327 (full); corridor 136,413 - Output: derived/wtd_channel.parquet (COMID, wtd_channel_zs, wtd_channel_fan, wtd_corridor100_zs, wtd_channel_n, frac_below_zs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- bed_relative(): channel_wtd_bed_rel = wtd_channel_zs - bankfull_depth
(positive = water table below bed = losing-possible; negative = gaining)
- losing_fraction from Task-6 frac_below_zs (per-vertex fraction where
wtd > bankfull_depth); no re-sampling needed
- Overall CONUS: p50 bed_rel=-0.90 m; 29% positive (losing-possible);
median losing_fraction=0.118; majority-losing (>0.5) on 14.2% of reaches
- Three spec-mandated regime checks (all pass):
Ogallala COMID=74049945: bed_rel=+62.6 m, losing_fraction=1.00
Appalachian COMID=74037194: bed_rel=-7.87 m, losing_fraction=0.00
LA River COMID=77030730: bed_rel=+1.37 m, corridor_impervious=0.71
(Phase-C falsification pair: losing channel in impervious-sealed watershed)
- Output: derived/wtd_bedrel.parquet (COMID, channel_wtd_bed_rel, losing_fraction)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- main() left-merges channel_width_obs.parquet and bankfull.parquet so
_resolve_width_m uses real per-reach widths (13,470 SWORD obs + 155,831
Zarrabi bankfull) instead of order-fallback for all reaches.
- _resolve_width_m now caps width at paths.WIDTH_CAP_M = 3000 m before
the hinge formula, guarding residual SWORD estuary/lake widths (CONUS
bugfix1 max was 16.7 km → half-width capped at 4.5 km).
- main() accepts --only {scaled,100m} to rebuild a single product; used
to skip the unchanged 100 m set.
- Two new tests: cap applied; left-merge preserves row count.
- Spec caveats: 3 km cap rationale + Wade crosswalk validity note.
Rebuilt corridors_scaled distribution: p50=100 m, p99=352 m, max=4500 m,
count > 100 m floor = 16,545 reaches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verlay
DEVIATION from plan Task 8: overlay uses corridors_scaled (not corridors_100m
per plan text). Rationale: large alluvial rivers need the scaled corridor to
cover the channel + its alluvial floor; a fixed 100 m strip misses the alluvial
body for order-8/9 reaches. Documented in alluvium.py docstring.
Alluvial class chosen: ROCK_TYPE == 100 ("Unconsolidated sand and gravel
aquifers") from USGS Principal Aquifers. All explicitly alluvial/basin-fill
AQ_NAMEs (Mississippi Valley, Pecos, Basin-and-Range fill, etc.) fall in this
class. ROCK_TYPE 200 (Semiconsolidated sand, coastal/bedrock) excluded.
Implementation: dissolve 806 alluvial polygons to 1 geometry, then
gpd.overlay intersection chunked by pfaf-4 prefix (38 chunks). Fill
alluvium_fraction=0 where no intersection (explicit, not NaN).
Results (346,327 CONUS reaches, 0 nulls):
45,767 reaches (13.2%) have alluvium_fraction > 0
Sanity: Mississippi alluvial valley median=1.000; Rocky Mtn median=0.000
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion stats 11-variable Phase-A channel attribute netCDF (2,939,404 global COMIDs, f64, dim COMID) assembled from derived parquets; NaN outside CONUS coverage. Stats JSON at merit_attribute_statistics_merit_channel_attributes_v1.nc.json matches ddrs six-key per-variable format (min/max/mean/std/p10/p90 over finite values). TDD: test_assemble verifies dim=4, dtype, NaN reindex. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ixes Replace Leopold & Maddock order-width formula with the NCAR WRF-Hydro Mannings_Bw lookup table (Strahler 1–10) as the order fallback for corridor scaling. Lower the minimum corridor half-width from 100 m to 10 m so corridors 5–10 now scale with channel geometry rather than flooring at the positional-error envelope. With the 1.5× hinge rule, orders 1–4 still pin at 10 m; orders 5–10 yield 11–165 m half-widths from the table. Bug fixes: - streamcat_transfer: broad except swallowed KeyError, silently zeroing HUC2 regions — narrowed to IncompleteJSONError only - wtd_sample: bare except pass hid zonal_stats failures per tile; fallback column name mismatch (wtd → wtd_corridor10_zs) - corridors main(): unconditional width-parquet reads before --only filter caused FileNotFoundError; replaced with lazy _merge_width_columns() - _to_equal_area: CRS=None passed to geopandas producing cryptic error; now raises ValueError explicitly API: populate __init__.py with build_corridors, build_scaled_corridors, order_bankfull_width_m, scaled_half_width_m, sample_along_lines, weighted_transfer, assemble. Tests: 69 tests (was 0) covering all 12 pipeline modules, including new test_alluvium, test_core, test_sword_width, test_zarrabi_transfer files and parametrized corridor hinge tests (orders 1–4 floor, 5–10 scale against paths.WRF_HYDRO_BW_M directly). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add zonal_stats_corridors(da, corridors, stat, id_col) as the public channel-burning extraction path: corridors are reprojected to the raster CRS and zonal statistics computed via the extractrs accessor. Refactor _corridor_mean_zs to delegate each pfaf-4 tile to zonal_stats_corridors so the tiled pipeline path and the public API share the same logic. Export zonal_stats_corridors from __init__.py alongside the existing corridor-geometry and sampling functions. Add 3 tests covering the uniform- raster path, CRS reprojection, and the no-CRS ValueError guard. Remove specs/2026-07-04-corridor-buffer-scaling.md — design docs live outside the repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…all__ smoke test Update test_wtd_sample.py to import sample_along_lines and zonal_stats_corridors from pipelines.channel_attrs (the public package) rather than the submodule, so the __init__.py export path is exercised. Add test_public_api_exports_all_symbols to test_core.py to assert every name in __all__ is importable and callable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
Test plan
🤖 Generated with Claude Code