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

- 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:
Graham McIntire 2026-08-04 12:12:28 -05:00
parent 08fe568cd7
commit 933397d246
21 changed files with 127 additions and 1113 deletions

View file

@ -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 && \

View file

@ -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",

View file

@ -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"],

View file

@ -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]

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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.
#

View file

@ -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

View 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

View file

@ -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

View file

@ -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"},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 """

View file

@ -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 """

View file

@ -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