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