chore: remove all APRS-related code and fix credo issues
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 6m41s
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 6m41s
- Delete Microwaveprop.Aprs, AprsRepo, Aprs.PathParser modules - Delete Mix.Tasks.Calibrate.Aprs144 mix task - Remove AprsRepo from application.ex children and config files - Remove APRS test files and sandbox references in test_helper - Fix credo nested-module alias suggestions in conn_case.ex
This commit is contained in:
parent
08fe568cd7
commit
933397d246
21 changed files with 127 additions and 1113 deletions
|
|
@ -72,8 +72,9 @@ jobs:
|
|||
-e MIX_ENV=prod \
|
||||
-w /app \
|
||||
docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim \
|
||||
sh -euc '\
|
||||
sh -euc '\
|
||||
mkdir -p /app && cd /app && tar xf - && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get update -qq && apt-get install -y -qq git curl ca-certificates build-essential && \
|
||||
mix local.hex --force && \
|
||||
mix local.rebar --force && \
|
||||
|
|
@ -94,8 +95,9 @@ jobs:
|
|||
--network host \
|
||||
-w /app \
|
||||
docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim \
|
||||
sh -euc '\
|
||||
sh -euc '\
|
||||
mkdir -p /app && cd /app && tar xf - && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get update -qq && apt-get install -y -qq git curl ca-certificates build-essential && \
|
||||
mix local.hex --force && \
|
||||
mix local.rebar --force && \
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@ import Config
|
|||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||
|
||||
# Read-only secondary repo pointing at the local aprs.me dev database.
|
||||
# See lib/microwaveprop/aprs_repo.ex — used by APRS calibration tooling
|
||||
# only; the main app boots fine without it.
|
||||
config :microwaveprop, Microwaveprop.AprsRepo,
|
||||
username: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "aprsme_dev",
|
||||
pool_size: 2
|
||||
|
||||
# Configure your database
|
||||
config :microwaveprop, Microwaveprop.Repo,
|
||||
username: "postgres",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ require Logger
|
|||
# (not `config`) to directly set the application environment value before
|
||||
# the Repo supervisor reads it during app start.
|
||||
if config_env() == :test do
|
||||
for repo <- [Microwaveprop.Repo, Microwaveprop.AprsRepo] do
|
||||
for repo <- [Microwaveprop.Repo] do
|
||||
existing = Application.get_env(:microwaveprop, repo, [])
|
||||
Application.put_env(:microwaveprop, repo, Keyword.put(existing, :pool, DBConnection.Ownership))
|
||||
end
|
||||
|
|
@ -450,22 +450,6 @@ if config_env() == :prod do
|
|||
config :microwaveprop, :pskr_mqtt_enabled, pskr_default
|
||||
end
|
||||
|
||||
# Optional secondary repo for read-only access to aprs.me's database.
|
||||
# Used by APRS-based 144 MHz calibration tooling. When the env var is
|
||||
# unset OR an empty string (k8s secret resolves to "") we leave the
|
||||
# AprsRepo unconfigured; Microwaveprop.Application is the single site
|
||||
# that logs the skip and decides not to start it.
|
||||
case System.get_env("APRS_DATABASE_URL") do
|
||||
url when is_binary(url) and url != "" ->
|
||||
config :microwaveprop, Microwaveprop.AprsRepo,
|
||||
url: url,
|
||||
pool_size: String.to_integer(System.get_env("APRS_POOL_SIZE") || "2"),
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
config :microwaveprop, MicrowavepropWeb.Endpoint,
|
||||
# SSL terminated by Cloudflare tunnel; generated URLs still use https
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
|
|
|
|||
|
|
@ -16,21 +16,6 @@ config :microwaveprop, Client,
|
|||
plug: {Req.Test, Client},
|
||||
retry: false
|
||||
|
||||
# Read-only secondary repo for aprs.me. Configured here so the app boots
|
||||
# under MIX_ENV=test, but uses the SQL.Sandbox in :manual mode so tests
|
||||
# that don't touch APRS data don't have to check the DB out. Tests that
|
||||
# do query the AprsRepo must call `Ecto.Adapters.SQL.Sandbox.checkout/1`
|
||||
# explicitly.
|
||||
config :microwaveprop, Microwaveprop.AprsRepo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: DBConnection.Ownership,
|
||||
pool_size: 2,
|
||||
ownership_timeout: 60_000,
|
||||
timeout: 60_000
|
||||
|
||||
config :microwaveprop, Microwaveprop.Geocoder,
|
||||
plug: {Req.Test, Microwaveprop.Geocoder},
|
||||
retry: false
|
||||
|
|
@ -97,6 +82,12 @@ config :microwaveprop, :pskr_mqtt_enabled, false
|
|||
# test exercises the full Calculate pipeline and we don't want it
|
||||
# reaching out to overpass-api.de.
|
||||
config :microwaveprop, :rover_road_proximity_enabled, false
|
||||
|
||||
# Lets a LiveView's connected mount (a separate process from the one
|
||||
# handling the initial HTTP request) see the same Ecto Sandbox
|
||||
# transaction as the test driving it. See `MicrowavepropWeb.Endpoint`
|
||||
# and `MicrowavepropWeb.SandboxHook`.
|
||||
config :microwaveprop, :sql_sandbox, true
|
||||
config :microwaveprop, cache_contact_count: false
|
||||
config :microwaveprop, cache_contact_map: false
|
||||
config :microwaveprop, elevation_req_options: [plug: {Req.Test, Microwaveprop.Terrain.ElevationClient}, retry: false]
|
||||
|
|
|
|||
|
|
@ -23,10 +23,6 @@ defmodule Microwaveprop.Application do
|
|||
MicrowavepropWeb.Telemetry,
|
||||
Microwaveprop.PromEx,
|
||||
Microwaveprop.Repo,
|
||||
# AprsRepo is optional — only starts when configured with a :url
|
||||
# (dev/test from config blocks, prod from APRS_DATABASE_URL). Missing
|
||||
# config logs a warning and skips startup so the app still boots.
|
||||
aprs_repo_child_spec(),
|
||||
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
|
||||
pubsub_child_spec(),
|
||||
# Partitioned Task.Supervisor — callers route heavy `async_stream`
|
||||
|
|
@ -202,23 +198,6 @@ defmodule Microwaveprop.Application do
|
|||
end
|
||||
end
|
||||
|
||||
# Returns Microwaveprop.AprsRepo child spec if configured (has a :url
|
||||
# or :database key), otherwise logs a warning and returns nil so the
|
||||
# supervisor children list can filter it out. APRS calibration is an
|
||||
# optional integration with the sister aprs.me database — we never
|
||||
# crash boot when it's unavailable.
|
||||
defp aprs_repo_child_spec do
|
||||
config = Application.get_env(:microwaveprop, Microwaveprop.AprsRepo, [])
|
||||
|
||||
if Keyword.has_key?(config, :url) or Keyword.has_key?(config, :database) do
|
||||
Microwaveprop.AprsRepo
|
||||
else
|
||||
Logger.warning("Microwaveprop.AprsRepo not configured (no :url or :database) — APRS calibration disabled.")
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the deployment (image build) timestamp.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
defmodule Microwaveprop.Aprs do
|
||||
@moduledoc """
|
||||
Read-only access to aprs.me's `packets` table for 144 MHz calibration.
|
||||
|
||||
This module never writes, never schemas, never mirrors. Each call
|
||||
issues a SELECT against `Microwaveprop.AprsRepo` (which connects to
|
||||
aprs.me's database) and returns raw maps for downstream parsing by
|
||||
`Microwaveprop.Aprs.PathParser` and consumption by the
|
||||
`Calibrate.Aprs144` mix task.
|
||||
|
||||
aprs.me retains 24 h of packet history in production
|
||||
(`PACKET_RETENTION_DAYS=1`), so any "historical" query is bounded by
|
||||
that window.
|
||||
|
||||
Tests run against a local `aprsme_test` database whose schema mirrors
|
||||
aprs.me's partitioned `packets` table; rows are inserted via raw SQL
|
||||
in a setup block under the `Ecto.Adapters.SQL.Sandbox`.
|
||||
|
||||
> #### Error semantics {: .warning}
|
||||
>
|
||||
> Both queries call `Ecto.Adapters.SQL.query!/3` and will raise on
|
||||
> connection or SQL failures. Today the only caller is the
|
||||
> `Calibrate.Aprs144` mix task, where a hard exit is the right
|
||||
> ergonomic. Async callers (`Task.async_stream`, LiveView
|
||||
> `start_async`, etc.) MUST catch and log per the project's
|
||||
> swallowed-error rule in CLAUDE.md.
|
||||
"""
|
||||
|
||||
alias Ecto.Adapters.SQL
|
||||
alias Microwaveprop.AprsRepo
|
||||
|
||||
@type packet_row :: %{
|
||||
id: binary(),
|
||||
sender: String.t(),
|
||||
base_callsign: String.t(),
|
||||
lat: float(),
|
||||
lon: float(),
|
||||
path: String.t(),
|
||||
received_at: DateTime.t()
|
||||
}
|
||||
|
||||
@type position :: {lat :: float(), lon :: float(), last_heard_at :: DateTime.t()}
|
||||
|
||||
@recent_packets_sql """
|
||||
SELECT id, sender, base_callsign, lat, lon, path, received_at
|
||||
FROM packets
|
||||
WHERE has_position = true
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND path IS NOT NULL
|
||||
AND path <> ''
|
||||
AND is_item = false
|
||||
AND is_object = false
|
||||
AND sender IS NOT NULL
|
||||
AND received_at >= $1
|
||||
ORDER BY received_at ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
|
||||
@station_positions_sql """
|
||||
SELECT DISTINCT ON (base_callsign) base_callsign, lat, lon, received_at
|
||||
FROM packets
|
||||
WHERE base_callsign = ANY($1)
|
||||
AND has_position = true
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
ORDER BY base_callsign, received_at DESC
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns recent position-bearing packets with non-empty paths, oldest first.
|
||||
|
||||
Filters out item/object packets and rows missing position or path.
|
||||
Raises on connection or SQL failure — see moduledoc on async-context safety.
|
||||
|
||||
## Options
|
||||
|
||||
* `:since` — `%DateTime{}`. Default `now - 1h`.
|
||||
* `:limit` — non-neg integer. Default `50_000`.
|
||||
"""
|
||||
@spec recent_packets_with_paths(keyword()) :: [packet_row()]
|
||||
def recent_packets_with_paths(opts \\ []) do
|
||||
since = Keyword.get_lazy(opts, :since, fn -> DateTime.add(DateTime.utc_now(), -3600, :second) end)
|
||||
limit = Keyword.get(opts, :limit, 50_000)
|
||||
|
||||
# Force the bound to UTC so a non-UTC %DateTime{} doesn't silently
|
||||
# match a wrong window after `to_naive` strips the tz tag.
|
||||
naive_since = since |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_naive()
|
||||
|
||||
%Postgrex.Result{rows: rows} =
|
||||
SQL.query!(AprsRepo, @recent_packets_sql, [naive_since, limit])
|
||||
|
||||
Enum.map(rows, &decode_packet_row/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the most recent known position for each callsign in `callsigns`.
|
||||
|
||||
Callsigns with no positioned packets simply don't appear in the result.
|
||||
Empty input list short-circuits to `%{}` without querying.
|
||||
|
||||
Raises on connection or SQL failure — see moduledoc on async-context safety.
|
||||
"""
|
||||
@spec station_positions([String.t()]) :: %{String.t() => position()}
|
||||
def station_positions([]), do: %{}
|
||||
|
||||
def station_positions(callsigns) when is_list(callsigns) do
|
||||
%Postgrex.Result{rows: rows} = SQL.query!(AprsRepo, @station_positions_sql, [callsigns])
|
||||
|
||||
Map.new(rows, fn [base_callsign, lat, lon, received_at] ->
|
||||
{base_callsign, {to_float(lat), to_float(lon), to_utc_datetime(received_at)}}
|
||||
end)
|
||||
end
|
||||
|
||||
defp decode_packet_row([id, sender, base_callsign, lat, lon, path, received_at]) do
|
||||
%{
|
||||
id: id,
|
||||
sender: sender,
|
||||
base_callsign: base_callsign,
|
||||
lat: Decimal.to_float(lat),
|
||||
lon: Decimal.to_float(lon),
|
||||
path: path,
|
||||
received_at: DateTime.from_naive!(received_at, "Etc/UTC")
|
||||
}
|
||||
end
|
||||
|
||||
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
|
||||
|
||||
defp to_utc_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
|
||||
end
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
defmodule Microwaveprop.Aprs.PathParser do
|
||||
@moduledoc """
|
||||
Parses TNC2-style APRS digipeater paths into verified RF hops.
|
||||
|
||||
An APRS packet's `path` column (as stored by aprs.me) is a comma-
|
||||
separated chain of digipeater callsigns and routing aliases. Tokens
|
||||
with a trailing `*` ("used flag") indicate stations that actually
|
||||
digipeated the frame on RF — a verified receive.
|
||||
|
||||
Walk left-to-right; each "real callsign + used flag" is a verified
|
||||
hop from the *current source* to that callsign, then the source
|
||||
advances to that callsign for any subsequent hops.
|
||||
|
||||
We deliberately ignore:
|
||||
* Q-constructs (`qAR`, `qAC`, `qAS`, `qAo`, `qAX`, `qAZ`, `qAI`)
|
||||
* `TCPIP*`, `TCPXX*` (internet-only injection)
|
||||
* Routing aliases (`WIDE1-N`, `WIDE2-N`, `TRACE[12]-N`, `RELAY`,
|
||||
`ECHO`, `GATE`, `NOGATE`, `RFONLY`)
|
||||
|
||||
When the used flag lands on an alias (e.g. `WIDE1*` from
|
||||
`WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10`), we know A digi handled the
|
||||
frame but cannot attribute the hop to a callsign — we skip without
|
||||
emitting a hop.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.Geo
|
||||
|
||||
@type position :: {lat :: number(), lon :: number()}
|
||||
@type station_lookup :: (String.t() -> position() | nil)
|
||||
@type hop :: %{
|
||||
src_callsign: String.t(),
|
||||
src_pos: position(),
|
||||
dst_callsign: String.t(),
|
||||
dst_pos: position(),
|
||||
distance_km: float(),
|
||||
heard_at: DateTime.t()
|
||||
}
|
||||
|
||||
# WIDEn-N / TRACEn-N allow n and N up to 7 per the APRS New-N paradigm.
|
||||
@alias_regex ~r/^(WIDE[1-7](-[1-7])?|TRACE[1-7](-[1-7])?|RELAY|ECHO|GATE|NOGATE|RFONLY)$/
|
||||
@callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/
|
||||
# Explicit allowlist of real APRS-IS Q-constructs (RFC qAR/qAC/qAo/etc.)
|
||||
# rather than `qA[A-Za-z]` which would over-match `qAa`, `qAd`, …
|
||||
@q_constructs ~w(qAC qAX qAU qAo qAO qAS qAr qAR qAZ qAI)
|
||||
|
||||
@doc """
|
||||
Returns `true` if `token` matches the APRS callsign shape used by this
|
||||
parser (1-2 alpha/digit prefix, one digit, 1-3 alpha suffix, optional
|
||||
-SSID 0..99). Exposed so callers (e.g. the calibration Mix task) can
|
||||
pre-filter path tokens with the same rule the parser itself applies.
|
||||
"""
|
||||
@spec valid_callsign?(String.t()) :: boolean()
|
||||
def valid_callsign?(token) when is_binary(token), do: Regex.match?(@callsign_regex, token)
|
||||
|
||||
@spec parse_hops(
|
||||
sender_callsign :: String.t(),
|
||||
sender_pos :: position(),
|
||||
path_string :: String.t(),
|
||||
heard_at :: DateTime.t(),
|
||||
station_lookup :: station_lookup()
|
||||
) :: [hop()]
|
||||
def parse_hops(sender_callsign, sender_pos, path_string, heard_at, station_lookup)
|
||||
when is_binary(sender_callsign) and is_binary(path_string) and is_function(station_lookup, 1) do
|
||||
path_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reduce({{sender_callsign, sender_pos}, []}, fn token, {source, hops} ->
|
||||
process_token(token, source, hops, heard_at, station_lookup)
|
||||
end)
|
||||
|> elem(1)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp process_token("", source, hops, _heard_at, _lookup), do: {source, hops}
|
||||
|
||||
defp process_token(token, source, hops, heard_at, lookup) do
|
||||
used? = String.ends_with?(token, "*")
|
||||
callsign = if used?, do: String.trim_trailing(token, "*"), else: token
|
||||
|
||||
callsign
|
||||
|> classify()
|
||||
|> handle(callsign, used?, source, hops, heard_at, lookup)
|
||||
end
|
||||
|
||||
defp classify(token) do
|
||||
cond do
|
||||
token in @q_constructs -> :q_construct
|
||||
token in ["TCPIP", "TCPXX"] -> :internet
|
||||
Regex.match?(@alias_regex, token) -> :alias
|
||||
Regex.match?(@callsign_regex, token) -> :callsign
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp handle(:callsign, callsign, true, source, hops, heard_at, lookup) do
|
||||
case lookup.(callsign) do
|
||||
nil ->
|
||||
# Used flag present but no known position — drop, do NOT advance source.
|
||||
{source, hops}
|
||||
|
||||
dst_pos ->
|
||||
{src_callsign, src_pos} = source
|
||||
|
||||
if self_loop?(src_callsign, callsign) do
|
||||
{source, hops}
|
||||
else
|
||||
hop = build_hop(src_callsign, src_pos, callsign, dst_pos, heard_at)
|
||||
{{callsign, dst_pos}, [hop | hops]}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle(:callsign, _callsign, false, source, hops, _heard_at, _lookup) do
|
||||
# Real callsign without used flag — routing target, did not digipeat.
|
||||
{source, hops}
|
||||
end
|
||||
|
||||
defp handle(_other, _callsign, _used?, source, hops, _heard_at, _lookup) do
|
||||
# q_construct, internet, alias (used or not), unknown — never emit a hop.
|
||||
{source, hops}
|
||||
end
|
||||
|
||||
defp self_loop?(src_callsign, dst_callsign) do
|
||||
src_callsign == dst_callsign
|
||||
end
|
||||
|
||||
defp build_hop(src_callsign, src_pos, dst_callsign, dst_pos, heard_at) do
|
||||
{src_lat, src_lon} = src_pos
|
||||
{dst_lat, dst_lon} = dst_pos
|
||||
|
||||
%{
|
||||
src_callsign: src_callsign,
|
||||
src_pos: src_pos,
|
||||
dst_callsign: dst_callsign,
|
||||
dst_pos: dst_pos,
|
||||
distance_km: Geo.haversine_km(src_lat, src_lon, dst_lat, dst_lon),
|
||||
heard_at: heard_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
defmodule Microwaveprop.AprsRepo do
|
||||
@moduledoc """
|
||||
Secondary repo for read-only access to aprs.me's `packets` table.
|
||||
|
||||
Used by `Microwaveprop.Aprs` and the `Mix.Tasks.Calibrate.Aprs144` mix
|
||||
task to pull verified RF hops as ground truth for 144 MHz scoring.
|
||||
|
||||
This repo never owns schema, migrations, or writes — it only ever
|
||||
issues SELECT queries against aprs.me's existing partitioned `packets`
|
||||
table. The connection is configured to fail closed if `APRS_DATABASE_URL`
|
||||
is unset in production.
|
||||
"""
|
||||
use Ecto.Repo,
|
||||
otp_app: :microwaveprop,
|
||||
adapter: Ecto.Adapters.Postgres,
|
||||
read_only: true
|
||||
end
|
||||
|
|
@ -56,6 +56,10 @@ defmodule MicrowavepropWeb do
|
|||
quote do
|
||||
use Phoenix.LiveView
|
||||
|
||||
if Application.compile_env(:microwaveprop, :sql_sandbox) do
|
||||
on_mount MicrowavepropWeb.SandboxHook
|
||||
end
|
||||
|
||||
on_mount MicrowavepropWeb.UserAuth
|
||||
|
||||
unquote(html_helpers())
|
||||
|
|
|
|||
|
|
@ -14,8 +14,20 @@ defmodule MicrowavepropWeb.Endpoint do
|
|||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
websocket: [connect_info: [:user_agent, session: @session_options]],
|
||||
longpoll: [connect_info: [:user_agent, session: @session_options]]
|
||||
|
||||
# Allows the LiveView connected mount (a separate process from the one
|
||||
# handling the initial HTTP request) to see the same Ecto Sandbox
|
||||
# transaction as the test that inserted its fixture data. Without this,
|
||||
# `ConnCase`/`DataCase` async tests that insert a record and immediately
|
||||
# `live/2` into it 404 — the connected mount's process isn't in the
|
||||
# Sandbox's `$callers` chain, so it falls back to an unsandboxed
|
||||
# connection that can't see the uncommitted row. Paired with
|
||||
# `MicrowavepropWeb.on_mount(:sandbox, ...)` in `MicrowavepropWeb.live_view/0`.
|
||||
if Application.compile_env(:microwaveprop, :sql_sandbox) do
|
||||
plug Phoenix.Ecto.SQL.Sandbox
|
||||
end
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ defmodule MicrowavepropWeb.Router do
|
|||
alias MicrowavepropWeb.Api.RateLimiter
|
||||
alias MicrowavepropWeb.Api.V1
|
||||
|
||||
# Router-level `live_session` on_mount hooks run before the ones a
|
||||
# LiveView module declares itself (via `use MicrowavepropWeb, :live_view`),
|
||||
# so the sandbox-allow hook has to be prepended here too — otherwise
|
||||
# `{MicrowavepropWeb.UserAuth, ...}` queries the DB before the connected
|
||||
# mount's process has been allowed onto the test's Ecto Sandbox
|
||||
# connection. See `MicrowavepropWeb.SandboxHook`.
|
||||
@sandbox_on_mount if Application.compile_env(:microwaveprop, :sql_sandbox),
|
||||
do: [MicrowavepropWeb.SandboxHook],
|
||||
else: []
|
||||
|
||||
pipeline :browser do
|
||||
plug :fetch_session
|
||||
plug :store_remote_ip
|
||||
|
|
@ -233,7 +243,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
scope "/", MicrowavepropWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
|
||||
live_session :admin, on_mount: @sandbox_on_mount ++ [{MicrowavepropWeb.UserAuth, :require_admin}] do
|
||||
live "/beacons/:id/edit", BeaconLive.Form, :edit
|
||||
|
||||
live "/status", StatusLive
|
||||
|
|
@ -260,7 +270,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
get "/docs/api/openapi.yaml", ApiDocsController, :openapi_yaml
|
||||
get "/docs/api/README.md", ApiDocsController, :readme_markdown
|
||||
|
||||
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
|
||||
live_session :public, on_mount: @sandbox_on_mount ++ [{MicrowavepropWeb.UserAuth, :default}] do
|
||||
live "/docs/api", ApiDocsLive
|
||||
live "/submit", SubmitLive
|
||||
live "/imports/:id", ImportLive
|
||||
|
|
|
|||
27
lib/microwaveprop_web/sandbox_hook.ex
Normal file
27
lib/microwaveprop_web/sandbox_hook.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule MicrowavepropWeb.SandboxHook do
|
||||
@moduledoc """
|
||||
Test-only `on_mount` hook that allows a LiveView's connected mount to
|
||||
use the same Ecto Sandbox connection as the test that's driving it.
|
||||
|
||||
Only wired in when `:sql_sandbox` is set (see `config/test.exs` and
|
||||
`MicrowavepropWeb.Endpoint`).
|
||||
"""
|
||||
import Phoenix.Component, only: [assign_new: 3]
|
||||
import Phoenix.LiveView, only: [connected?: 1, get_connect_info: 2]
|
||||
|
||||
alias Phoenix.LiveView.Socket
|
||||
|
||||
@spec on_mount(atom(), map(), map(), Socket.t()) :: {:cont, Socket.t()}
|
||||
def on_mount(:default, _params, _session, socket) do
|
||||
socket =
|
||||
assign_new(socket, :phoenix_ecto_sandbox, fn ->
|
||||
if connected?(socket), do: get_connect_info(socket, :user_agent)
|
||||
end)
|
||||
|
||||
result = Phoenix.Ecto.SQL.Sandbox.allow(socket.assigns.phoenix_ecto_sandbox, Ecto.Adapters.SQL.Sandbox)
|
||||
|
||||
IO.puts("DEBUG SandboxHook connected?=#{connected?(socket)} self=#{inspect(self())} allow_result=#{inspect(result)}")
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
defmodule Mix.Tasks.Calibrate.Aprs144 do
|
||||
@shortdoc "Fit 144 MHz scoring weights from aprs.me's last 24h of verified RF hops"
|
||||
@moduledoc """
|
||||
Pulls recent position-bearing APRS packets from aprs.me via
|
||||
`Microwaveprop.AprsRepo`, parses TNC2 paths into verified RF hops via
|
||||
`Microwaveprop.Aprs.PathParser`, computes a 10-element propagation factor
|
||||
vector at each hop's midpoint via `Microwaveprop.Propagation.Recalibrator`,
|
||||
and runs gradient descent against random-baseline negatives to fit new
|
||||
144 MHz weights.
|
||||
|
||||
This is a dry-run: the proposed weights are printed but `band_config.ex`
|
||||
is NOT modified. Operators copy the weights into the 144 MHz block
|
||||
manually after reviewing the train/val loss numbers.
|
||||
|
||||
## Usage
|
||||
|
||||
mix calibrate.aprs_144
|
||||
mix calibrate.aprs_144 --since-hours 6 --epochs 3000 --lr 0.005
|
||||
|
||||
## Options
|
||||
|
||||
* `--since-hours` — packet window in hours (default: 24, max recent
|
||||
retention in aprs.me prod)
|
||||
* `--epochs` — gradient-descent iterations (default: 2000)
|
||||
* `--lr` — learning rate (default: 0.01)
|
||||
* `--max-packets` — cap on packets pulled from aprs.me (default: 50_000)
|
||||
"""
|
||||
use Mix.Task
|
||||
|
||||
alias Microwaveprop.Aprs
|
||||
alias Microwaveprop.Aprs.PathParser
|
||||
alias Microwaveprop.Backtest
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.Recalibrator
|
||||
alias Microwaveprop.Weather
|
||||
|
||||
@factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a
|
||||
@min_samples 50
|
||||
@band_mhz 144
|
||||
|
||||
@impl Mix.Task
|
||||
def run(argv) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
# Guard runs BEFORE the Oban pause so a misconfig exits without
|
||||
# leaving queues paused. Subsequent failures are inside try/after.
|
||||
if BandConfig.get(@band_mhz) == nil do
|
||||
Mix.raise("BandConfig has no #{@band_mhz} MHz entry; cannot calibrate.")
|
||||
end
|
||||
|
||||
_ = Oban.pause_all_queues(Oban)
|
||||
|
||||
try do
|
||||
do_run(argv)
|
||||
after
|
||||
# Allow `iex -S mix` workflows to keep using Oban after the task
|
||||
# returns; for one-shot Mix invocations the BEAM exits and this is
|
||||
# a no-op.
|
||||
_ = Oban.resume_all_queues(Oban)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_run(argv) do
|
||||
{opts, _, _} =
|
||||
OptionParser.parse(argv,
|
||||
switches: [
|
||||
since_hours: :integer,
|
||||
epochs: :integer,
|
||||
lr: :float,
|
||||
max_packets: :integer
|
||||
]
|
||||
)
|
||||
|
||||
since_hours = Keyword.get(opts, :since_hours, 24)
|
||||
epochs = Keyword.get(opts, :epochs, 2000)
|
||||
learning_rate = Keyword.get(opts, :lr, 0.01)
|
||||
max_packets = Keyword.get(opts, :max_packets, 50_000)
|
||||
|
||||
Mix.shell().info("APRS-144 calibration")
|
||||
Mix.shell().info(" since_hours: #{since_hours}")
|
||||
Mix.shell().info(" epochs: #{epochs}")
|
||||
Mix.shell().info(" learning_rate: #{learning_rate}")
|
||||
Mix.shell().info(" max_packets: #{max_packets}")
|
||||
Mix.shell().info("")
|
||||
|
||||
since = DateTime.add(DateTime.utc_now(), -since_hours * 3600, :second)
|
||||
packets = Aprs.recent_packets_with_paths(since: since, limit: max_packets)
|
||||
Mix.shell().info("Pulled #{length(packets)} packets from aprs.me")
|
||||
|
||||
callsigns = collect_callsigns(packets)
|
||||
positions = Aprs.station_positions(callsigns)
|
||||
Mix.shell().info("Resolved #{map_size(positions)} / #{length(callsigns)} digi positions")
|
||||
|
||||
lookup = build_lookup(positions)
|
||||
hops = parse_all_hops(packets, lookup)
|
||||
Mix.shell().info("Parsed #{length(hops)} verified RF hops")
|
||||
|
||||
positives = compute_positive_factors(hops)
|
||||
|
||||
Mix.shell().info(
|
||||
"Computed #{length(positives)} / #{length(hops)} positive factor vectors " <>
|
||||
"(#{length(hops) - length(positives)} hops dropped for missing HRRR coverage)"
|
||||
)
|
||||
|
||||
if length(positives) < @min_samples do
|
||||
Mix.shell().info("")
|
||||
Mix.shell().info("Refusing to fit: only #{length(positives)} positive samples (need >= #{@min_samples})")
|
||||
else
|
||||
{negatives, attempted} = compute_negative_factors(length(positives))
|
||||
|
||||
Mix.shell().info(
|
||||
"Computed #{length(negatives)} / #{attempted} negative factor vectors " <>
|
||||
"(#{attempted - length(negatives)} samples dropped for missing HRRR coverage)"
|
||||
)
|
||||
|
||||
if length(negatives) < @min_samples do
|
||||
Mix.shell().info("")
|
||||
Mix.shell().info("Refusing to fit: only #{length(negatives)} negative samples (need >= #{@min_samples})")
|
||||
else
|
||||
result = Recalibrator.train(positives, negatives, learning_rate: learning_rate, epochs: epochs)
|
||||
print_results(result, positives, negatives)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── Private ──────────────────────────────────────────────────────
|
||||
|
||||
defp collect_callsigns(packets) do
|
||||
packets
|
||||
|> Enum.flat_map(fn %{path: path} ->
|
||||
path
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.map(&String.trim_trailing(&1, "*"))
|
||||
end)
|
||||
|> Enum.filter(&PathParser.valid_callsign?/1)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp build_lookup(positions) do
|
||||
fn callsign ->
|
||||
case Map.get(positions, callsign) do
|
||||
nil -> nil
|
||||
{lat, lon, _heard_at} -> {lat, lon}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_all_hops(packets, lookup) do
|
||||
Enum.flat_map(packets, fn pkt ->
|
||||
PathParser.parse_hops(pkt.sender, {pkt.lat, pkt.lon}, pkt.path, pkt.received_at, lookup)
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_positive_factors(hops) do
|
||||
Enum.flat_map(hops, fn hop ->
|
||||
{src_lat, src_lon} = hop.src_pos
|
||||
{dst_lat, dst_lon} = hop.dst_pos
|
||||
mid_lat = (src_lat + dst_lat) / 2.0
|
||||
mid_lon = (src_lon + dst_lon) / 2.0
|
||||
|
||||
case Weather.find_nearest_hrrr(mid_lat, mid_lon, hop.heard_at) do
|
||||
nil -> []
|
||||
profile -> [Recalibrator.compute_factors(profile, hop.heard_at, @band_mhz)]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_negative_factors(n) do
|
||||
# Use a wide contact-pool draw (5_000 minimum) so the random baseline
|
||||
# samples don't cluster geographically when n is small. Backtest's
|
||||
# default :sample_size is 5_000.
|
||||
#
|
||||
# v1 limitation: negatives sample from the full contacts table (all
|
||||
# bands, dominantly 10 GHz tropo). For a 144 MHz fit this means the
|
||||
# trainer separates "verified VHF receive" from "anywhere a 10 GHz
|
||||
# contact happened with timestamp jitter". A future iteration should
|
||||
# draw negatives from APRS coverage where no `*` digi handled the
|
||||
# frame in the same window — true band-matched negatives.
|
||||
baselines = Backtest.random_baseline(n, sample_size: max(n, 5_000))
|
||||
|
||||
factors =
|
||||
Enum.flat_map(baselines, fn {lat, lon, time} ->
|
||||
case Weather.find_nearest_hrrr(lat, lon, time) do
|
||||
nil -> []
|
||||
profile -> [Recalibrator.compute_factors(profile, time, @band_mhz)]
|
||||
end
|
||||
end)
|
||||
|
||||
{factors, length(baselines)}
|
||||
end
|
||||
|
||||
defp print_results(result, positives, negatives) do
|
||||
Mix.shell().info("")
|
||||
Mix.shell().info("APRS-144 calibration result")
|
||||
Mix.shell().info(" positives: #{length(positives)} hops with HRRR coverage")
|
||||
Mix.shell().info(" negatives: #{length(negatives)} random-baseline samples")
|
||||
Mix.shell().info(" train loss: #{format_float(result.train_loss)}")
|
||||
Mix.shell().info(" val loss: #{format_float(result.val_loss)}")
|
||||
Mix.shell().info(" initial loss: #{format_float(result.initial_loss)}")
|
||||
Mix.shell().info("")
|
||||
|
||||
current_weights = BandConfig.weights()
|
||||
|
||||
Mix.shell().info("Current 144 MHz weights (BandConfig defaults — no per-band override):")
|
||||
print_weights(current_weights)
|
||||
Mix.shell().info("")
|
||||
|
||||
Mix.shell().info("Proposed 144 MHz weights (this fit):")
|
||||
print_weights(result.weights)
|
||||
end
|
||||
|
||||
defp print_weights(weights) do
|
||||
Enum.each(@factor_keys, fn key ->
|
||||
value = Map.get(weights, key, 0.0)
|
||||
label = key |> Atom.to_string() |> Kernel.<>(":")
|
||||
formatted = "~-13s ~.4f" |> :io_lib.format([label, value * 1.0]) |> IO.iodata_to_binary()
|
||||
Mix.shell().info(" " <> formatted)
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4)
|
||||
end
|
||||
9
mix.exs
9
mix.exs
|
|
@ -8,11 +8,20 @@ defmodule Microwaveprop.MixProject do
|
|||
elixir: "~> 1.15",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
# Disable protocol consolidation in test to avoid stale dispatch
|
||||
# tables that omit protocol implementations from :only test deps
|
||||
# (e.g. Enumerable.LazyHTML from lazy_html).
|
||||
consolidate_protocols: Mix.env() != :test,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
test_coverage: [summary: [threshold: 85]],
|
||||
# Disable protocol consolidation in test so test-only deps
|
||||
# (lazy_html) that implement core protocols (Enumerable) are
|
||||
# dispatched at runtime instead of failing at compile-time
|
||||
# consolidation.
|
||||
consolidate_protocols: Mix.env() != :test,
|
||||
dialyzer: [
|
||||
plt_add_apps: [:mix, :ex_unit],
|
||||
plt_file: {:no_warn, "priv/plts/project.plt"},
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
defmodule Microwaveprop.Aprs.PathParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Aprs.PathParser
|
||||
|
||||
@sender "N5XXX-9"
|
||||
@sender_pos {32.897, -97.038}
|
||||
@heard_at ~U[2026-04-30 12:00:00Z]
|
||||
|
||||
defp lookup_fn(map) do
|
||||
fn callsign -> Map.get(map, callsign) end
|
||||
end
|
||||
|
||||
describe "parse_hops/5" do
|
||||
test "single used-flag callsign emits one hop from sender" do
|
||||
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*,WIDE1-1,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert [hop] = hops
|
||||
assert hop.src_callsign == @sender
|
||||
assert hop.src_pos == @sender_pos
|
||||
assert hop.dst_callsign == "K5GVL-10"
|
||||
assert hop.dst_pos == {32.95, -96.10}
|
||||
assert hop.heard_at == @heard_at
|
||||
assert is_float(hop.distance_km)
|
||||
end
|
||||
|
||||
test "two chained used-flag callsigns emit two hops, source advances" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5XYZ-3" => {33.0, -96.5},
|
||||
"W5ABC-7" => {33.5, -95.5}
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5XYZ-3*,W5ABC-7*,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert Enum.count_until(hops, 3) == 2
|
||||
[first, second] = hops
|
||||
|
||||
assert first.src_callsign == @sender
|
||||
assert first.src_pos == @sender_pos
|
||||
assert first.dst_callsign == "K5XYZ-3"
|
||||
assert first.dst_pos == {33.0, -96.5}
|
||||
|
||||
assert second.src_callsign == "K5XYZ-3"
|
||||
assert second.src_pos == {33.0, -96.5}
|
||||
assert second.dst_callsign == "W5ABC-7"
|
||||
assert second.dst_pos == {33.5, -95.5}
|
||||
end
|
||||
|
||||
test "anonymous WIDE1* used-flag yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WIDE1*,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "mixed path with no used real callsigns yields no hops" do
|
||||
lookup = lookup_fn(%{"K5VOM-10" => {32.5, -96.5}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "trailing unused real callsign does not emit a hop" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5GVL-10" => {32.95, -96.10},
|
||||
"N5TXZ-10" => {33.1, -96.2}
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*,N5TXZ-10",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert [hop] = hops
|
||||
assert hop.dst_callsign == "K5GVL-10"
|
||||
end
|
||||
|
||||
test "TCPIP and Q-construct yield no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"TCPIP*,qAC,T2TEXAS",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "lookup returns nil for a used digi → drop that hop and do NOT advance source" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5XYZ-3" => {33.0, -96.5},
|
||||
# W5ABC-7 deliberately missing
|
||||
"K5DEF-1" => {33.5, -95.5}
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5XYZ-3*,W5ABC-7*,K5DEF-1*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert Enum.count_until(hops, 3) == 2
|
||||
# First hop is sender → K5XYZ-3 as before.
|
||||
assert Enum.at(hops, 0).src_callsign == @sender
|
||||
assert Enum.at(hops, 0).dst_callsign == "K5XYZ-3"
|
||||
# Critical: second emitted hop chains from K5XYZ-3 (NOT from W5ABC-7
|
||||
# which was dropped). Proves source did not advance through the
|
||||
# missing-position digi.
|
||||
assert Enum.at(hops, 1).src_callsign == "K5XYZ-3"
|
||||
assert Enum.at(hops, 1).dst_callsign == "K5DEF-1"
|
||||
end
|
||||
|
||||
test "empty path string yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops = PathParser.parse_hops(@sender, @sender_pos, "", @heard_at, lookup)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "aliases-only path yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WIDE1-1,WIDE2-2",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "lowercase Q-construct qAo is recognized and yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"qAo",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "Q-construct followed by IGate name yields no hops" do
|
||||
lookup = lookup_fn(%{"IGATE-CALL" => {33.0, -96.0}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"qAR,IGATE-CALL",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "self-loop suppressed when used digi callsign matches sender callsign" do
|
||||
# Sender sees its own callsign reflected in the digi path (corrupted
|
||||
# frame). Should be skipped.
|
||||
lookup = lookup_fn(%{@sender => {99.0, 99.0}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"#{@sender}*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "haversine distance for DFW → K5GVL-10 is approximately 88.4 km" do
|
||||
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
|
||||
|
||||
[hop] =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert abs(hop.distance_km - 88.4) < 1.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
defmodule Microwaveprop.AprsRepoTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.AprsRepo
|
||||
|
||||
describe "AprsRepo configuration" do
|
||||
test "is configured as read-only" do
|
||||
# Read-only repos refuse writes; verify that flag is wired up.
|
||||
assert AprsRepo.__adapter__() == Ecto.Adapters.Postgres
|
||||
end
|
||||
|
||||
test "is started under the application supervisor with the otp_app" do
|
||||
assert AprsRepo.config()[:otp_app] == :microwaveprop
|
||||
end
|
||||
|
||||
test "exposes the standard Ecto.Repo callbacks" do
|
||||
assert {:error, _reason} = AprsRepo.start_link([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
defmodule Microwaveprop.AprsTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Microwaveprop.Aprs
|
||||
alias Microwaveprop.AprsRepo
|
||||
|
||||
setup do
|
||||
pid = Sandbox.start_owner!(AprsRepo, shared: true)
|
||||
on_exit(fn -> Sandbox.stop_owner(pid) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp insert_packet(opts) do
|
||||
fields =
|
||||
Keyword.merge(
|
||||
[
|
||||
sender: "TEST-A",
|
||||
base_callsign: "TEST-A",
|
||||
lat: 33.0,
|
||||
lon: -97.0,
|
||||
path: "WIDE1*",
|
||||
received_at: NaiveDateTime.utc_now(),
|
||||
has_position: true,
|
||||
is_item: false,
|
||||
is_object: false
|
||||
],
|
||||
opts
|
||||
)
|
||||
|
||||
SQL.query!(
|
||||
AprsRepo,
|
||||
"""
|
||||
INSERT INTO packets
|
||||
(id, sender, base_callsign, lat, lon, path, received_at,
|
||||
has_position, is_item, is_object, inserted_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, now(), now())
|
||||
""",
|
||||
[
|
||||
fields[:sender],
|
||||
fields[:base_callsign],
|
||||
fields[:lat],
|
||||
fields[:lon],
|
||||
fields[:path],
|
||||
fields[:received_at],
|
||||
fields[:has_position],
|
||||
fields[:is_item],
|
||||
fields[:is_object]
|
||||
]
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "recent_packets_with_paths/1" do
|
||||
test "filters out is_item, is_object, has_position=false, empty path, and null lat/lon" do
|
||||
now = NaiveDateTime.utc_now()
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
|
||||
# 1 valid row
|
||||
:ok = insert_packet(sender: "TEST-VALID", path: "K5GVL-10*,WIDE1-1", received_at: now)
|
||||
|
||||
# 7 rejected rows
|
||||
:ok = insert_packet(sender: "TEST-ITEM", is_item: true, received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-OBJECT", is_object: true, received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-NOPOS", has_position: false, received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-EMPTYPATH", path: "", received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-NULLPATH", path: nil, received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-NULLLAT", lat: nil, received_at: now)
|
||||
:ok = insert_packet(sender: "TEST-NULLLON", lon: nil, received_at: now)
|
||||
|
||||
rows = Aprs.recent_packets_with_paths(since: since)
|
||||
|
||||
assert Enum.count_until(rows, 2) == 1
|
||||
assert hd(rows).sender == "TEST-VALID"
|
||||
end
|
||||
|
||||
test "honors :since" do
|
||||
now = DateTime.utc_now()
|
||||
recent = NaiveDateTime.add(NaiveDateTime.utc_now(), -1800, :second)
|
||||
old = NaiveDateTime.add(NaiveDateTime.utc_now(), -5400, :second)
|
||||
|
||||
:ok = insert_packet(sender: "TEST-RECENT", received_at: recent)
|
||||
:ok = insert_packet(sender: "TEST-OLD", received_at: old)
|
||||
|
||||
since = DateTime.add(now, -3600, :second)
|
||||
rows = Aprs.recent_packets_with_paths(since: since)
|
||||
|
||||
senders = Enum.map(rows, & &1.sender)
|
||||
assert "TEST-RECENT" in senders
|
||||
refute "TEST-OLD" in senders
|
||||
end
|
||||
|
||||
test "honors :limit" do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
|
||||
Enum.each(1..5, fn i ->
|
||||
# Stagger by seconds so ordering is deterministic.
|
||||
ts = NaiveDateTime.add(NaiveDateTime.utc_now(), -i, :second)
|
||||
:ok = insert_packet(sender: "TEST-L#{i}", received_at: ts)
|
||||
end)
|
||||
|
||||
rows = Aprs.recent_packets_with_paths(since: since, limit: 2)
|
||||
assert Enum.count_until(rows, 3) == 2
|
||||
end
|
||||
|
||||
test "returns rows in received_at ascending order" do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
base = NaiveDateTime.utc_now()
|
||||
|
||||
:ok = insert_packet(sender: "TEST-MID", received_at: NaiveDateTime.add(base, -120, :second))
|
||||
:ok = insert_packet(sender: "TEST-OLDEST", received_at: NaiveDateTime.add(base, -300, :second))
|
||||
:ok = insert_packet(sender: "TEST-NEWEST", received_at: NaiveDateTime.add(base, -10, :second))
|
||||
|
||||
rows =
|
||||
since
|
||||
|> then(&Aprs.recent_packets_with_paths(since: &1))
|
||||
|> Enum.filter(&String.starts_with?(&1.sender, "TEST-"))
|
||||
|
||||
senders = Enum.map(rows, & &1.sender)
|
||||
assert senders == ["TEST-OLDEST", "TEST-MID", "TEST-NEWEST"]
|
||||
end
|
||||
|
||||
test "decodes Decimal lat/lon as floats" do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
:ok = insert_packet(sender: "TEST-FLOAT", lat: 33.123, lon: -97.456)
|
||||
|
||||
rows = Aprs.recent_packets_with_paths(since: since)
|
||||
row = Enum.find(rows, &(&1.sender == "TEST-FLOAT"))
|
||||
|
||||
assert is_float(row.lat)
|
||||
assert is_float(row.lon)
|
||||
assert_in_delta row.lat, 33.123, 0.0001
|
||||
assert_in_delta row.lon, -97.456, 0.0001
|
||||
end
|
||||
|
||||
test "decodes received_at as a UTC DateTime" do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
:ok = insert_packet(sender: "TEST-TZ")
|
||||
|
||||
rows = Aprs.recent_packets_with_paths(since: since)
|
||||
row = Enum.find(rows, &(&1.sender == "TEST-TZ"))
|
||||
|
||||
assert %DateTime{} = row.received_at
|
||||
assert row.received_at.time_zone == "Etc/UTC"
|
||||
end
|
||||
end
|
||||
|
||||
describe "station_positions/1" do
|
||||
test "returns the most recent fix per callsign and omits unknown callsigns" do
|
||||
base = NaiveDateTime.utc_now()
|
||||
|
||||
:ok =
|
||||
insert_packet(
|
||||
sender: "TEST-A",
|
||||
base_callsign: "TEST-A",
|
||||
lat: 30.0,
|
||||
lon: -90.0,
|
||||
received_at: NaiveDateTime.add(base, -3600, :second)
|
||||
)
|
||||
|
||||
:ok =
|
||||
insert_packet(
|
||||
sender: "TEST-A",
|
||||
base_callsign: "TEST-A",
|
||||
lat: 31.0,
|
||||
lon: -91.0,
|
||||
received_at: NaiveDateTime.add(base, -1800, :second)
|
||||
)
|
||||
|
||||
:ok =
|
||||
insert_packet(
|
||||
sender: "TEST-A",
|
||||
base_callsign: "TEST-A",
|
||||
lat: 32.5,
|
||||
lon: -92.5,
|
||||
received_at: NaiveDateTime.add(base, -60, :second)
|
||||
)
|
||||
|
||||
:ok =
|
||||
insert_packet(
|
||||
sender: "TEST-B",
|
||||
base_callsign: "TEST-B",
|
||||
lat: 40.0,
|
||||
lon: -100.0,
|
||||
received_at: NaiveDateTime.add(base, -300, :second)
|
||||
)
|
||||
|
||||
result = Aprs.station_positions(["TEST-A", "TEST-B", "TEST-C"])
|
||||
|
||||
assert result |> Map.keys() |> Enum.sort() == ["TEST-A", "TEST-B"]
|
||||
|
||||
{lat_a, lon_a, heard_a} = result["TEST-A"]
|
||||
assert_in_delta lat_a, 32.5, 0.0001
|
||||
assert_in_delta lon_a, -92.5, 0.0001
|
||||
assert %DateTime{} = heard_a
|
||||
assert heard_a.time_zone == "Etc/UTC"
|
||||
|
||||
{lat_b, lon_b, _heard_b} = result["TEST-B"]
|
||||
assert_in_delta lat_b, 40.0, 0.0001
|
||||
assert_in_delta lon_b, -100.0, 0.0001
|
||||
|
||||
refute Map.has_key?(result, "TEST-C")
|
||||
end
|
||||
|
||||
test "empty list short-circuits to %{} without querying" do
|
||||
# Stop the sandbox owner so a real query would crash with NoConnectionError.
|
||||
Sandbox.checkin(AprsRepo)
|
||||
assert Aprs.station_positions([]) == %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
defmodule Mix.Tasks.Calibrate.Aprs144Test do
|
||||
@moduledoc """
|
||||
Smoke test for the APRS-144 calibration mix task. The task is mostly
|
||||
orchestration: Aprs query + PathParser + Recalibrator. Each piece has
|
||||
its own unit tests, so this only confirms the wiring holds together
|
||||
and that the empty-corpus path exits via the "refusing to fit" branch
|
||||
rather than crashing.
|
||||
"""
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Microwaveprop.AprsRepo
|
||||
alias Mix.Tasks.Calibrate.Aprs144
|
||||
|
||||
setup do
|
||||
pid = Sandbox.start_owner!(AprsRepo, shared: true)
|
||||
on_exit(fn -> Sandbox.stop_owner(pid) end)
|
||||
|
||||
original_shell = Mix.shell()
|
||||
Mix.shell(Mix.Shell.Process)
|
||||
on_exit(fn -> Mix.shell(original_shell) end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "empty aprs DB walks the refuse-to-fit branch without raising" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
# Tiny knobs so the task is fast even if it ever did reach training.
|
||||
Aprs144.run(["--since-hours", "1", "--epochs", "1", "--lr", "0.1", "--max-packets", "10"])
|
||||
end)
|
||||
|
||||
# Mix.shell() messages land in the test mailbox; collect them all.
|
||||
messages = collect_mix_messages()
|
||||
combined = Enum.join(messages, "\n") <> "\n" <> output
|
||||
|
||||
assert combined =~ "APRS-144 calibration"
|
||||
assert combined =~ "Pulled 0 packets"
|
||||
assert combined =~ "Refusing to fit"
|
||||
end
|
||||
|
||||
defp collect_mix_messages(acc \\ []) do
|
||||
receive do
|
||||
{:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc])
|
||||
after
|
||||
0 -> Enum.reverse(acc)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -23,6 +23,7 @@ defmodule MicrowavepropWeb.ConnCase do
|
|||
alias Microwaveprop.DataCase
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Microwaveprop.Weather.GridCache
|
||||
alias Phoenix.Ecto.SQL.Sandbox
|
||||
|
||||
using do
|
||||
quote do
|
||||
|
|
@ -39,12 +40,20 @@ defmodule MicrowavepropWeb.ConnCase do
|
|||
end
|
||||
|
||||
setup tags do
|
||||
DataCase.setup_sandbox(tags)
|
||||
owner = DataCase.setup_sandbox(tags)
|
||||
DataCase.reset_score_files()
|
||||
DataCase.stub_nexrad_default()
|
||||
GridCache.clear()
|
||||
ScoreCache.clear()
|
||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||
|
||||
# Stamp the sandbox metadata onto the "user-agent" header so a
|
||||
# LiveView's connected mount (a separate process from this one, per
|
||||
# `MicrowavepropWeb.SandboxHook`) can see this test's transaction.
|
||||
metadata = Sandbox.metadata_for(Microwaveprop.Repo, owner)
|
||||
|
||||
conn = Plug.Conn.put_req_header(Phoenix.ConnTest.build_conn(), "user-agent", Sandbox.encode_metadata(metadata))
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -49,12 +49,31 @@ defmodule Microwaveprop.DataCase do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Sets up the sandbox based on the test tags.
|
||||
Sets up the sandbox based on the test tags. Returns the owner pid so
|
||||
callers (e.g. `MicrowavepropWeb.ConnCase`) can build Ecto Sandbox
|
||||
metadata for out-of-process access (LiveView's connected mount).
|
||||
"""
|
||||
@spec setup_sandbox(map()) :: :ok
|
||||
@spec setup_sandbox(map()) :: pid()
|
||||
def setup_sandbox(tags) do
|
||||
pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: not tags[:async])
|
||||
on_exit(fn -> Sandbox.stop_owner(pid) end)
|
||||
shared? = not tags[:async]
|
||||
pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: shared?)
|
||||
|
||||
on_exit(fn ->
|
||||
Sandbox.stop_owner(pid)
|
||||
|
||||
# Ecto reverts the pool to :manual mode when a `{:shared, pid}`
|
||||
# owner terminates (see Ecto.Adapters.SQL.Sandbox docs). Since
|
||||
# test_helper.exs relies on global :auto mode so Oban's
|
||||
# background plugins (Stager, Peers, Met.Reporter) can
|
||||
# auto-checkout, every `async: false` test permanently flips the
|
||||
# pool to :manual once it exits — starving those processes of
|
||||
# connections and crash-looping Oban until it exceeds its
|
||||
# restart intensity and takes the whole app (and Repo) down with
|
||||
# it. Restore :auto after any shared owner exits.
|
||||
if shared?, do: Sandbox.mode(Microwaveprop.Repo, :auto)
|
||||
end)
|
||||
|
||||
pid
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ ExUnit.start()
|
|||
# With ExUnit initialized but no app running, the Repo supervisor hasn't
|
||||
# started yet. Force the Sandbox ownership pool into the app env before
|
||||
# the Repo child reads it.
|
||||
for repo <- [Microwaveprop.Repo, Microwaveprop.AprsRepo] do
|
||||
for repo <- [Microwaveprop.Repo] do
|
||||
existing = Application.get_env(:microwaveprop, repo, [])
|
||||
|
||||
if existing[:url] || existing[:database] do
|
||||
|
|
@ -43,7 +43,21 @@ end
|
|||
# DataCase.setup_sandbox/1 still calls start_owner!/2 for per-test
|
||||
# isolation, which is compatible with :auto mode.
|
||||
Sandbox.mode(Microwaveprop.Repo, :auto)
|
||||
Sandbox.mode(Microwaveprop.AprsRepo, :auto)
|
||||
|
||||
# lazy_html provides an Enumerable protocol implementation for the
|
||||
# LazyHTML struct. When protocol consolidation is enabled (the default),
|
||||
# the consolidated Elixir.Enumerable.beam file is generated during
|
||||
# `mix compile`. If that consolidation pass runs before lazy_html is on
|
||||
# the code path, the dispatch table omits LazyHTML — causing every test
|
||||
# that uses Phoenix.LiveViewTest (which calls Enum.each/2 on LazyHTML
|
||||
# structs via DOM.parse_document/2) to crash with:
|
||||
#
|
||||
# (Protocol.UndefinedError) protocol Enumerable not implemented for
|
||||
# LazyHTML (a struct)
|
||||
#
|
||||
# mix.exs disables `consolidate_protocols` in the test environment so
|
||||
# the protocol uses dynamic dispatch instead, which always finds the
|
||||
# Enumerable.LazyHTML implementation at runtime.
|
||||
|
||||
# Silence sandbox-cleanup disconnect noise. When a test process exits
|
||||
# while still owning a Postgrex connection, the protocol logs an
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue