Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
+ `if/else` with a classifier that pattern-matches the error reason
directly, returning `:transient | :permanent`, and dispatch via
`case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
step on the row/col comprehensions so an image box whose centre
lands outside the raster collapses to an empty iteration instead
of firing Elixir 1.19's ambiguous-range warning. Adds a regression
test for the right-edge box.
Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
duct_thickness, best_duct_freq, duct_usable_for_band,
distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
handling; parse_snmpget_output OID normalisation and junk-line
tolerance.
- HrrrNativeClient native_level_count, native_variables,
native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
monotonic in RH, finite across the operating envelope) + idempotent
nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
output; latlon_to_pixel round-trip for every grid cell.
Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.
Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
Phase 3 Stream A: Rust's prop-grid-rs writes f00 profile files as
MessagePack (c4f309c). Elixir needs to read both formats during the
cutover window and indefinitely afterward — .mp.gz wins when both
exist.
Reader:
- path_for/1 stays on .etf.gz (Elixir legacy writer)
- mp_path_for/1 returns the .mp.gz sibling Rust lands
- read/1 prefers .mp.gz, falls back to .etf.gz, :enoent if neither
- decode_mp_body handles the top-level {v, valid_time, cells} shape
- normalize_profile atomizes a whitelist of known keys (surface_*,
native_min_gradient, ducts[], profile[] levels, ...) and leaves
everything else as strings so no String.to_atom bomb is possible
- Directory scan picks up both extensions so retain_window and
prune_older_than also work uniformly; list_valid_times dedupes
by timestamp
Adds msgpax ~> 2.4 dependency. 4 new tests cover format preference,
key atomization, :enoent path, and dedupe.
The persisted grid_data files contain atoms (:base_m, :top_m,
:thickness_m, :min_freq_ghz, :native_min_gradient, :ducts, etc.) that
live in modules which aren't eagerly loaded during application start.
warm_grid_cache_from_latest_profile runs in Application.start/2 before
those modules get pulled in by their first call sites, so [:safe] was
rejecting our own legitimate files at boot — every weather page hit
crashed with "invalid or unsafe external representation of a term".
The whole point of [:safe] is to refuse atoms from untrusted external
ETF input. ProfilesFile.write!/2 in this same module is the only writer
of these files, so the input is trusted by definition — drop the flag.
The test additions round-trip the production data shape including duct
fields as a regression guard.
Two related UI changes driven by the same data:
* Move the selected-layer description out of the sidebar and into a
dedicated top-right overlay on the map. The sidebar kept the same
layer buttons but dropped the descriptive text; the new overlay
shows group + layer name + description in one card. Mobile still
shows the description in the collapsible panel since it has no
top-right free real estate.
* Add a forecast-hour timeline bar at the bottom of the map, matching
the propagation map. WeatherMapLive enumerates ProfilesFile on mount
(the grid worker has been persisting f00..f18 on disk since
commit 07ffcf5), pushes data-valid-times for the JS hook to render
as buttons, and handles a new select_time event by reading the
per-hour ProfilesFile on demand via Weather.weather_grid_at/2.
weather_grid_at/2 deliberately skips the GridCache write path for
non-latest hours — caching 18 × 92k rows would add ~300 MB per pod.
A ~2 MB ETF decode per scrub is fast enough for a click.
Subscribes to propagation:pipeline so the timeline picks up new
forecast hours live as they land, without waiting for the full chain
to finish.
Extend the f00 profile persistence to cover every forecast hour
(f00-f18) so /weather keeps working across pod restarts and shows
forecast-hour atmospheric data without a fresh database. Route the
Weather cold path (latest_grid_valid_time, load_weather_grid,
weather_point_detail) through ProfilesFile first, with the legacy
hrrr_profiles table as a last-resort historical fallback. Warm
GridCache from the latest profile on app start so /weather is hot
the moment a pod boots.
The propagation_scores → binary files cutover dropped the factors
JSONB column, which left point_detail returning factors: %{} and
broke the analysis breakdown popup on map clicks. Persist the
enriched f00 grid_data to /data/scores/profiles/{iso}.etf.gz and
rescore on demand at click time so the factor block renders again.