prop/lib/microwaveprop/commercial.ex
Graham McIntire 6aa91e7656
fix: April 2026 codebase review — address 13 bugs across propagation chain
Each fix is covered by a regression test that fails on `main` and
passes on this commit.

Round 1 (initial review):

* propagation: thread `latitude` into the conditions map so
  `score_season/4` actually picks up regional multipliers
* hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at
  a future cycle that NOAA hasn't published yet
* radio: spherical-vector great-circle midpoint replaces the
  arithmetic mean — anti-meridian paths no longer fold to Greenwich
* weather: `reconcile_weather_statuses` scales the longitude band by
  `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude
* radio/maidenhead: clamp 90°/180° below the field-bucket overflow so
  `from_latlon` never emits invalid characters like 'S'
* prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and
  read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native
  Duct Boost actually fires
* propagation/region (Elixir + Rust): inclusive upper bounds so points
  exactly at lat_max get the regional multiplier
* weather/sounding_params (Elixir + Rust): drop the 10 m gradient
  floor so HRRR's thin near-surface layers stop hiding sharp ducts
* weather/sounding_params: when the profile ends inside a duct,
  finalize it with the highest sample as the top instead of throwing
  it away (Rust port already correct)

Round 2 (post-fix sweep):

* radio + commercial: single canonical haversine in Radio (atan2
  form); Commercial delegates instead of carrying a second copy that
  could disagree at threshold distances
* prop_grid_rs/profiles_file: `snap_coords` matches Elixir's
  step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so
  Rust-keyed and Elixir-keyed profile maps land on the same cell
* weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse`
  uniformly — wgrib2 dropping the trailing `.0` from a longitude no
  longer crashes the whole chain step
2026-04-25 10:52:51 -05:00

200 lines
6.5 KiB
Elixir

