Skip to content

Stabilize modeled-climatology change factors and fix CONUS CSV/JSON divergence - #739

Merged
cstephen merged 3 commits into
mainfrom
stabilize-climatology-ratios
Aug 11, 2026
Merged

Stabilize modeled-climatology change factors and fix CONUS CSV/JSON divergence#739
cstephen merged 3 commits into
mainfrom
stabilize-climatology-ratios

Conversation

@brucecrevensten

Copy link
Copy Markdown
Member

Background

Investigating arctic_hydrology/modeled_climatology/81009008 revealed that the JSON output reported a doy_min of 602,159.949 cfs for C2LE2 at doy 119 (2034–2065) — ~70× above its own doy_max — while the CSV export of the same endpoint showed entirely different numbers. Three distinct defects were isolated, all verified numerically against production:

  1. CSV/JSON divergence — the CSV branch returns before the GCM-diff adjustment, so CSV serves raw original-GCM values under a header claiming they're Blaskey/Maurer-adjusted. @cstephen's json_csv_discrepancy branch fixes this for the two Arctic routes; this PR intentionally leaves those hunks to that branch and fixes only the CONUS route (no conflicts — different files/regions).
  2. Ratio blow-up near zero — the change factor gcm_projected / gcm_historical explodes when the historical minimum is near zero (0.001 cfs winter flows on frozen streams). The one-sided 0 → 0.0001 floor makes this worse, not better. The 602,159.949 value is exactly 1.223 × (492.363 / 0.001).
  3. Internal inconsistency — each statistic is scaled by its own independent ratio, so adjusted triplets can violate min ≤ mean ≤ max even at ordinary magnitudes. For CONUS stream 50563, 409 of 115,656 adjusted entries were inconsistent (raw data: 0), e.g. doy 214 CCSM4/rcp26: adjusted min 353.0 > adjusted max 343.7.

Changes (one commit per fix)

Commit 1 — CONUS: apply Maurer adjustment before CSV export. Mirrors the Arctic fix on json_csv_discrepancy; both formats now serve the same adjusted data, matching the CSV header's description.

Commit 2 — Stabilized change factors (shared helper in postprocessing.py, used by both Blaskey and Maurer functions):

ratio = (projected + ε) / (historical + ε), clamped to [0.1, 10]
ε = 1% of the model's historical mean flow (floored at 0.0001)
  • The symmetric ε makes the factor approach 1 ("no change") as both values approach zero, instead of diverging — and it's scale-aware, so it behaves the same for headwater creeks and large rivers.
  • The [0.1, 10] cap is a backstop against noisy order statistics; ordinary ratios (e.g. mean flows on healthy baselines) are essentially unchanged (< 2% shift at 81009008).
  • Effect at 81009008 doy 119: C2LE2 doy_min goes from 602,159.949 → ≈ 12.2 cfs.

Commit 3 — Enforce min ≤ mean ≤ max after adjustment: clamp doy_min down to doy_mean and doy_max up to doy_mean.

Testing

  • tests/test_hydrology_adjustments.py (14 tests): reproduces the production doy-119 blowup and the doy-214 ordering violation with real production values; covers epsilon scaling, clamping, PGW-model skipping, and structure preservation for both adjustment functions.
  • Full suite: 33 passed; the 2 test_landslide failures pre-exist on main (require a live database).

Notes for review

  • ε fraction (1%) and cap (10×) are judgment calls — happy to tune. Rationale: the cap bounds fabricated values; ε controls how quickly small-flow ratios damp toward 1.
  • Follow-up candidates (not in this PR): the CONUS JSON serves each model's historical scenario as an exact copy of Maurer post-adjustment (redundant payload); the water-temperature additive adjustment can also produce min/mean/max crossings; a methodologically stronger long-term fix is computing change factors on 31-day windows (or applying deltas to the daily series upstream in arctic_rivers) — see the investigation writeup for details.

🤖 Generated with Claude Code

brucecrevensten and others added 3 commits July 31, 2026 13:11
The CSV branch returned before calculate_and_apply_gcm_diffs_to_maurer_climatology
ran, so format=csv served raw original-GCM values while JSON served the
Maurer-adjusted values -- under a CSV header claiming the values were adjusted.
Move the adjustment ahead of the CSV export so both formats serve the same data.

