prop/lib/mix/tasks/rust.golden.ex
Graham McIntire 0c98707091
feat: harden /map analysis breakdown + move Plausible to root layout
Two fixes cover the blank "analysis breakdown" panel the user reported:

1. Bound the point_detail fallback lookback to 24h. Previously
   factors_from_fallback_profile would happily use a week-old analysis
   profile when no current one existed — now anything older than 24h
   returns an empty factor map so the UI can surface "breakdown
   unavailable" instead of silently misattributing.

2. Move the Plausible analytics snippet from Layouts.app into
   root.html.heex. Full-bleed LiveViews (/map, /contacts/map,
   /weather, home) bypass Layouts.app, so the snippet only loaded on
   the navbar-wrapped pages. root.html.heex is loaded once per HTTP
   document so coverage is now universal.

Added ~30 tests locking both down:
  • point_detail fallback: exact-time wins, 24h boundary accepted,
    30h-stale rejected, multi-band coverage, missing-cell degrades to
    empty factors, default-valid_time path
  • analytics_test.exs: Plausible script present on 12 representative
    pages, exactly once, loaded async, surviving a login round-trip

Also fixed pre-existing credo issues per standing rule: shortened the
map_live_test ETS match_delete call, extracted a function from the
deeply-nested hrrr_point_enqueuer.enqueue_for_contacts/1 cond, and
swapped Mix.Tasks.Rust.Golden's IO.inspect for Mix.shell().info.
2026-04-21 15:56:30 -05:00

214 lines
5.7 KiB
Elixir

