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.
214 lines
5.7 KiB
Elixir
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
|