refactor(propagation): drop ShadowComparator (Phase 1 only)

Phase 2 cutover completed in 65693ed — Rust prop-grid-rs owns
f01-f18 production writes and the shadow path has been dead
for weeks. No callers in lib/, test/, or mix tasks.
This commit is contained in:
Graham McIntire 2026-04-21 09:16:01 -05:00
parent 9ced0eab87
commit a81e55e348
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -1,119 +0,0 @@
defmodule Microwaveprop.Propagation.ShadowComparator do
@moduledoc """
Shadow-mode validator for the Rust `prop-grid-rs` port.
During Phase 1 shadow mode the Rust worker writes score-grid files to
`/data/scores_shadow/<band>/<iso>.ntms` while Elixir keeps writing to
`/data/scores/`. This module compares the two files and logs the
per-band delta distribution so drift is visible before cutover.
Callable from IEx on demand or from a one-shot Mix task; not started
in the supervision tree (this is temporary instrumentation).
"""
alias Microwaveprop.Propagation.ScoresFile
require Logger
@default_prod_dir "/data/scores"
@default_shadow_dir "/data/scores_shadow"
@type diff :: %{
band_mhz: non_neg_integer(),
valid_time: DateTime.t(),
total_cells: non_neg_integer(),
matching_cells: non_neg_integer(),
missing_shadow_cells: non_neg_integer(),
max_abs_delta: non_neg_integer(),
mean_abs_delta: float()
}
@spec compare(non_neg_integer(), DateTime.t(), keyword()) ::
{:ok, diff()} | {:error, term()}
def compare(band_mhz, %DateTime{} = valid_time, opts \\ []) do
prod_dir = Keyword.get(opts, :prod_dir, @default_prod_dir)
shadow_dir = Keyword.get(opts, :shadow_dir, @default_shadow_dir)
with {:ok, prod} <- read_file(prod_dir, band_mhz, valid_time),
{:ok, shadow} <- read_file(shadow_dir, band_mhz, valid_time) do
{:ok, diff_payloads(band_mhz, valid_time, prod, shadow)}
end
end
defp read_file(dir, band_mhz, valid_time) do
path = band_mhz |> ScoresFile.path_for(valid_time) |> swap_base(dir)
case File.read(path) do
{:ok, binary} -> decode_scores_file(binary)
{:error, reason} -> {:error, {:read, path, reason}}
end
end
defp swap_base(path, new_base) do
suffix = Path.relative_to(path, ScoresFile.base_dir())
Path.join(new_base, suffix)
end
# Inline parse of the v1 scores-file on-disk format so this module
# doesn't reach into `ScoresFile`'s private `decode/1`. The format is
# documented in `ScoresFile`'s moduledoc; only the body size and
# dimensions matter for a diff, so we intentionally keep this minimal.
# The literal "NTMS" magic is the file-header marker already on disk.
defp decode_scores_file(
<<"NTMS", 1::unsigned-8, _band_mhz::unsigned-little-32, _valid_time_unix::signed-little-64,
_lat_min::float-little-32, _lon_min::float-little-32, _step::float-little-32, n_rows::unsigned-little-16,
n_cols::unsigned-little-16, body::binary>>
) do
expected = n_rows * n_cols
if byte_size(body) == expected do
{:ok, %{n_rows: n_rows, n_cols: n_cols, scores: body}}
else
{:error, :invalid_format}
end
end
defp decode_scores_file(_), do: {:error, :invalid_format}
defp diff_payloads(band_mhz, valid_time, prod, shadow) do
%{scores: p_body, n_cols: n_cols, n_rows: n_rows} = prod
%{scores: s_body} = shadow
{total, matching, missing, max_abs, sum_abs} =
for row <- 0..(n_rows - 1),
col <- 0..(n_cols - 1),
reduce: {0, 0, 0, 0, 0} do
{t, m, miss, max_d, sum_d} ->
idx = row * n_cols + col
p = :binary.at(p_body, idx)
s = :binary.at(s_body, idx)
cond do
p == 255 and s == 255 ->
{t, m, miss, max_d, sum_d}
p != 255 and s == 255 ->
{t + 1, m, miss + 1, max_d, sum_d}
p == s ->
{t + 1, m + 1, miss, max_d, sum_d}
true ->
d = abs(p - s)
{t + 1, m, miss, max(max_d, d), sum_d + d}
end
end
mean = if total > 0, do: sum_abs / total, else: 0.0
%{
band_mhz: band_mhz,
valid_time: valid_time,
total_cells: total,
matching_cells: matching,
missing_shadow_cells: missing,
max_abs_delta: max_abs,
mean_abs_delta: mean
}
end
end