Stop fabricating a surface dewpoint when the observation is missing #15
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/surface-dewpoint-no-fabrication"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
SoundingParams.derive/1reported a surface dewpoint that was never measured (surface_dewpoint_c: sfc["dwpc"] || sfc["tmpc"] - 10). The entry filter dropped every level without a dewpoint, so the real surface was discarded and the first dewpoint-bearing level (925 hPa) was silently reported as "the surface" — a profile whose surface has no dewpoint reportedsurface_dewpoint_c == 12.0, reproduced before the fix. (tmp/bugs.md§B, P2; the root cause is the level filter, not the dead fallback.)derive/1keeps every level withpres/tmpc/hght(nil-dewpoint levels included), still requires at least three levels carrying a dewpoint, and reports the true surface's dewpoint asnil.Scorer.score_td_depression/3andScorer.absolute_humidity/2gained explicit nil clauses (neutral 50 / nil) rather than raisingArithmeticErroron the now-possible missing value.compute_k_indexno longer substitutes-30.0for missing 850/700 hPa dewpoints; the index is nil when it is genuinely undefined, and every consumer already renders nil as "no value".PathCompute.build_scoring/2logs when it returns{nil, nil}(profile/temp/dewpoint counts plus the reason) instead of dropping the failure silently. No scoring behaviour changed; the loss-budget 7.5 g/m³ humidity fallback is untouched.rust/prop_grid_rs/src/sounding_params.rsalready models the dewpoint asOption, and the quantities it derives are unchanged, so the Elixir/Rust mirror stays exact.Verification
lib/restored and the new assertions in place, 5 assertions fail (surface_dewpoint_c12.0 instead of nil,k_index37.0 instead of nil,ArithmeticError: 80.0 - nil); with the fix, 77 passed — green on three seeds.surface_dewpoint_c/dewpoint_f/k_indexwas audited for an explicit nil path; the list is in the commit context.mix credo --strict: no issues;mix format --check-formattedon touched files: clean.Flagged, not fixed
Microwaveprop.Propagation.Recalibrator(dev/test-only) still invents values —absolute_humidity(...) else 10.0andscore_td_depression(temp_f || 70, dewpoint_f || 60, ...).build_path_conditions/2averages each field over the profiles that observed it, one bucket per field, so a cell with no surface dewpoint still moves the T−Td pair through its temperature (thepath_integrated_conditions/2test "averages only the profiles that observed a dewpoint" pins exactly that). Long-standing bucket behaviour rather than something this PR introduced — pairing per profile would move scores on mixed paths and change the golden fixture, so it is now documented at the decision point instead of changed here.Review follow-up (
98637821)Rebased onto
main(#12 is in), which also clears the merge conflict.Precipitable water (High) — fixed. The pair guard is unreachable while the entry filter drops dewpoint-less levels, and once they are kept it deletes the whole layer between the two dewpoint-bearing neighbours.
compute_precipitable_water/1now pairs only the dewpoint-bearing levels, so the trapezoid spans the gap and only the missing levels' own spans are excluded. New tests pin theno_upper_dewpointscolumn at 29.2 mm (was 11.8 mm) and assert it equals the same sounding with the dewpoint-less levels deleted. Againstmain, the value is unchanged — that filter produced the same arithmetic before this PR, so this restores the pre-PR number rather than inventing a third one.Surface refractivity datum (Warning) — fixed on both sides.
derive/1reported N from the lowest level that has a dewpoint; with the surface now allowed to have none, one row carried 1013 hPa surface fields beside a 925 hPa N. It is now nil unless the observed surface reported a dewpoint, matchingsurface_dewpoint_c, andrust/prop_grid_rs/src/sounding_params.rs::surface_refractivityreturnsNonefor the same input instead of the lowest usable level. Both Elixir call sites share onerefractivity_n/3so the profile and the surface datum cannot drift.Blast radius was measured before touching the Rust side:
hrrr_profiles: 0 of 132,613 rows in a day (2026-09-18) have a surface level without a dewpoint in the stored profile, so the Rust change is inert on current HRRR data rather than blanking the map layer.soundings: 18,709 of 29,474 rows are already nil in bothsurface_dewpoint_candsurface_refractivity— zero rows carry one without the other, so no stored pairing changes.Bucket averaging (Suggestion) — documented, not changed. You called it pre-existing and defensible; I agree, and changing it would move scores for mixed paths and regenerate the golden fixture, which is a scoring decision rather than a review fix.
build_path_conditions/2now says so at the point of decision, and the PR body lists it under "Flagged, not fixed".Verification (this revision)
mix test test/microwaveprop/weather test/microwaveprop/propagation→ 1316 passed.make precommit→ green (4940 passed, 6 skipped; credo, format, xref clean).cargo clippy --all-targets -- -D warningsclean;cargo test --releasegreen — 261 lib tests plusscorer_goldenparity, including the two new Rust datum tests.🤖 Skippy PR review
3 findings — 2 blocking before merge.
lib/microwaveprop/weather/sounding_params.ex:43lib/microwaveprop/weather/sounding_params.ex:127test/microwaveprop/propagation/scorer_test.exs:203Reviewed
81dacb1997a4. Commentskippy reviewto re-run.@ -36,3 +43,1 @@|> Enum.filter(fn p ->p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil and p["dwpc"] != nilend)|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)🟠 High — Keeping nil-dewpoint levels silently understates precipitable water
compute_precipitable_water/1still reduces over adjacent pairs ofsortedand skips any pair whose endpoint has no dewpoint, but that guard was unreachable before this diff: the old entry filter guaranteed every retained level had a dewpoint, so dewpoint-bearing levels formed pairs across the dropped ones (a trapezoid over the whole column). Now both pairs around a missing-dewpoint level are skipped and the entire layer span between its dewpoint-bearing neighbours contributes nothing.On this PR's own
no_upper_dewpointsfixture (dwpc at 1000/900/600/500, absent at 850/700) PW falls from 29.2 mm to 11.8 mm, a 60% dry bias that lands insoundings.precipitable_water_mmand in the PWAT rail on /skewt. Fix: reduce over the dewpoint-bearing levels (Enum.filter(sorted, fn p -> p["dwpc"] != nil end)), or keep the trapezoid across the gap, so only the missing level's own span is excluded. Thedo_derive/1comment added here says PW filters the dewpoint-less levels out; it does not, it drops whole pairs.@ -115,6 +124,9 @@ defmodule Microwaveprop.Weather.SoundingParams doenddefp compute_refractivity_profile(sorted, sfc_hght) do# Only levels with a dewpoint contribute N — refractivity is a🟡 Warning — surface_refractivity now describes a different level than the rest of the surface fields
sfc_nis the first entry of the dewpoint-filtered refractivity profile, so with the surface now allowed to have no dewpoint it is taken from whatever level does. In this PR's fixture that is 925 hPa (about 630 m AGL), whilesurface_pressure_mb,surface_temp_candsurface_dewpoint_cin the same result describe the true 1013 hPa surface: one row, two datums. That value is stored inhrrr_profiles.surface_refractivityand published as thesurface_refractivityweather-map layer.Either return nil when
hd(sorted)["dwpc"]is nil, or rename the field so the datum is explicit.rust/prop_grid_rs/src/sounding_params.rs::surface_refractivity/1picks the same level today, so the Elixir/Rust mirror has to change together.@ -146,0 +200,4 @@cond_map = Scorer.path_integrated_conditions(profiles, @contact)# Temperature still averages over both cells (25 and 15 → 20 °C).assert_in_delta cond_map.temp_f, 68.0, 0.1🔵 Suggestion — The path aggregation this test pins still mixes temperature and dewpoint populations
This asserts temperature averaged over both cells (25 and 15) against a dewpoint averaged over one (5), so the pair the band factors score is 20 C / 5 C: a 15 C depression that neither cell reported. A dewpoint-less cell therefore still bends the T-Td and humidity factors through its temperature, which is the half of the fabrication the new CHANGELOG entry claims is gone.
Pre-existing behaviour, so accepting it is defensible, but if the intent is that a missing dewpoint contributes nothing to humidity, drop those profiles from the temperature average too (or average per-profile pairs) and update this assertion.
81dacb19979863782152Fixed in
98637821(rebased ontomain; the conflict is gone).PW: you were right, and the fixture is the one you named. With the dewpoint-less levels retained, both pairs around each of them are dropped, so the 900→600 hPa layer vanished: 29.2 mm → 11.8 mm.
compute_precipitable_water/1now pairs only the dewpoint-bearing levels, which is both the correct trapezoid and the pre-#15 arithmetic (onmainthe entry filter removed those levels first, so theif lower/upper != nilguard was dead there — exactly as you said). New assertions: PW is 29.2 mm for the gapped profile and equal to the same sounding with the gap levels deleted.Surface refractivity: nil at the true surface, Elixir and Rust together.
derive/1now reports N only when the observed surface reported a dewpoint;sounding_params.rs::surface_refractivityreturnsNonerather than the lowest level that carries one, and both Elixir call sites share onerefractivity_n/3. Yourmixed_dwpcfixture assertion is inverted accordingly (surface_refractivity == nil).I measured the Rust side's cost before changing it: 0 of 132,613
hrrr_profilesrows in a recent day have a surface level without a dewpoint, so the HRRR-written column is unchanged in practice; and insoundings, 18,709 of 29,474 rows are already nil in bothsurface_dewpoint_candsurface_refractivity, with zero rows carrying one without the other. Theplanes.rs"fallback for when derive yields nil" comment is still true for every cell the corpus actually produces.The bucket-averaging suggestion: declined as a change, recorded as a decision —
build_path_conditions/2now documents that a dewpoint-less cell still moves T−Td through its temperature, and the PR body lists it under "Flagged, not fixed". Making it per-profile pairing would move scores on mixed paths and regenerate the golden fixture, which belongs in its own PR with its own validation.Verification: 1316 weather/propagation tests green,
make precommitgreen (4940 passed), clippy clean,cargo test --releasegreen includingscorer_golden.Merge-order note: #13 also adds a
###entry under## 2026-09-19inCHANGELOG.md, so the two will conflict textually at the same insertion point — keep both entries. #14 touchesscripts/recalibrate.pyand #15 does not, so those two are independent. Happy to rebase whichever lands last.skippy review
98637821526a7252a1b1Housekeeping: the follow-up commit is now
6a7252a1(the SHA above predates an amend that dropped the worktreedepssymlink from the index). Same content otherwise — four files, nodeps.🤖 Skippy PR review
2 findings — 2 blocking before merge.
deps:1depspoints into your Mac's filesystemlib/microwaveprop/weather/sounding_params.ex:106surface_refractivityis now a normal value, but the backfill uses NULL as "not derived yet"Findings outside the commentable diff
deps:1— Committed symlinkdepspoints into your Mac's filesystemThis commit adds a mode-120000 symlink at the repo root whose target is
/Users/graham/dev/ntms/prop/deps..gitignorecannot stop it: the rule is/deps/, which only matches a real directory, so a symlink nameddeps(which is not a directory to git) slips throughgit add -A. On every checkout that is not your machine (CI runner, Docker build context, a second box)depsis a dangling symlink, and nothing can be created or written under it: verified thatmkdir -p deps/fooand writingdeps/xboth fail with ENOENT/"File exists" on a dangling symlink, which is exactly whatmix deps.getand compilation do. Fix:git rm --cached depsand change the ignore rule todeps(no trailing slash) or add/depsas well.Force-pushed since
81dacb1997a4(that head is gone from the branch), so this is the full PR diff again. Resolved all 3 earlier findings: PW now spans the dewpoint gaps instead of dropping whole pairs (29.2 mm on the fixture), surface N is taken from the true surface or nil, and the mixed-population path average is now an explicit, documented decision pinned by a test. 0 earlier findings still open.Reviewed
986378215286. Commentskippy reviewto re-run.@ -93,3 +106,1 @@case refract_profile do[first | _] -> first.n_ -> nilif sfc["dwpc"] != nil do🟡 Warning — nil
surface_refractivityis now a normal value, but the backfill uses NULL as "not derived yet"Making
surface_refractivitynil when the surface level has no dewpoint (and persisting the same None from Rust) turns NULL from "never derived" into a legitimate stored result.Hrrr.backfill_hrrr_scalars/1still keys on it:backfill_hrrr_batch/1selectswhere is_nil(h.surface_refractivity)(lib/microwaveprop/weather/hrrr.ex:223), updates it with whateverderive/1returns, and the caller loops until a batch returns 0 rows. A row whose surface genuinely has no dewpoint is updated to NULL, stays matched, and comes back in the next batch, so the loop no longer terminates early: every invocation now walks the fullmax_batches(default 1000 batches of 500) re-deriving and re-updating the same rows instead of stopping when the work is done. Bound the selection so it can converge, e.g.where: is_nil(h.surface_refractivity) and h.inserted_at < ^cutofffor the shipped-without-scalars era, or add ascalars_derived_atcolumn and key on that.🤖 Skippy PR review
2 findings — 2 blocking before merge.
deps:1depsdirectorylib/microwaveprop/weather/sounding_params.ex:107Findings outside the commentable diff
deps:1— A symlink to your local Mac checkout got committed as the repo'sdepsdirectoryThis lands
depsas a symlink whose target is/Users/graham/dev/ntms/prop/deps, an absolute path that exists on no other machine..gitignorehas/deps/with a trailing slash, which only matches a directory, so the symlink walked straight past it. Every other checkout (the Linux box, CI) now gets a danglingdepsentry in exactly the place Mix reads and writes its dependency tree, somix deps.get/mix compilecannot usedeps/there. It is also unrelated to this PR's change.git rm --cached depsand adddeps(no slash) to.gitignore.Re-review at
98637821(full PR diff; the earlier review's inline comments are anchored on the pre-rebase head81dacb19). Resolved 2 of 3 earlier findings, both fixed in this commit: the precipitable-water trapezoid now pairs only dewpoint-bearing levels (29.2 mm, matching the gap-deleted sounding), and surface N is nil on the surface datum in Elixir and Rust. 1 still open: the path T-Td pairing, now documented as deliberate bucket behaviour. 2 new findings below.Reviewed
986378215286. Commentskippy reviewto re-run.@ -94,2 +106,2 @@[first | _] -> first.n_ -> nilif sfc["dwpc"] != nil dorefractivity_n(sfc["pres"], sfc["tmpc"], sfc["dwpc"])🟡 Warning — NULL surface N is now also the backfill's "not derived yet" marker, so those rows never leave its queue
surface_refractivityis legitimately nil for a derived row whenever the surface level has no dewpoint, butHrrr.backfill_hrrr_scalars/1useswhere is_nil(h.surface_refractivity)as its todo filter and halts only when a batch returns 0 updates, writing the derived value straight back (SET surface_refractivity = v.surface_refractivity). A row updated to NULL is re-selected on the next pass, so one such row keeps the loop running allmax_batches(1000 batches x 500 rows of select + update) and it never converges. Before this changederive/1could only return a map with a non-nil surface N — the refractivity profile was non-empty whenever the >=3-dewpoint guard passed — so NULL reliably meant "not derived yet" (rows wherederive/1returns nil were already stuck). Give the backfill its own completion marker (derived_attimestamp) instead of overloading the value column.🤖 Skippy PR review
1 finding — none blocking.
lib/microwaveprop/weather/sounding_params.ex:106Follow-up on
6a7252a: 2 of 3 earlier findings fixed and resolved (PW now spans the dewpoint gaps - 11.8 -> 29.2 mm, verified arithmetically against the fixture; surface N moved to the observed-surface datum on both the Elixir and Rust sides); the third resolved as consciously documented in build_path_conditions/2. Nothing blocking is open.Reviewed
6a7252a1b1bf. Commentskippy reviewto re-run.@ -93,3 +106,1 @@case refract_profile do[first | _] -> first.n_ -> nilif sfc["dwpc"] != nil do🔵 Suggestion — The NULL-refractivity backfill can no longer converge
With the datum now on the observed surface, a dewpoint-less surface reports
surface_refractivity: nil- andHrrr.backfill_hrrr_scalars/1uses that NULL as its work queue:where([h], is_nil(h.surface_refractivity)), batched, halting only when a pass derives nothing (hrrr.ex:212-226). Such a row now does derive (a map comes back, so it is counted inlength(updates)) but writes NULL again, so it is re-selected on every pass: the loop re-updates the same rows and bumps theirupdated_atfor allmax_batches(default 1000) instead of halting, which is the convergence the docstring promises. Inert on today's data (nohrrr_profilesrow has a dewpoint-less surface), but those rows are exactly what this PR makes legitimate.Fix: keep rows the derivation cannot fill out of the selection - also require a dewpoint at the highest-pressure level, or track attempted ids - so
backfill_hrrr_batch/1can return 0.Bookkeeping correction, then the state of this PR.
prior, so the note at the bottom of this run's review ("nothing blocking is open") is wrong, and its suggestion (6480) duplicated them. 6471 and 6480 were resolved as duplicates of 6469, which stands:backfill_hrrr_scalars/1keys its work queue onsurface_refractivity IS NULL, so a row whose surface has no dewpoint is derived, written back as NULL, and re-selected on every pass. That open warning is what this check is red for.depssymlink finding in reviews 621/622 is moot now.depsis not in the tree at6a7252a1, nor inmain; it existed only in the superseded head986378215286. Nothing to do.no_upper_dewpointsfixture, verified against the same sounding with those levels deleted), surface N sits on the observed-surface datum in bothsounding_params.exand its Rust mirror, and the mixed-population path average is now an explicit, documented decision.Fixed in
50e7e998.Convergence sentinel.
do_derive/1always setsducting_detectedto a concrete boolean (ducts != []) whenever it derives at all, regardless of whethersurface_refractivitycomes back nil — so a row transitionsducting_detectedfrom NULL to non-NULL on its first pass no matter what.backfill_hrrr_batch/1's WHERE clause now requires bothis_nil(h.surface_refractivity) and is_nil(h.ducting_detected), so a row with a legitimately-nil surface N still leaves the selection after one pass instead of being re-derived and re-updated on every subsequent batch forever.Second bug found while writing the required test. The raw
UPDATE ... FROM (VALUES ...)inbuild_values_row/3had no explicit type cast on thesurface_refractivity/min_refractivity_gradient/ducting_detected/duct_characteristicsplaceholders. A batch whose only row has an all-NULLsurface_refractivity— exactly the scenario this PR makes legitimate — leaves Postgres unable to infer the untyped NULL parameter's type, defaults it totext, and the UPDATE raisesdatatype_mismatchinstead of writing NULL. Added explicit::float8/::float8/::boolean/::jsonb[]/::timestampcasts (jsonb[]matches the actualduct_characteristicscolumn type per the partition migration). This would have crashed real backfill runs on the same input this PR introduces, so it's fixed alongside rather than filed separately.New test: inserts one profile with a dewpoint-less surface (3 other levels carry a dewpoint,
derive/1's minimum), asserts the firstbackfill_hrrr_scalars/1call returns 1 and the row now hassurface_refractivity: nilwith a realducting_detectedboolean, then asserts a second call returns 0.Verification: reverted just the WHERE-clause fix (keeping the SQL-cast fix so the test could run at all) and reran — the single row was re-derived and re-updated on every one of 5 batches instead of halting after the first, reproducing the finding directly. Restored the fix;
test/microwaveprop/weather/untested_functions_test.exsgreen (59 passed);mix formatclean on both touched files.skippy review
🤖 Skippy PR review
1 finding — 1 blocking before merge.
lib/microwaveprop/weather/hrrr.ex:232Follow-up on
50e7e998. Resolved the earlier open warning (6469): the batch loop now converges, since a row that derives successfully leaves the selection. The finding below supersedes it: the sentinel it keys on is never NULL on the rows this backfill was written for.Reviewed
50e7e99810a8. Commentskippy reviewto re-run.@ -221,3 +230,3 @@rows =HrrrProfile|> where([h], is_nil(h.surface_refractivity))|> where([h], is_nil(h.surface_refractivity) and is_nil(h.ducting_detected))🟡 Warning — The new convergence sentinel is never NULL in production, so the backfill stops repairing anything
ducting_detectedisboolean DEFAULT false(priv/repo/migrations/20260329204441_create_hrrr_profiles_and_add_hrrr_queued.exs:19, carried into the partitioned table by20260401154846_partition_hrrr_profiles.exs:49) and the pre-3f2d9773Rust upsert listed its columns explicitly without it (rust/prop_grid_rs/src/hrrr_points.rs), so the rows this function exists for ("the Rust worker shipped without deriving these scalars - the DB scalars are NULL", per the docstring above) aresurface_refractivity IS NULL AND ducting_detected = false, not NULL.is_nil(h.ducting_detected)is therefore false for every one of them and the batch returns 0 on its first pass: an unprocessed legacy row keeps its NULLsurface_refractivity/min_refractivity_gradientforever, which is the "N: / dN/dh: -" state3f2d9773fixed for newly written rows. No writer inlib/ever puts nil in that column either (grep -rn "ducting_detected: nil" lib/is empty), so the only rows the new WHERE can match are ones a test inserted by hand - which is exactly what the new test does (ducting_detected: nil), so it passes without exercising the production shape. The convergence goal is met (a row that derives successfully does leave the set), so key on a column that has both properties:is_nil(h.min_refractivity_gradient)(no DB default, non-nil on every successful derive), or add ascalars_derived_atmarker and select on that. One query settles the scale:select count(*) from hrrr_profiles where surface_refractivity is null and ducting_detected is not null.🤖 Skippy PR review
1 finding — 1 blocking before merge.
lib/microwaveprop/weather/hrrr.ex:232Re-review at
50e7e998(same head as prev_sha; no earlier finding was postable as fixed). Convergence itself is fixed: the batch loop now stops re-selecting a row whose derived surface N is legitimately nil. The new WHERE conjunct is what I am blocking on - see the finding. 1 earlier finding (6469) left open: it is the same NULL-means-not-derived role for the same column, now failing in the other direction.Reviewed
50e7e99810a8. Commentskippy reviewto re-run.@ -221,3 +230,3 @@rows =HrrrProfile|> where([h], is_nil(h.surface_refractivity))|> where([h], is_nil(h.surface_refractivity) and is_nil(h.ducting_detected))🟠 High — The sentinel column never holds NULL, so the backfill can no longer see the rows it was written for
ducting_detectedisboolean DEFAULT false(priv/repo/migrations/20260401154846_partition_hrrr_profiles.exs:49) andHrrrProfiledefaults it to false, so no writer ever stores NULL there: the pre-3f2d9773 Rust worker omitted the column from its INSERT entirely (git show 3f2d9773^:rust/prop_grid_rs/src/hrrr_points.rs, the column list has no scalar columns), and thehrrr_backfillchangeset path takes the default. Every row this function exists for (raw levels,surface_refractivityNULL, which is the "N: -" state the backfill was added in3f2d9773to repair) therefore carriesducting_detected = false, and the newand is_nil(h.ducting_detected)conjunct skips all of them: the repair returns 0, reports converged, and leaves their surface N NULL.The new test passes only because it inserts
ducting_detected: nilby hand, a state no production writer produces. Fix: give the backfill a real completion marker (scalars_derived_at) and key on that, or at minimum use a column with no default that is also NULL on the un-derived rows (is_nil(h.surface_refractivity) and is_nil(h.min_refractivity_gradient)). Convergence itself is fixed by this commit; the selection is what regressed.Bookkeeping for the two reviews that just landed on
50e7e998(711 at 10:29, 712 at 10:31): concurrent runs on the same head, and they agree.lib/microwaveprop/weather/hrrr.ex:232) is the canonical finding and stays open: the newis_nil(h.ducting_detected)conjunct keys on a column whoseDEFAULT falsemeans no writer ever stores NULL, so the backfill cannot see the un-derived rows it was written for. 7611 (the warning posted by review 711) is the same finding from the parallel run and is resolved as the duplicate - nothing was fixed in between, this is only de-duplication.priorbefore that resolution landed and so says 6469 is still open; ignore that line.Fixed in
39f71ba5. Finding 7625 was right:ducting_detectedhasDEFAULT falseat the SQL level (priv/repo/migrations/20260329204441_create_hrrr_profiles_and_add_hrrr_queued.exs:19, mirrored in the partition migration at20260401154846_partition_hrrr_profiles.exs:49/:129), and the Rust worker'sHrrrRowstruct (rust/prop_grid_rs/src/hrrr_points.rs:164) declaresducting_detected: bool, notOption<bool>- the UPSERT (lines 250-282) always writes a concrete boolean on every insert and update, past and present. No row inhrrr_profileshas ever been NULL inducting_detected. Sois_nil(h.surface_refractivity) and is_nil(h.ducting_detected)matched zero rows, forever - the backfill silently did nothing on every call, including for the legitimate legacy backlog it exists to fix. That's worse than the original non-termination: at least the old code did real work on each pass.Replaced the sentinel column with keyset pagination on
id.backfill_hrrr_batch/2now takes alast_idcursor (startsnil), orders byidascending, and filters toid > last_idonce a cursor exists, so each call makes monotonic progress through the matching rows regardless of what any individual row derives to - a row can staysurface_refractivity IS NULLforever (no surface dewpoint, orSoundingParams.derive/1returningniloutright for insufficient data) without blocking pagination past it.backfill_hrrr_scalars/1threads the cursor throughEnum.reduce_whileand halts once a page returns fewer rows thanbatch_size, not just when a page updates zero rows. WHERE clause is back to plainis_nil(h.surface_refractivity).Rewrote the test from
50e7e998that asserted on the (now gone)ducting_detectedtransition. It inserts two rows needing backfill - one whose surface has no dewpoint (derives a legitimately-nilsurface_refractivityand can never leave the predicate) and one that resolves normally - and callsbackfill_hrrr_scalars(batch_size: 1, max_batches: 5)to force multiple batches within one call. First call returns2(both rows advanced, cursor carried the loop across pages). Second call - fresh top-level invocation, no cursor persisted across separate calls, which is correct for what's documented as a manual one-off tool, not a scheduled worker - returns1: the still-nil row is re-selected and re-derived once, then the loop halts on the very next (empty) page instead of walking allmax_batches. That's the actual convergence guarantee: bounded work per call, not a persisted "never look at this row again."Verified:
MIX_TEST_PARTITION=pr15 EXUNIT_MAX_CASES=8 MIX_ENV=test mix compile --no-deps-check --warnings-as-errorsclean,MIX_TEST_PARTITION=pr15 EXUNIT_MAX_CASES=8 MIX_ENV=test mix test test/microwaveprop/weather/untested_functions_test.exs- 59 passed, 0 failures.mix format --check-formattedclean on both changed files.skippy review
Resolved 7625: the
is_nil(h.ducting_detected)conjunct is gone, so the backfill selects the rows it exists for again (is_nil(surface_refractivity)alone), and the keyset cursor onidplus the halt-on-short-page rule replace the old halt-on-zero-updates check, which is what makes a row that legitimately derives a nil surface N terminate instead of looping.Checked the new commit against the real shape of
hrrr_profiles: RANGE-partitioned onvalid_time, unique index on (lat, lon, valid_time), no index and no PK onid. Pages advance monotonically and a short page can only mean the predicate is exhausted past the cursor (rows that resolve leave it behind the cursor, never ahead); the UPDATE never re-admits a row the current page already passed.Map.get(derived, :surface_refractivity)still writes nil for the unresolvable rows, so they are re-derived once per invocation, which the new test pins deliberately. Nothing new in50e7e998..39f71ba5worth a comment. 0 findings still open.🤖 Skippy PR review
1 finding — 1 blocking before merge.
lib/microwaveprop/weather/hrrr.ex:242Re-review at
39f71ba5(the push after50e7e998). Resolved 1 earlier finding (7625): the WHERE clause no longer conjunctsis_nil(ducting_detected), so the legacy rows that holdsurface_refractivity IS NULLwith the SQL defaultfalseare visible to the backfill again, and the id keyset cursor is what guarantees the loop terminates now - both halves of that finding are fixed. 0 earlier findings still open; 1 new finding below, about the cursor itself.Reviewed
39f71ba581a4. Commentskippy reviewto re-run.@ -222,2 +239,4 @@query =HrrrProfile|> where([h], is_nil(h.surface_refractivity))|> order_by([h], asc: h.id)🟡 Warning — The keyset cursor has no index to walk, so every batch sorts the whole unresolved set
The keyset is the right fix, but nothing indexes it.
hrrr_profilesis partitioned byvalid_timeand after20260401154846_partition_hrrr_profiles.exscarries only the unique(lat, lon, valid_time),(valid_time, lat, lon)and a partial(valid_time) where is_grid_pointindex - the flat table's PK went away with the rename, so there is no index onidand none onsurface_refractivityeither.WHERE surface_refractivity IS NULL AND id > $cursor ORDER BY id LIMIT ntherefore cannot seek or merge: every batch appends all partitions, filters, and top-N-sorts the entire remaining matching set before returning one page, where the pre-PR query had no ORDER BY and could stop as soon as it hadlimitrows. Withmax_batches1000 xbatch_size500 that is up to 1000 increasingly-full sorts of exactly the set this backfill exists for (the rows the Rust worker shipped without scalars: 58k contacts plus the grid points), on the same Turing Pi 2 Postgres node the batching was added to protect. Fix: attach an index the cursor can walk -CREATE INDEX ... ON hrrr_profiles (id) WHERE surface_refractivity IS NULL, built per partition and ATTACHed on the parent the way20260421181818_add_hrrr_profiles_valid_time_latlon_index.exsdoes it - or give the backfill a real marker column (scalars_derived_at, written on every row a batch examines, null and therefore indexable on the untouched ones) and keep the unordered LIMIT scan.Fixed in
4c7a6a92.The keyset fix in
39f71ba5was correct on convergence, but the reviewer's follow-up (711/712/713) is right that it had nothing to seek:hrrr_profilesisPARTITION BY RANGE (valid_time), and the live partitioned table (20260401154846_partition_hrrr_profiles.exs) carries only(lat, lon, valid_time)unique,(valid_time), and the laterhrrr_profiles_surface_refractivity_null_idx(20260601202826) — that last one indexes the (always-NULL, under its own predicate)surface_refractivitycolumn, notid, so it can't serveORDER BY id. ThePRIMARY KEY (id)only exists on thedown()rollback table, never on the live one. SoWHERE surface_refractivity IS NULL AND id > $cursor ORDER BY id LIMIT nwas forcing a full scan + sort of the entire remaining unresolved set on every batch.Added
hrrr_profiles_null_surface_refractivity_id_index, a second partial index on(id) WHERE surface_refractivity IS NULL, built per-partition andATTACHed to the parent — the same CONCURRENTLY + attach dance20260421181818_add_hrrr_profiles_valid_time_latlon_index.exsalready uses for this exact table, so no long lock on the partitioned parent.Verification: migrated a scratch test DB partition, confirmed all 30 partition indexes attach cleanly (
mix ecto.migrate, no errors).EXPLAIN (ANALYZE, BUFFERS)onSELECT id, profile FROM hrrr_profiles WHERE surface_refractivity IS NULL ORDER BY id LIMIT 5now shows anIndex Scan using hrrr_profiles_<partition>_null_surface_refractivity_id_indexper partition instead of a sort node.test/microwaveprop/weather/untested_functions_test.exsstill green (59 passed) on a clean DB.skippy review
🤖 Skippy PR review
1 finding — none blocking.
priv/repo/migrations/20260920160000_add_hrrr_profiles_null_surface_refractivity_id_index.exs:33Resolved 1 earlier finding (7642): the keyset now has a partial index on (id) built per partition and ATTACHed to the parent, so the backfill's ORDER BY id LIMIT n is an index seek instead of a sort of the remaining unresolved set. 0 earlier findings still open; 1 new suggestion below, about the same index and the UPDATE the batch issues.
Reviewed
4c7a6a9219cb. Commentskippy reviewto re-run.@ -0,0 +30,4 @@def up doexecute """CREATE INDEX IF NOT EXISTS hrrr_profiles_null_surface_refractivity_id_indexON ONLY hrrr_profiles (id) WHERE surface_refractivity IS NULL🔵 Suggestion — The new index can't serve the UPDATE half of the loop, so every batch still scans the whole table
This index fixes the SELECT (seek on
id, no sort of the remaining set), but the same batch writes its results withUPDATE hrrr_profiles AS h ... FROM (VALUES ...) AS v(...) WHERE h.id = v.id, and Postgres only uses a partial index when the query's qualifiers imply its predicate.h.id = v.iddoes not implysurface_refractivity IS NULL, so this index is invisible to that statement: with no index onid(the flat table's PK went away in20260401154846), the only plan available is to scanhrrr_profilesacross all 30 partitions to match the 500-row VALUES list, once per batch, up tomax_batches1000 times on the Turing Pi 2 node the batching exists to protect. Two one-line fixes, either works: addAND h.surface_refractivity IS NULLto that UPDATE (the batch selected exactly those rows, so semantics are unchanged, and the partial index becomes usable), or drop the partial predicate so a plain(id)index serves both the filter/ordering and the update lookup.Separately, this index supersedes
hrrr_profiles_surface_refractivity_null_idxfrom20260601202826_add_performance_indexes.exs: same predicate, and it can serve a bareWHERE surface_refractivity IS NULLas well as the ordering the old one cannot. Keeping both means every insert and every one of these updates maintains two partial indexes over the same rows, so the old one is dead weight once this lands.Resolved 7642:
4c7a6a92adds20260920160000_add_hrrr_profiles_null_surface_refractivity_id_index.exs, a partial index on(id) WHERE surface_refractivity IS NULLbuilt per partition and ATTACHed to the parent, so the keyset query can seek inidorder instead of top-N sorting the whole remaining matching set on every batch. Checked that the cursor really had nothing to walk: no migration in the tree indexeshrrr_profiles.id(the flat table's PK went with the rename in20260401154846), and the pre-existinghrrr_profiles_surface_refractivity_null_idxkeys the always-NULL column under that predicate, so it could find matching rows but never order them.New commit is the migration alone (
git diff 39f71ba5..4c7a6a92is one file), no new findings, 0 blocking findings open. Check goes green.4c7a6a9219601d6ab401Superseded
4c7a6a92with601d6ab4(force-pushed — that commit had no review yet).You're right that a partial index on the predicate already existed:
hrrr_profiles_surface_refractivity_null_idx(20260601202826_add_performance_indexes.exs, added specifically for "backfill query scans for nulls"). It indexessurface_refractivity— a column that's constant (NULL) under its own predicate — so it can find the candidate rows but carries noidordering; it was never going to serveORDER BY id.is_nil(h.surface_refractivity)has no other consumer in the codebase (checked), so rather than carry a third index alongside it, this migration drops that index and replaces it with one on(id) WHERE surface_refractivity IS NULL— same predicate,idas the key, so the cursor'sid > $1 ORDER BY id LIMIT ncan seek instead of sort. Built per-partition withCONCURRENTLY+ATTACH PARTITION, matching20260421181818_add_hrrr_profiles_valid_time_latlon_index.exs's existing pattern for this exact table. Generated viamix ecto.gen.migrationthis time.Verification, done properly this time (my first pass's
EXPLAINwas against an empty test table, which the planner will seq-scan+sort regardless of index quality): forcedSET LOCAL enable_seqscan = offon a pinned connection viaRepo.transaction/1, then ranEXPLAIN (ANALYZE, BUFFERS)on the exact backfill shape (WHERE surface_refractivity IS NULL AND id > $1 ORDER BY id LIMIT 500). Plan isLimit -> Merge Append (Sort Key: id) -> Index Scan using hrrr_profiles_<partition>_null_surface_refractivity_id_indexper partition — grepped the plan text for an actualSortoperator node and found zero; the "Sort Key" line is Merge Append's own annotation describing what it's merging pre-ordered inputs on, not a separate sort step.test/microwaveprop/weather/untested_functions_test.exsgreen (59 passed) on a freshly created, uncontaminated test DB.skippy review
🤖 Skippy PR review
1 finding — none blocking.
priv/repo/migrations/20260920160342_replace_hrrr_profiles_null_surface_refractivity_index.exs:39Force-push: prev head
4c7a6a92is no longer in the branch, so the earlier suggestion (7660) pointed at a migration file this rewrite deletes; its duplicate-index half is fixed here (the old*_surface_refractivity_null_idxis dropped), and I resolved that thread and re-anchored the remaining half on the live migration. 1 suggestion open, none blocking.Reviewed
601d6ab4011a. Commentskippy reviewto re-run.@ -0,0 +36,4 @@execute """CREATE INDEX IF NOT EXISTS hrrr_profiles_null_surface_refractivity_id_indexON ONLY hrrr_profiles (id) WHERE surface_refractivity IS NULL🔵 Suggestion — The supersede half landed, but the batch UPDATE still cannot use this index
Dropping the duplicated
hrrr_profiles_surface_refractivity_null_idxcloses one half of the earlier suggestion; the other half stands. The batch write inHrrr.backfill_hrrr_batch/2isUPDATE hrrr_profiles AS h SET ... FROM (VALUES ...) AS v(id, ...) WHERE h.id = v.id, and Postgres only uses a partial index when the query's own qualifiers imply its predicate:h.id = v.iddoes not implysurface_refractivity IS NULL, so this index is invisible to that statement. With no other index onid(the flat table's PK went away in20260401154846), every batch scans all 30 partitions to match its 500-row VALUES list, up tomax_batches(1000) times, on the Turing Pi 2 node the batching exists to protect. Either addAND h.surface_refractivity IS NULLto that UPDATE (the batch selected exactly those rows, so semantics are unchanged), or drop the partial predicate so a plain(id)index serves both statements.🤖 Skippy PR review
1 finding — none blocking.
priv/repo/migrations/20260920160342_replace_hrrr_profiles_null_surface_refractivity_index.exs:35Second run on this head: review 719 landed the UPDATE/index half (which supersedes and resolves 7660). The suggestion below is new and is about this migration's ordering. 1 suggestion open, none blocking.
Reviewed
601d6ab4011a. Commentskippy reviewto re-run.@ -0,0 +32,4 @@@disable_migration_lock truedef up doexecute "DROP INDEX IF EXISTS hrrr_profiles_surface_refractivity_null_idx"🔵 Suggestion — The DROP commits before the replacement exists, so a failed build leaves the table with no index at all
up/0dropshrrr_profiles_surface_refractivity_null_idxas its first statement, and@disable_ddl_transaction truemeans that drop commits on its own, before a single partition index exists. If aCREATE INDEX CONCURRENTLYin the loop below fails (lock timeout, cancellation, deploy restart) or the migration is interrupted, the only index servingWHERE surface_refractivity IS NULLis already gone: the backfill's SELECT goes back to sorting the whole unresolved set on every batch, and any other null-filter query seq scans, until the migration is retried. Move the drop to the end ofup/0, after the ATTACH loop, so the new index is live before the old one goes; carrying both for the duration of the build is a transient write cost, not a correctness one.601d6ab401988d25cf62Rebased onto
mainat988d25cf(#13 and #14 both merged, so this clears the merge conflict — the CHANGELOG.md collision at the## 2026-09-19insertion point was exactly the one flagged in the earlier merge-order note; kept both entries, per-#13's above #15's, with a blank line separator between them).No code changes in the rebase — same three commits as before (
ducting_detectedfix → keyset pagination → the superseding id index), just replayed onto the new base. Verified after rebase:hrrr_profilespartitions.test/microwaveprop/weather/untested_functions_test.exs,sounding_params_test.exs,scorer_test.exs,path_compute_test.exs— 135 passed.mix format --check-formattedclean repo-wide.cargo test --release sounding_params— 8 passed (Rust mirror unaffected by the rebase; this PR doesn't touch anything #13/#14 changed).cargo clippy --all-targets -D warningshas 4 pre-existing failures (chunks_exact→as_chunksindecoder.rs/pgrid.rs/sgrid.rs) — confirmed identical onmainitself with the same local toolchain, unrelated to this PR (none of those files are touched here).skippy review
Rebase check, not a re-review:
988d25cfis601d6ab4re-based ontomain(it carries #12's CHANGELOG / validation-report entries). Every file this PR touches is byte-identical between the two heads, so there is nothing new to review and nothing to resolve.The two open suggestions therefore stand as posted (their inline anchors are on
601d6ab4011a, which is no longer in the branch):priv/repo/migrations/20260920160342_replace_hrrr_profiles_null_surface_refractivity_index.exs:39- the batch write is stillUPDATE hrrr_profiles AS h ... WHERE h.id = v.id(lib/microwaveprop/weather/hrrr.ex:288), and Postgres will not use a partial index whose predicate (surface_refractivity IS NULL) those qualifiers do not imply, so the UPDATE still scans all partitions per batch.priv/repo/migrations/20260920160342_replace_hrrr_profiles_null_surface_refractivity_index.exs:35-up/0still dropshrrr_profiles_surface_refractivity_null_idxbefore the replacement exists.Both are suggestion severity, so nothing blocking is open and the check goes green.