defmodule Mix.Tasks.Rust.Golden do
@shortdoc "Generate golden test fixtures for the Rust scorer port"
@moduledoc """
Dump Elixir-scorer output for a sample of points to
`priv/rust_golden/scores.bincode`. The Rust crate loads this fixture
in its scorer tests to assert byte-for-byte parity with the Elixir
implementation.
The fixture format is a little-endian binary:
n_samples : u32
per-sample:
abs_humidity : f64
temp_f : f64
dewpoint_f : f64
wind_speed_kts : f64 (NaN = None)
sky_cover_pct : f64 (NaN = None)
utc_hour : u8
utc_minute : u8
month : u8
longitude : f64
latitude : f64 (NaN = None)
pressure_mb : f64 (NaN = None)
prev_pressure_mb : f64 (NaN = None)
rain_rate_mmhr : f64 (NaN = None)
min_refractivity_gradient : f64 (NaN = None)
bl_depth_m : f64 (NaN = None)
pwat_mm : f64 (NaN = None)
best_duct_band_ghz: f64 (NaN = None)
bulk_richardson : f64 (NaN = None)
band_mhz : u32
expected_score : i32
No Rust dependency on bincode's stable format — we write fixed-width
fields in a fixed order and the Rust loader reads them in the same
order. Matches the Rust `Conditions` struct field-for-field.
mix rust.golden # writes priv/rust_golden/scores.bincode
mix rust.golden --print 5 # pretty-print first 5 samples
"""
use Mix.Task
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
@impl true
def run(args) do
{opts, _, _} = OptionParser.parse(args, strict: [print: :integer])
samples = build_samples()
path = Path.join(["priv", "rust_golden", "scores.bincode"])
File.mkdir_p!(Path.dirname(path))
File.write!(path, encode(samples))
Mix.shell().info("wrote #{length(samples)} samples to #{path}")
if n = opts[:print] do
samples |> Enum.take(n) |> Enum.each(&Mix.shell().info(inspect(&1)))
end
end
defp build_samples do
bands = BandConfig.all_bands()
conditions_set = [
summer_peak(),
winter_dry_cold(),
stormy_afternoon(),
dawn_duct(),
humid_gulf()
]
for {c, idx} <- Enum.with_index(conditions_set), band <- bands do
result = Scorer.composite_score(c, band)
{idx, c, band.freq_mhz, result.score}
end
end
defp summer_peak do
%{
abs_humidity: 10.0,
temp_f: 80,
dewpoint_f: 70,
wind_speed_kts: 5,
sky_cover_pct: 20,
utc_hour: 11,
utc_minute: 15,
month: 6,
longitude: -75.0,
pressure_mb: 1020,
prev_pressure_mb: 1018,
rain_rate_mmhr: 0,
min_refractivity_gradient: -350,
bl_depth_m: 400,
pwat_mm: 25.0
}
end
defp winter_dry_cold do
%{
abs_humidity: 2.5,
temp_f: 28,
dewpoint_f: 14,
wind_speed_kts: 18,
sky_cover_pct: 10,
utc_hour: 14,
utc_minute: 0,
month: 1,
longitude: -97.0,
pressure_mb: 1028,
prev_pressure_mb: 1026,
rain_rate_mmhr: 0,
min_refractivity_gradient: -60,
bl_depth_m: 1800,
pwat_mm: 5.0
}
end
defp stormy_afternoon do
%{
abs_humidity: 18.0,
temp_f: 85,
dewpoint_f: 74,
wind_speed_kts: 22,
sky_cover_pct: 95,
utc_hour: 21,
utc_minute: 0,
month: 7,
longitude: -97.0,
pressure_mb: 1002,
prev_pressure_mb: 1008,
rain_rate_mmhr: 8.0,
min_refractivity_gradient: -80,
bl_depth_m: 2200,
pwat_mm: 38.0
}
end
defp dawn_duct do
%{
abs_humidity: 14.0,
temp_f: 72,
dewpoint_f: 70,
wind_speed_kts: 3,
sky_cover_pct: 5,
utc_hour: 11,
utc_minute: 30,
month: 5,
longitude: -95.0,
pressure_mb: 1018,
prev_pressure_mb: 1017,
rain_rate_mmhr: 0,
min_refractivity_gradient: -220,
bl_depth_m: 140,
pwat_mm: 30.0
}
end
defp humid_gulf do
%{
abs_humidity: 22.0,
temp_f: 88,
dewpoint_f: 77,
wind_speed_kts: 8,
sky_cover_pct: 55,
utc_hour: 18,
utc_minute: 0,
month: 8,
longitude: -93.0,
latitude: 29.5,
pressure_mb: 1014,
prev_pressure_mb: 1014,
rain_rate_mmhr: 0,
min_refractivity_gradient: -110,
bl_depth_m: 700,
pwat_mm: 52.0
}
end
defp encode(samples) do
header = <<length(samples)::unsigned-little-32>>
body = for {_idx, c, band_mhz, score} <- samples, into: <<>>, do: encode_sample(c, band_mhz, score)
header <> body
end
defp encode_sample(c, band_mhz, score) do
f(c.abs_humidity) <>
f(c.temp_f) <>
f(c.dewpoint_f) <>
opt(c[:wind_speed_kts]) <>
opt(c[:sky_cover_pct]) <>
<<c.utc_hour::unsigned-8, c.utc_minute::unsigned-8, c.month::unsigned-8>> <>
f(c.longitude) <>
opt(c[:latitude]) <>
opt(c[:pressure_mb]) <>
opt(c[:prev_pressure_mb]) <>
opt(c[:rain_rate_mmhr]) <>
opt(c[:min_refractivity_gradient]) <>
opt(c[:bl_depth_m]) <>
opt(c[:pwat_mm]) <>
opt(c[:best_duct_band_ghz]) <>
opt(c[:bulk_richardson]) <>
<<band_mhz::unsigned-little-32, score::signed-little-32>>
end
# IEEE 754 quiet-NaN bit pattern, little-endian. Used as the sentinel
# for `None` on the Rust side — `f64::is_nan` → `Option::None`.
@nan_bytes <<0x7FF8_0000_0000_0000::unsigned-little-64>>
defp f(n) when is_float(n), do: <<n::float-little-64>>
defp f(n) when is_integer(n), do: <<n * 1.0::float-little-64>>
defp opt(nil), do: @nan_bytes
defp opt(v), do: f(v)
end