Rework of d61fbd3's dialyzer suppression: the 4 warnings from
LiveTable.LiveResource's macro expansion are now fixed at the
root rather than hidden via .dialyzer_ignore.exs.
Changes:
- Delete .dialyzer_ignore.exs and remove ignore_warnings from
mix.exs. No more hidden suppressions.
- New MicrowavepropWeb.LiveTableResource shim that wraps
use LiveTable.LiveResource and overrides:
* maybe_subscribe/1 — explicit :ok = on Phoenix.PubSub.subscribe/2
(previously discarded, tripping :unmatched_return).
* handle_info/2 — explicit _ = on File.rm, Process.send_after.
Switch all 4 LiveTable-using LiveViews (contact_live/index,
beacon_live/index, admin/contact_edit_live, user_management_live/index)
to use MicrowavepropWeb.LiveTableResource instead.
- New priv/dep_patches/live_table-0.4.1.patch adds _ = in front of
LiveTable.LiveSelectHelpers.restore_live_select_from_params/2 in
the dep's macro-expanded handle_params/3. The call's return was
silently discarded, tripping the fourth warning per LiveView.
- New lib/mix/tasks/deps.patch.ex applies every
priv/dep_patches/*.patch to its target dep idempotently after
mix deps.get. Wired into the :setup alias + overridden
mix deps.get so the patch survives cache regeneration in CI.
- Other modified files in this commit are the spec-coverage
additions from the parallel agent pass (beacons.ex,
commercial.ex, propagation.ex, weather.ex, narr_client.ex,
run_timing.ex, 3 workers) — tighter specs for bounds, tuple
shapes, schema-typed params. Dialyzer remains at 0 after
the changes.
Upstream TODO: send a PR for the live_table `_ =` fix. Once
merged and released, drop the patch + mix task + bump the dep.
mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2165 tests, 2 pre-existing flakes (MapLive timestamp +
PropagationPrune ScoresFile), 0 regressions.
209 lines
6.6 KiB
Elixir
209 lines
6.6 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
|
|
|
|
defp haversine_km(lat1, lon1, lat2, lon2) do
|
|
r = 6371.0
|
|
phi1 = lat1 * :math.pi() / 180
|
|
phi2 = lat2 * :math.pi() / 180
|
|
dphi = (lat2 - lat1) * :math.pi() / 180
|
|
dlambda = (lon2 - lon1) * :math.pi() / 180
|
|
|
|
a =
|
|
:math.sin(dphi / 2) * :math.sin(dphi / 2) +
|
|
:math.cos(phi1) * :math.cos(phi2) * :math.sin(dlambda / 2) * :math.sin(dlambda / 2)
|
|
|
|
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
|
|
r * c
|
|
end
|
|
|
|
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
|