Companion to the arctic_hydrology fix on the json_csv_discrepancy branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Blaskey and Maurer adjustments scaled each statistic by
gcm_projected / gcm_historical with only a one-sided floor on the
denominator (0 -> 0.0001). When the historical minimum is near zero --
routine for frozen arctic streams in winter -- the ratio explodes: at
stream 81009008, doy 119, C2LE2's doy_min came out as 602,159.949 cfs
(1.223 * 492.363/0.001), far above its own doy_max.

Replace the raw quotient with a stabilized ratio shared in postprocessing:

  ratio = (projected + eps) / (historical + eps), clamped to [0.1, 10]

where eps is 1% of the model's historical mean flow, so the offset scales
from headwater creeks to large rivers. As both values approach zero the
factor now approaches 1 (no change) instead of diverging.

Unit tests reproduce the production blowup case and cover the epsilon
scaling, clamping, and structure handling of both adjustment functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Because each statistic is scaled by its own change factor, adjusted values
could violate basic ordering even at ordinary magnitudes: for CONUS stream
50563 (dynamic/CCSM4/rcp26, doy 214) the adjusted doy_min (353.042)
exceeded the adjusted doy_max (343.703), and 409 of 115,656 entries for
that stream were internally inconsistent. Order statistics have noisy
ratios -- a single flood day in the GCM historical era deflates the max's
factor without touching the min's.

After adjustment, clamp doy_min down to doy_mean and doy_max up to
doy_mean so every published triplet satisfies min <= mean <= max.

Tests cover the production doy-214 crossing case and a synthetic Blaskey
crossing case.

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

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.

Pull request overview

This PR addresses correctness issues in modeled hydrology climatology outputs by (1) ensuring CONUS CSV exports match JSON (both using the Maurer-adjusted data when requested) and (2) stabilizing the GCM change-factor math to prevent near-zero baseline blowups and enforce post-adjustment min ≤ mean ≤ max.

Changes:

  • CONUS modeled climatology: apply the Maurer adjustment prior to CSV serialization so CSV and JSON return the same adjusted values for source=gcm_diff_applied_to_maurer.
  • Introduce scale_aware_epsilon() and stabilized_ratio() (with caps) to prevent ratio explosions near zero and make the behavior scale-aware across streams.
  • Enforce doy_min ≤ doy_mean ≤ doy_max after applying per-stat ratios for both Arctic (Blaskey) and CONUS (Maurer) adjustments; add focused unit tests reproducing production failures.

Reviewed changes

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

File Description
tests/test_hydrology_adjustments.py Adds unit tests covering stabilized ratios, ordering enforcement, PGW skipping, and structure preservation for both adjustment paths.
routes/conus_hydrology.py Stabilizes Maurer adjustment ratios, clamps stat ordering, and moves adjustment before CSV generation to eliminate CSV/JSON divergence for CONUS.
routes/arctic_hydrology.py Stabilizes Blaskey adjustment ratios and clamps stat ordering to prevent extreme/invalid adjusted outputs.
postprocessing.py Adds shared constants plus scale_aware_epsilon() and stabilized_ratio() helper functions for robust ratio-based adjustments.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cstephen

cstephen commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

I checked a bunch of arbitrary streams (for both CONUS and Alaska) from both futurehydrology.org and my local webapp, pointed at the stabilize-climatology-ratios Data API branch, to confirm that the new math improves the hydrographs. Indeed it does!

Here are some side-by-side URLs with old math vs. new math, and in every case it's an improvement:

https://futurehydrology.org/alaska/stream/81024981
http://localhost:3000/alaska/stream/81024981

https://futurehydrology.org/alaska/stream/81010040
http://localhost:3000/alaska/stream/81010040

https://futurehydrology.org/alaska/stream/81007312
http://localhost:3000/alaska/stream/81007312

https://futurehydrology.org/alaska/stream/81010509
http://localhost:3000/alaska/stream/81010509

https://futurehydrology.org/alaska/stream/81000040
http://localhost:3000/alaska/stream/81000040

https://futurehydrology.org/alaska/stream/81019526
http://localhost:3000/alaska/stream/81019526

https://futurehydrology.org/alaska/stream/81007411
http://localhost:3000/alaska/stream/81007411