defmodule Microwaveprop.Commercial do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Commercial.Link
alias Microwaveprop.Commercial.Sample
alias Microwaveprop.Repo
@default_radius_km 75.0
@default_baseline_days 7
@default_current_window_seconds 900
@min_baseline_samples 5
@spec enabled_links() :: [Link.t()]
def enabled_links do
Link
|> where([l], l.enabled == true)
|> Repo.all()
end
@doc """
Returns the aggregate commercial-link rx_power degradation at a location,
or `nil` if no healthy links are in range.
Commercial LOS microwave links near the target point act as an inverse
tropo sensor: when their rx_power drops significantly below the 7-day
baseline without an obvious equipment cause (link_state still 1), the
refractivity structure that hurts their short Fresnel zone is usually
the same structure that *helps* long beyond-LOS amateur propagation.
Result map:
%{
degradation_db: float (positive means worse than baseline),
baseline_dbm: float,
current_dbm: float,
n_links: integer
}
## Options
* `:radius_km` — distance from the target point to include a link (default 75)
* `:baseline_days` — days of history for the baseline average (default 7)
* `:current_window_seconds` — how recent "current" rx must be (default 900)
"""
@spec link_degradation_at({float(), float()}, DateTime.t(), keyword()) :: map() | nil
def link_degradation_at({lat, lon}, valid_time, opts \\ []) do
lookup = build_link_lookup(valid_time, opts)
link_degradation_from_lookup({lat, lon}, lookup, opts)
end
@doc """
Precompute every enabled link's degradation once so a bulk caller
(like PropagationGridWorker's per-cell merge) can reuse the same
`(link, endpoint, degradation)` list across all 95k grid points
instead of re-running `enabled_links/0` and the per-link sample
queries per call. Result is fed back into
`link_degradation_from_lookup/3`.
"""
@spec build_link_lookup(DateTime.t(), keyword()) :: [
{Link.t(), {float(), float()}, map() | nil}
]
def build_link_lookup(valid_time, opts \\ []) do
baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days)
current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds)
baseline_cutoff = DateTime.add(valid_time, -baseline_days * 24 * 3600, :second)
current_cutoff = DateTime.add(valid_time, -current_window, :second)
enabled_links()
|> Enum.map(fn link -> {link, link_endpoint(link)} end)
|> Enum.reject(fn {_link, endpoint} -> is_nil(endpoint) end)
|> Enum.map(fn {link, endpoint} ->
degradation = link_degradation(link.id, baseline_cutoff, current_cutoff, valid_time)
{link, endpoint, degradation}
end)
end
@doc """
Pure per-cell aggregator — given a `(lat, lon)` point and a
precomputed lookup from `build_link_lookup/2`, returns the
aggregate degradation map or `nil` when no in-range link is
reporting usable data.
"""
@spec link_degradation_from_lookup(
{float(), float()},
[{Link.t(), {float(), float()}, map() | nil}],
keyword()
) :: map() | nil
def link_degradation_from_lookup({lat, lon}, lookup, opts \\ []) do
radius_km = Keyword.get(opts, :radius_km, @default_radius_km)
results =
for {_link, {elat, elon}, %{} = degradation} <- lookup,
haversine_km(lat, lon, elat, elon) <= radius_km do
degradation
end
aggregate_degradation(results)
end
defp aggregate_degradation([]), do: nil
defp aggregate_degradation(list) do
baseline_avg = average(Enum.map(list, & &1.baseline_dbm))
current_avg = average(Enum.map(list, & &1.current_dbm))
%{
degradation_db: Float.round(baseline_avg - current_avg, 2),
baseline_dbm: Float.round(baseline_avg, 2),
current_dbm: Float.round(current_avg, 2),
n_links: length(list)
}
end
defp link_degradation(link_id, baseline_cutoff, current_cutoff, valid_time) do
baseline =
Sample
|> where([s], s.link_id == ^link_id)
|> where([s], s.sampled_at >= ^baseline_cutoff and s.sampled_at < ^current_cutoff)
|> where([s], s.link_state == 1 and not is_nil(s.rx_power_0))
|> select([s], avg(s.rx_power_0))
|> Repo.one()
{current, baseline_n} = fetch_current_and_baseline_count(link_id, current_cutoff, valid_time)
cond do
is_nil(baseline) or is_nil(current) ->
nil
baseline_n < @min_baseline_samples ->
nil
true ->
%{baseline_dbm: to_float(baseline), current_dbm: current}
end
end
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp to_float(n) when is_number(n), do: n * 1.0
defp fetch_current_and_baseline_count(link_id, current_cutoff, valid_time) do
current =
Sample
|> where([s], s.link_id == ^link_id)
|> where([s], s.sampled_at >= ^current_cutoff and s.sampled_at <= ^valid_time)
|> where([s], s.link_state == 1 and not is_nil(s.rx_power_0))
|> order_by([s], desc: s.sampled_at)
|> limit(1)
|> select([s], s.rx_power_0)
|> Repo.one()
baseline_n =
Sample
|> where([s], s.link_id == ^link_id)
|> where([s], s.sampled_at < ^current_cutoff)
|> where([s], s.link_state == 1 and not is_nil(s.rx_power_0))
|> select([s], count(s.id))
|> Repo.one()
{current, baseline_n}
end
defp link_endpoint(%Link{endpoint_a: %{"lat" => lat, "lon" => lon}}) when is_number(lat) and is_number(lon),
do: {lat * 1.0, lon * 1.0}
defp link_endpoint(_), do: nil
# Single canonical haversine implementation lives in `Microwaveprop.Radio`.
# Two formulae for the same physical distance let identical coordinates
# land on opposite sides of a 75 km link-radius threshold depending on
# which module asked.
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Radio.haversine_km(lat1, lon1, lat2, lon2)
defp average([]), do: 0.0
defp average(list), do: Enum.sum(list) / length(list)
@spec create_link(map()) :: {:ok, Link.t()} | {:error, Ecto.Changeset.t()}
def create_link(attrs) do
%Link{}
|> Link.changeset(attrs)
|> Repo.insert()
end
@spec create_sample(map()) :: {:ok, Sample.t()} | {:error, Ecto.Changeset.t()}
def create_sample(attrs) do
%Sample{}
|> Sample.changeset(attrs)
|> Repo.insert()
end
@spec weather_stations() :: [String.t()]
def weather_stations do
Link
|> where([l], l.enabled == true and not is_nil(l.weather_station))
|> select([l], l.weather_station)
|> distinct(true)
|> Repo.all()
end
end