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 = <> 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]) <> <> <> 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]) <> <> 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: <> defp f(n) when is_integer(n), do: <> defp opt(nil), do: @nan_bytes defp opt(v), do: f(v) end