https://futurehydrology.org/conus/stream/39046
http://localhost:3000/conus/stream/39046

https://futurehydrology.org/conus/stream/7532
http://localhost:3000/conus/stream/7532

https://futurehydrology.org/conus/stream/33308
http://localhost:3000/conus/stream/33308

https://futurehydrology.org/conus/stream/30118
http://localhost:3000/conus/stream/30118

https://futurehydrology.org/conus/stream/56453
http://localhost:3000/conus/stream/56453

I found one stream whose hydrographs got worse with the change, but after closer inspection, this turned out to be a problem with the webapp's client-side LOWESS smoothing, not the Data API data, and can be fixed by changing the LOWESS smoothing span (f) on the webapp side (with more testing on the webapp side of course):

https://futurehydrology.org/conus/stream/53799
http://localhost:3000/conus/stream/53799

@charparr charparr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OK, I've gone through the logic in postprocessing.py and the implementation in the routes pretty carefully. I think we are nailing the desired behavior here...the stabilized ratio (projected + epsilon) / (historical + epsilon) is shrinking poorly constrained low-flow change factors toward a value of 1 (i.e. no change) but converging on the established ratio when the baseline is large relative to epsilon. I think the tests coverage is a nice addition here because we've it covers the blowups we saw before, and the min/mean/max ordering which is an important part of this PR. I did leave some comments that are mostly regarding the assumptions that we are baking into this code...and maybe these should get documented somewhere / ticketed for later consideration if they need to be conveyed to users. The implementation is strong and I have zero issues merging this as is.

Comment thread postprocessing.py
Comment on lines +277 to +278
RATIO_EPSILON_FLOOR = 0.0001
RATIO_CAP = 10.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One immediate question I have is how frequently are the floor / cap values triggered? If these values are returned frequently then they might need adjustment. It seems like they are intended to catch outliers which I think is the right approach but it could be worth an empirical test before all is said and done. I do see that Claude called these out already as "judgement calls" that Claude is "happy to tune".

Comment thread postprocessing.py
"""
if not values:
return RATIO_EPSILON_FLOOR
epsilon = RATIO_EPSILON_FRACTION * (sum(values) / len(values))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One choice I see embedded here is that the arithmetic mean is the value used to scale the epsilon. This makes sense to me because it is easy to explain and to conceptualize: big historical flows get bigger epsilons, and smaller historical flows get smaller epsilons.

But, are there any streamflow regimes for which this might have unanticipated consequences? Imagine a stream where the flow is: [small, small, small, BIG BIG GUSH O WATER, small, small, ...] : the mean may not scale the epsilon value in a desirable way because the big flow might get only get scaled a tiny bit.

Down the road it may be worthwhile exploring the use of the median the scale factor, or some other statistic.

And/or just adding some more text along the lines of
"Hey for a stream that is highly variable or seasonal, a spike in flow is going to raise the mean, which will raise the epsilon for the entire year's worth of flows.

doy_stats[stat] = blaskey_adjusted
# each stat is scaled by its own ratio, so the adjusted values
# can cross; clamp to preserve min <= mean <= max
if all(k in doy_stats for k in ("doy_min", "doy_mean", "doy_max")):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good comment on the "why" -- feels like we'll come back to this

Comment thread routes/conus_hydrology.py
Comment on lines +845 to +855
# each stat is scaled by its own ratio, so the adjusted
# values can cross; clamp to preserve min <= mean <= max
if all(
k in doy_stats for k in ("doy_min", "doy_mean", "doy_max")
):
doy_stats["doy_min"] = min(
doy_stats["doy_min"], doy_stats["doy_mean"]
)
doy_stats["doy_max"] = max(
doy_stats["doy_max"], doy_stats["doy_mean"]
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is identical to R543 to R551 in routes/arctic_hydrology.py

And thematically it feels like postprocessing. Consider consolidating and moving to the post-processing module. If nothing else the route is already 1000+ LOC so it could be nice. However, this suggestion is not functional so just take it under consideration.

@cstephen
cstephen merged commit 4682615 into main Aug 11, 2026
1 of 2 checks passed
@cstephen
cstephen deleted the stabilize-climatology-ratios branch August 11, 2026 21:52
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.

4 participants