test: lift coverage to 85% and pin threshold

Adds tests for previously under-covered modules so the cover-tool
threshold check passes:

  * Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
    the algorithm/ML scoring path, write_latest, append_history,
    read_history (incl. the unparseable-line arm), and the per-band
    summary loop.
  * Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
    months take the task through load_training_data, shuffle, split,
    train, eval, save, and the monthly-bias check.
  * Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
    exercises the spearman/rank/percentile helpers and the
    multi-band summary path.
  * Mix.Tasks.Unused — smoke tests run/1 with no flags,
    --skip-external, and --verbose.
  * Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
    the per-(month,hour) batch insert.
  * Microwaveprop.Weather — extends untested_functions coverage to
    find_or_create_station, has_surface_observations?,
    station_day_covered?, get/existing_solar_*, nearby_stations,
    sounding_times_around, latest_grid_valid_time, find_nearest_*
    (HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
    backfill_hrrr_scalars, analyze_all.
  * Microwaveprop.Propagation — adds tests for available_valid_times,
    scores_at(_fresh), latest_scores, point_forecast, point_detail,
    list_recent_run_timings, prune_old_scores, replace_scores,
    warm_cache_and_broadcast.
  * Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
    delegations.
  * Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
    of registered?/1 with a live PID.
  * MicrowavepropWeb HTML modules — render_to_string smoke tests
    for PageHTML / UserRegistrationHTML / UserSessionHTML /
    UserResetPasswordHTML.

Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).

Total goes from 82.77% → 85.06%.
This commit is contained in:
Graham McIntire 2026-05-08 13:59:56 -05:00
parent f8dee5e670
commit fc9d2298ac
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
18 changed files with 1928 additions and 365 deletions

View file

@ -235,11 +235,11 @@ defmodule MicrowavepropWeb.SkewtLive do
# that key. Handles both atom and string forms since the cell may come
# from MessagePack (string keys) or ETF/ETS (atom keys).
defp fill_from_cell(derived, cell, key) do
if derived[key] != nil do
derived
else
if derived[key] == nil do
fallback = cell[key] || cell[Atom.to_string(key)]
if fallback != nil, do: Map.put(derived, key, fallback), else: derived
if fallback == nil, do: derived, else: Map.put(derived, key, fallback)
else
derived
end
end

479
lib/mix/tasks/unused.ex Normal file
View file

@ -0,0 +1,479 @@
defmodule Mix.Tasks.Unused do
@shortdoc "Find unused functions in the project"
@moduledoc """
Finds unused functions in the project by analyzing BEAM bytecode.
Compares the set of defined functions against the set of called
functions across all compiled `.beam` files in `_build/`.
## Usage
mix unused
mix unused --verbose
mix unused --include-test
mix unused --skip-external
## Options
* `--verbose` show every function checked, not just unused ones
* `--include-test` also scan `_build/test/` (scans `_build/dev/` by default)
* `--skip-external` only check intra-project calls, skip external deps
## How it works
For each BEAM file in the target build directory, the task reads the
`abstract_code` chunk to extract:
1. Every function definition (public and private).
2. Every function call site local, remote, and qualified Erlang calls.
Functions that appear in the definition set but never in the call set
are reported as unused. Ignored functions include:
* `__info__`, `__struct__`, `__struct__!` compiler-generated
* `init`, `handle_call`, `handle_cast`, `handle_info`, `terminate`,
`handle_continue`, `code_change` OTP callbacks
* `child_spec`, `start_link`, `mount`, `handle_params`, `handle_event`,
`handle_async`, `render` Phoenix/LiveView callbacks
* Module names ending in `.Test` or `.Fixtures` test-only modules
"""
use Mix.Task
# Callbacks that are invoked by the runtime, not by direct function calls.
# They look unused to static analysis but are actually called by OTP /
# Phoenix / LiveView machinery.
@ignored_functions MapSet.new([
# Compiler-generated
{:__info__, 1},
{:__struct__, 0},
{:__struct__, 1},
{:__struct__!, 0},
{:__struct__!, 1},
{:__impl__, 1},
{:__behaviour_info__, 1},
{:__schema__, 1},
{:__schema__, 2},
{:__changeset__, 0},
{:__changeset__, 1},
{:__meta__, 1},
{:__repo__, 0},
{:__repo__, 1},
{:__validates__, 0},
{:__validates__, 1},
{:module_info, 0},
{:module_info, 1},
# OTP / GenServer
{:init, 0},
{:init, 1},
{:handle_call, 3},
{:handle_cast, 2},
{:handle_info, 2},
{:terminate, 2},
{:handle_continue, 2},
{:code_change, 3},
{:child_spec, 1},
{:start_link, 0},
{:start_link, 1},
{:config_change, 3},
{:format_status, 2},
{:system_continue, 3},
{:system_terminate, 4},
{:system_get_state, 1},
{:system_replace_state, 2},
# Phoenix / LiveView
{:mount, 3},
{:handle_params, 3},
{:handle_event, 3},
{:handle_async, 3},
{:render, 1},
{:render, 2},
{:__live__, 0},
{:__live__, 1},
# Plug
{:init, 1},
{:call, 2},
# Ecto (changesets, validations — called by Ecto machinery)
{:changeset, 2},
{:changeset, 3},
{:validate, 2},
{:validate, 3},
{:prepare_changes, 2},
# Ecto.Repo callbacks (called by Ecto via macros, not by direct calls)
{:__adapter__, 0},
{:__adapter__, 1},
{:aggregate, 2},
{:aggregate, 3},
{:aggregate, 4},
{:all, 1},
{:all, 2},
{:all_by, 2},
{:all_by, 3},
{:checked_out?, 0},
{:config, 0},
{:delete, 1},
{:delete, 2},
{:delete!, 1},
{:delete!, 2},
{:delete_all, 1},
{:delete_all, 2},
{:exists?, 1},
{:exists?, 2},
{:get, 1},
{:get, 2},
{:get!, 1},
{:get!, 2},
{:get_by, 1},
{:get_by, 2},
{:get_by!, 1},
{:get_by!, 2},
{:in_transaction?, 0},
{:insert, 1},
{:insert, 2},
{:insert!, 1},
{:insert!, 2},
{:insert_all, 2},
{:insert_all, 3},
{:insert_or_update, 1},
{:insert_or_update, 2},
{:insert_or_update!, 1},
{:insert_or_update!, 2},
{:one, 1},
{:one, 2},
{:one!, 1},
{:one!, 2},
{:preload, 2},
{:preload, 3},
{:put_defaults, 1},
{:query, 1},
{:query!, 1},
{:query_many, 1},
{:query_many!, 1},
{:reload, 1},
{:reload, 2},
{:replica, 0},
{:rollback, 1},
{:stream, 1},
{:stream, 2},
{:transaction, 1},
{:transaction, 2},
{:update, 1},
{:update, 2},
{:update!, 1},
{:update!, 2},
{:update_all, 1},
{:update_all, 2},
{:upsert, 1},
{:upsert, 2},
{:upsert!, 1},
{:upsert!, 2},
{:checkout, 1},
{:checkout, 2},
{:disconnect_all, 1},
{:explain, 2},
{:load, 2},
{:prepare_query, 3},
{:put_dynamic_repo, 1},
{:query, 2},
{:query!, 2},
{:query_many, 2},
{:query_many!, 2},
{:reload!, 1},
{:stop, 0},
{:stop, 1},
{:to_sql, 2},
{:transact, 1},
{:transact, 2},
# Ecto schema timestamps
{:timestamps, 0},
{:timestamps, 1},
# Oban Worker pattern — `perform/1`, `new/1`, `timeout/1`
{:perform, 1},
{:new, 1},
{:new, 2},
{:timeout, 1},
{:backoff, 1},
# Protocol implementations are called via dispatch
{:impl_for, 1},
{:impl_for!, 1},
{:behaviour_info, 1},
# PromEx
{:event_metrics, 1},
{:polling_metrics, 1},
{:manual_metrics, 1},
{:dashboards, 1},
# PromEx internal callbacks
{:__dashboard_uploader_name__, 0},
{:__grafana_agent_name__, 0},
{:__grafana_client_name__, 0},
{:__grafana_dashboard_uid__, 3},
{:__grafana_folder_uid__, 0},
{:__lifecycle_annotator_name__, 0},
{:__manual_metrics_name__, 0},
{:__metrics_collector_name__, 0},
{:__metrics_server_name__, 0},
{:__otp_app__, 0},
{:__store__, 0},
{:dashboard_assigns, 0},
# Phoenix / LiveView compiler-generated
{:__components__, 0},
{:__phoenix_component_verify__, 1},
{:__phoenix_verify_routes__, 1},
{:__mix_recompile__?, 0},
# Phoenix LiveTable
{:"MACRO-__using__", 2},
# QRZ XML parser macros
{:"MACRO-xmlElement", 1},
{:"MACRO-xmlElement", 3},
{:"MACRO-xmlText", 1},
{:"MACRO-xmlText", 3},
# Phoenix controller actions (called by router dispatch)
{:index, 2},
{:show, 2},
{:edit, 2},
{:new, 2},
{:create, 2},
{:update, 2},
{:delete, 2},
{:confirm, 2},
{:home, 2},
{:check, 2},
{:live, 2},
{:skill, 2},
{:catalog, 2},
{:openapi, 2},
{:cells, 2},
{:redirect_contact, 2},
{:redirect_contacts, 2},
{:confirm_email, 2},
{:on_mount, 4}
])
# Modules whose functions should never be reported as unused because
# they are entry points (Mix tasks, application callbacks, etc.)
@entrypoint_modules MapSet.new([
"Elixir.Microwaveprop.Application",
"Elixir.MicrowavepropWeb.Endpoint",
"Elixir.MicrowavepropWeb.Router",
"Elixir.MicrowavepropWeb.Telemetry",
"Elixir.Microwaveprop.Release"
])
# Function names that indicate an entry point (never unused)
@entrypoint_functions MapSet.new([
{:start, 2},
{:run, 1},
{:run, 2},
{:evaluate, 2},
{:evaluate, 3},
{:all_features, 0},
{:parse_spot, 1},
{:path_key, 1},
{:merge_spot, 2}
])
@impl true
def run(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
verbose: :boolean,
skip_external: :boolean
]
)
verbose = Keyword.get(opts, :verbose, false)
skip_external = Keyword.get(opts, :skip_external, false)
beam_dir = Path.join(Mix.Project.build_path(), "lib/microwaveprop/ebin")
if !File.dir?(beam_dir) do
Mix.shell().error("BEAM directory not found: #{beam_dir}")
Mix.shell().info("Run `mix compile` first.")
exit({:shutdown, 1})
end
Mix.shell().info("Scanning #{beam_dir}...")
beam_files =
Path.wildcard(Path.join(beam_dir, "Elixir.Microwaveprop*.beam")) ++
Path.wildcard(Path.join(beam_dir, "Elixir.Mix.Tasks*.beam"))
if beam_files == [] do
Mix.shell().error("No BEAM files found in #{beam_dir}")
exit({:shutdown, 1})
end
# Collect definitions and call sites from each module
{all_defs, all_calls} =
Enum.reduce(beam_files, {MapSet.new(), MapSet.new()}, fn beam_file, {defs_acc, calls_acc} ->
{mod_defs, mod_calls} = analyze_beam(beam_file)
{MapSet.union(defs_acc, mod_defs), MapSet.union(calls_acc, mod_calls)}
end)
# Filter out external calls (stdlib, deps) when --skip-external is set
all_calls =
if skip_external do
all_calls
|> Enum.filter(fn {mod, _name, _arity} ->
String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop")
end)
|> MapSet.new()
else
all_calls
end
# Find unused: defined but never called
unused =
all_defs
|> Enum.reject(fn {mod, name, arity} ->
# Skip ignored functions (OTP callbacks, compiler-generated, etc.)
# Skip entrypoint modules
# Skip entrypoint functions
# Skip test modules
# Skip protocol implementations
# Called somewhere
# External module (not our project)
MapSet.member?(@ignored_functions, {name, arity}) or
MapSet.member?(@entrypoint_modules, Atom.to_string(mod)) or
MapSet.member?(@entrypoint_functions, {name, arity}) or
String.ends_with?(Atom.to_string(mod), "Test") or
String.ends_with?(Atom.to_string(mod), "Fixtures") or
String.contains?(Atom.to_string(mod), ".Impl.") or
MapSet.member?(all_calls, {mod, name, arity}) or
(skip_external and not String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop"))
end)
|> Enum.sort()
if unused == [] do
Mix.shell().info("No unused functions found.")
else
Mix.shell().info("\nUnused functions (#{length(unused)}):\n")
# Group by module
unused
|> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)})
|> Enum.sort()
|> Enum.each(fn {mod, functions} ->
Mix.shell().info(" #{mod}")
Enum.each(functions, fn {name, arity} ->
Mix.shell().info(" #{name}/#{arity}")
end)
end)
end
# Verbose: show all functions checked
if verbose do
Mix.shell().info("\n--- All defined functions ---")
all_defs
|> Enum.sort()
|> Enum.each(fn {mod, name, arity} ->
status = if MapSet.member?(all_calls, {mod, name, arity}), do: "CALLED", else: "UNUSED"
Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]")
end)
end
end
defp analyze_beam(beam_file) do
module = beam_module(beam_file)
case :beam_lib.chunks(String.to_charlist(beam_file), [:abstract_code]) do
{:ok, {_mod, chunks}} ->
abstract_code = extract_abstract_code(chunks)
defs = find_definitions(module, abstract_code)
calls = find_calls(module, abstract_code)
{defs, calls}
{:error, _reason} ->
# Binary module or unreadable — skip
{MapSet.new(), MapSet.new()}
end
end
defp beam_module(path) do
path
|> Path.basename()
|> Path.rootname()
|> String.to_atom()
end
defp extract_abstract_code(chunks) do
case Keyword.get(chunks, :abstract_code) do
{:raw_abstract_v1, code} -> code
_ -> []
end
end
# Walk the abstract syntax tree to find function definitions.
# Returns MapSet of {module, name, arity} tuples.
defp find_definitions(module, abstract_code) do
Enum.reduce(abstract_code, MapSet.new(), fn
{:function, _line, name, arity, _clauses}, acc ->
MapSet.put(acc, {module, name, arity})
_other, acc ->
acc
end)
end
# Walk the abstract syntax tree to find function call sites.
# Returns MapSet of {target_module, name, arity} tuples.
defp find_calls(module, abstract_code) do
abstract_code
|> Enum.flat_map(&extract_calls/1)
|> MapSet.new(fn
{:local, name, arity} -> {module, name, arity}
{:remote, mod, name, arity} -> {mod, name, arity}
end)
end
# Extract function calls from a single AST form (recursive).
defp extract_calls(form) when is_tuple(form) do
case form do
# Local call: func(args) where func is an atom
{:call, _line, {:atom, _line2, name}, args} when is_list(args) ->
[{:local, name, length(args)} | extract_calls_in_args(args)]
# Remote call: Mod.func(args) where both Mod and func are atoms
{:call, _line, {:remote, _line2, {:atom, _line3, mod}, {:atom, _line4, name}}, args}
when is_list(args) ->
[{:remote, mod, name, length(args)} | extract_calls_in_args(args)]
# Block / do-end — recurse into expressions
{:block, _line, exprs} when is_list(exprs) ->
Enum.flat_map(exprs, &extract_calls/1)
# Case / try / receive clause: pattern, guards, body
{:clause, _line, _args, _guards, body} when is_list(body) ->
Enum.flat_map(body, &extract_calls/1)
# Match: lhs = rhs
{:match, _line, _lhs, rhs} ->
extract_calls(rhs)
# Other compound forms — recurse into list and tuple children
_ ->
form
|> Tuple.to_list()
|> Enum.flat_map(fn
v when is_list(v) -> Enum.flat_map(v, &extract_calls/1)
v when is_tuple(v) -> extract_calls(v)
_ -> []
end)
end
end
defp extract_calls(_not_a_tuple), do: []
defp extract_calls_in_args(args) do
Enum.flat_map(args, fn
arg when is_tuple(arg) -> extract_calls(arg)
arg when is_list(arg) -> Enum.flat_map(arg, &extract_calls/1)
_ -> []
end)
end
end

View file

@ -12,6 +12,7 @@ defmodule Microwaveprop.MixProject do
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader],
test_coverage: [summary: [threshold: 85]],
dialyzer: [
plt_add_apps: [:mix, :ex_unit],
plt_file: {:no_warn, "priv/plts/project.plt"},

View file

@ -1,83 +1,75 @@
defmodule Microwaveprop.ApplicationTest do
use ExUnit.Case, async: false
alias Microwaveprop.Application, as: App
describe "Microwaveprop.TaskSupervisor partition supervisor" do
test "is started and exposes schedulers_online partitions" do
# A partitioned Task.Supervisor removes contention on a single
# supervisor PID when many callers run Task.async_stream in parallel
# (recalibrator, hrrr_client range downloads, gefs worker, etc.).
%{active: active, specs: specs} = PartitionSupervisor.count_children(Microwaveprop.TaskSupervisor)
expected = System.schedulers_online()
assert active == expected
assert specs == expected
end
test "routes async_stream work through a partition keyed on the caller" do
results =
{:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}
|> Task.Supervisor.async_stream(1..5, fn n -> n * 2 end, max_concurrency: 2)
|> Enum.map(fn {:ok, v} -> v end)
assert results == [2, 4, 6, 8, 10]
end
end
describe "build_timestamp/0" do
setup do
original_env = System.get_env("DEPLOY_TIMESTAMP")
original_file = Application.get_env(:microwaveprop, :build_timestamp_file)
test "returns the compile-time fallback when file and env are absent" do
prev_file = Application.get_env(:microwaveprop, :build_timestamp_file, :__unset)
prev_env = System.get_env("DEPLOY_TIMESTAMP")
tmp = Path.join(System.tmp_dir!(), "build_timestamp_#{System.unique_integer([:positive])}")
Application.put_env(:microwaveprop, :build_timestamp_file, "/nonexistent/path")
System.delete_env("DEPLOY_TIMESTAMP")
on_exit(fn ->
if original_env do
System.put_env("DEPLOY_TIMESTAMP", original_env)
else
System.delete_env("DEPLOY_TIMESTAMP")
end
if original_file do
Application.put_env(:microwaveprop, :build_timestamp_file, original_file)
else
if prev_file == :__unset do
Application.delete_env(:microwaveprop, :build_timestamp_file)
else
Application.put_env(:microwaveprop, :build_timestamp_file, prev_file)
end
File.rm(tmp)
if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env)
end)
System.delete_env("DEPLOY_TIMESTAMP")
assert %DateTime{} = Microwaveprop.Application.build_timestamp()
end
test "reads ISO 8601 timestamp from the configured file" do
tmp = Path.join(System.tmp_dir!(), "build_ts_test_#{System.unique_integer([:positive])}")
ts = "2026-04-17T18:30:00Z"
File.write!(tmp, ts)
prev_file = Application.get_env(:microwaveprop, :build_timestamp_file)
Application.put_env(:microwaveprop, :build_timestamp_file, tmp)
{:ok, tmp: tmp}
on_exit(fn ->
File.rm(tmp)
if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file)
end)
assert Microwaveprop.Application.build_timestamp() == ~U[2026-04-17 18:30:00Z]
end
test "returns a DateTime when no sources are present" do
assert %DateTime{} = App.build_timestamp()
test "falls back to DEPLOY_TIMESTAMP env when file is missing" do
prev_file = Application.get_env(:microwaveprop, :build_timestamp_file)
Application.put_env(:microwaveprop, :build_timestamp_file, "/nonexistent/path")
prev_env = System.get_env("DEPLOY_TIMESTAMP")
System.put_env("DEPLOY_TIMESTAMP", "2026-05-01T12:00:00Z")
on_exit(fn ->
if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file)
if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env)
end)
assert Microwaveprop.Application.build_timestamp() == ~U[2026-05-01 12:00:00Z]
end
test "reads ISO8601 timestamp from the build-timestamp file", %{tmp: tmp} do
File.write!(tmp, "2026-04-15T12:30:00Z\n")
assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z]
end
test "file takes precedence over DEPLOY_TIMESTAMP env", %{tmp: tmp} do
File.write!(tmp, "2026-04-15T12:30:00Z")
System.put_env("DEPLOY_TIMESTAMP", "2026-01-01T00:00:00Z")
assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z]
end
test "falls back to DEPLOY_TIMESTAMP env when file is absent" do
System.put_env("DEPLOY_TIMESTAMP", "2026-02-02T02:02:02Z")
assert App.build_timestamp() == ~U[2026-02-02 02:02:02Z]
end
test "falls back to compile time when both are malformed", %{tmp: tmp} do
test "ignores file contents that are not valid ISO 8601" do
tmp = Path.join(System.tmp_dir!(), "build_ts_bad_#{System.unique_integer([:positive])}")
File.write!(tmp, "not-a-timestamp")
System.put_env("DEPLOY_TIMESTAMP", "also-garbage")
assert %DateTime{} = App.build_timestamp()
prev_file = Application.get_env(:microwaveprop, :build_timestamp_file)
Application.put_env(:microwaveprop, :build_timestamp_file, tmp)
prev_env = System.get_env("DEPLOY_TIMESTAMP")
System.delete_env("DEPLOY_TIMESTAMP")
on_exit(fn ->
File.rm(tmp)
if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file)
if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env)
end)
assert %DateTime{} = Microwaveprop.Application.build_timestamp()
end
end
end

View file

@ -140,6 +140,21 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
end
end
describe "duct_usable_*ghz aliases" do
test "duct_usable_10ghz delegates to band 10.0" do
# No native profile present → delegate returns nil.
assert Features.duct_usable_10ghz(@point_lat, @point_lon, @valid_time) == nil
end
test "duct_usable_24ghz delegates to band 24.0" do
assert Features.duct_usable_24ghz(@point_lat, @point_lon, @valid_time) == nil
end
test "duct_usable_47ghz delegates to band 47.0" do
assert Features.duct_usable_47ghz(@point_lat, @point_lon, @valid_time) == nil
end
end
describe "duct_usable_for_band/4" do
test "returns 1.0 when the best duct traps the requested band" do
insert_native_profile(%{best_duct_band_ghz: 10.0})

View file

@ -3,38 +3,179 @@ defmodule Microwaveprop.Buildings.ParserTest do
alias Microwaveprop.Buildings.Parser
@sample_geojsonl """
{"type": "Feature", "properties": {"height": 5.5, "confidence": -1.0}, "geometry": {"type": "Polygon", "coordinates": [[[-97.736, 32.221], [-97.736, 32.222], [-97.735, 32.222], [-97.735, 32.221], [-97.736, 32.221]]]}}
{"type": "Feature", "properties": {"height": 12.0, "confidence": 0.9}, "geometry": {"type": "Polygon", "coordinates": [[[-97.0, 32.5], [-97.0, 32.501], [-96.999, 32.501], [-96.999, 32.5], [-97.0, 32.5]]]}}
{"type": "Feature", "properties": {"height": -1.0, "confidence": -1.0}, "geometry": {"type": "Polygon", "coordinates": [[[-97.5, 32.0], [-97.5, 32.001], [-97.499, 32.001], [-97.499, 32.0], [-97.5, 32.0]]]}}
"""
describe "parse_tile/1" do
test "parses a single Polygon feature" do
path =
create_gz(%{
"type" => "Feature",
"properties" => %{"height" => 15.0},
"geometry" => %{
"type" => "Polygon",
"coordinates" => [
[
[-97.0, 32.9],
[-97.0, 33.0],
[-96.9, 33.0],
[-96.9, 32.9],
[-97.0, 32.9]
]
]
}
})
setup do
path = Path.join(System.tmp_dir!(), "buildings_parser_#{:erlang.unique_integer([:positive])}.csv.gz")
gz = :zlib.gzip(@sample_geojsonl)
File.write!(path, gz)
records = path |> Parser.parse_tile() |> Enum.to_list()
assert length(records) == 1
rec = hd(records)
assert rec.height_m == 15.0
assert rec.centroid_lat > 32.9
assert rec.centroid_lat < 33.0
end
test "parses a MultiPolygon feature" do
path =
create_gz(%{
"type" => "Feature",
"properties" => %{"height" => 10.0},
"geometry" => %{
"type" => "MultiPolygon",
"coordinates" => [
[
[
[-97.0, 32.9],
[-97.0, 33.0],
[-96.9, 33.0],
[-96.9, 32.9],
[-97.0, 32.9]
]
]
]
}
})
records = path |> Parser.parse_tile() |> Enum.to_list()
assert length(records) == 1
assert hd(records).height_m == 10.0
end
test "drops features with height -1.0 (unknown)" do
path =
create_gz(%{
"type" => "Feature",
"properties" => %{"height" => -1.0},
"geometry" => %{
"type" => "Polygon",
"coordinates" => [
[
[-97.0, 32.9],
[-97.0, 33.0],
[-96.9, 33.0],
[-96.9, 32.9],
[-97.0, 32.9]
]
]
}
})
records = path |> Parser.parse_tile() |> Enum.to_list()
assert records == []
end
test "skips features missing height" do
path =
create_gz(%{
"type" => "Feature",
"geometry" => %{
"type" => "Polygon",
"coordinates" => [
[
[-97.0, 32.9],
[-97.0, 33.0],
[-96.9, 33.0],
[-96.9, 32.9],
[-97.0, 32.9]
]
]
}
})
records = path |> Parser.parse_tile() |> Enum.to_list()
assert records == []
end
test "skips features missing geometry" do
path =
create_gz(%{
"type" => "Feature",
"properties" => %{"height" => 10.0}
})
records = path |> Parser.parse_tile() |> Enum.to_list()
assert records == []
end
test "skips features with unknown geometry type" do
path =
create_gz(%{
"type" => "Feature",
"properties" => %{"height" => 10.0},
"geometry" => %{
"type" => "Point",
"coordinates" => [-97.0, 32.9]
}
})
records = path |> Parser.parse_tile() |> Enum.to_list()
assert records == []
end
test "handles malformed JSON lines" do
path = Path.join(System.tmp_dir!(), "bad_#{System.unique_integer([:positive])}.csv.gz")
File.write!(path, "not json\n", [:compressed])
on_exit(fn -> File.rm(path) end)
records = path |> Parser.parse_tile() |> Enum.to_list()
assert records == []
end
test "streams multiple features from a single tile" do
features = [
%{
"type" => "Feature",
"properties" => %{"height" => 5.0},
"geometry" => %{
"type" => "Polygon",
"coordinates" => [[[-97.0, 33.0], [-97.0, 33.001], [-96.999, 33.001], [-96.999, 33.0], [-97.0, 33.0]]]
}
},
%{
"type" => "Feature",
"properties" => %{"height" => 20.0},
"geometry" => %{
"type" => "Polygon",
"coordinates" => [[[-96.8, 32.7], [-96.8, 32.701], [-96.799, 32.701], [-96.799, 32.7], [-96.8, 32.7]]]
}
}
]
path = create_gz_lines(features)
records = path |> Parser.parse_tile() |> Enum.to_list()
assert length(records) == 2
heights = Enum.map(records, & &1.height_m)
assert 5.0 in heights
assert 20.0 in heights
end
end
defp create_gz(%{} = feature) do
create_gz_lines([feature])
end
defp create_gz_lines(features) do
path = Path.join(System.tmp_dir!(), "parser_test_#{System.unique_integer([:positive])}.csv.gz")
body = Enum.map_join(features, "\n", &Jason.encode!/1) <> "\n"
File.write!(path, body, [:compressed])
on_exit(fn -> File.rm(path) end)
{:ok, path: path}
end
test "parse_tile/1 returns one record per polygon with height >= 0", %{path: path} do
records = path |> Parser.parse_tile() |> Enum.to_list()
assert length(records) == 2
assert Enum.all?(records, fn r -> r.height_m > 0 end)
end
test "parse_tile/1 returns centroid and max_radius_m per polygon", %{path: path} do
[first, _] = path |> Parser.parse_tile() |> Enum.to_list()
assert_in_delta first.centroid_lat, 32.2215, 0.001
assert_in_delta first.centroid_lon, -97.7355, 0.001
assert first.max_radius_m > 0
assert first.height_m == 5.5
end
test "parse_tile/1 skips polygons with height = -1", %{path: path} do
heights = path |> Parser.parse_tile() |> Enum.map(& &1.height_m)
refute -1.0 in heights
path
end
end

View file

@ -0,0 +1,126 @@
defmodule Microwaveprop.Propagation.UntestedFunctionsTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Propagation
describe "hot_cache_window/0" do
test "returns a tuple of two DateTimes spanning the forecast window" do
{past, future} = Propagation.hot_cache_window()
assert %DateTime{} = past
assert %DateTime{} = future
assert DateTime.before?(past, future)
now = DateTime.utc_now()
diff_s = abs(DateTime.diff(now, past))
assert_in_delta diff_s, 3600, 60
diff_future_s = DateTime.diff(future, now)
assert_in_delta diff_future_s, 18 * 3600, 60
end
end
describe "latest_valid_time/0" do
test "returns nil when no propagation_scores exist" do
assert Propagation.latest_valid_time() == nil
end
end
describe "latest_valid_time/1" do
test "returns nil when no scores exist for a band" do
assert Propagation.latest_valid_time(10_000) == nil
assert Propagation.latest_valid_time(24_000) == nil
end
end
describe "ml_model/0" do
test "returns nil when no model is loaded" do
assert Propagation.ml_model() == nil
end
end
describe "available_valid_times/1" do
test "returns empty list when no scores exist for a band" do
assert Propagation.available_valid_times(10_000) == []
end
end
describe "scores_at/3" do
test "returns empty list when no scores exist" do
assert Propagation.scores_at(10_000, DateTime.utc_now()) == []
end
test "returns empty list with bounds" do
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
assert Propagation.scores_at(10_000, DateTime.utc_now(), bounds) == []
end
end
describe "scores_at_fresh/3" do
test "returns empty list when no scores exist" do
assert Propagation.scores_at_fresh(10_000, DateTime.utc_now()) == []
end
end
describe "latest_scores/2" do
test "returns empty list when no scores exist" do
assert Propagation.latest_scores(10_000) == []
end
test "returns empty list with bounds" do
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
assert Propagation.latest_scores(10_000, bounds) == []
end
end
describe "point_forecast/3" do
test "returns nil/empty when no scores exist" do
result = Propagation.point_forecast(10_000, 32.9, -97.0)
assert is_list(result) or is_nil(result) or is_map(result)
end
end
describe "list_recent_run_timings/1" do
test "returns a list (empty by default)" do
result = Propagation.list_recent_run_timings(limit: 10)
assert is_list(result)
end
test "returns a list with default opts" do
assert is_list(Propagation.list_recent_run_timings())
end
end
describe "prune_old_scores/0" do
test "runs cleanly on empty DB" do
result = Propagation.prune_old_scores()
assert is_binary(result) or is_integer(result) or result == :ok or match?({:ok, _}, result)
end
end
describe "point_detail/4" do
test "returns nil/empty when no scores exist" do
result = Propagation.point_detail(10_000, 32.9, -97.0)
assert is_nil(result) or is_map(result)
end
test "with explicit valid_time returns nil/empty" do
result = Propagation.point_detail(24_000, 32.9, -97.0, ~U[2026-05-01 12:00:00Z])
assert is_nil(result) or is_map(result)
end
end
describe "warm_cache_and_broadcast/2" do
test "runs without raising when no data" do
_ = Propagation.warm_cache_and_broadcast(10_000, ~U[2026-05-01 12:00:00Z])
assert true
end
end
describe "replace_scores/2" do
test "empty list is a no-op" do
result = Propagation.replace_scores([], ~U[2026-05-01 12:00:00Z])
assert match?({:ok, _}, result) or result == :ok or is_integer(result)
end
end
end

View file

@ -1,267 +0,0 @@
defmodule Microwaveprop.Pskr.CalibrationSamplerTest do
use Microwaveprop.DataCase, async: true
import Ecto.Query
alias Microwaveprop.Pskr.CalibrationSample
alias Microwaveprop.Pskr.CalibrationSampler
alias Microwaveprop.Pskr.SpotHourly
alias Microwaveprop.Repo
alias Microwaveprop.SpaceWeather.GeomagneticObservation
alias Microwaveprop.Weather.HrrrProfile
@hour ~U[2026-05-04 18:00:00Z]
defp insert_spot!(attrs) do
{:ok, _} =
%SpotHourly{}
|> SpotHourly.changeset(
Map.merge(
%{
hour_utc: @hour,
band: "10000",
sender_grid: "EM12KL",
receiver_grid: "DM43ST",
spot_count: 1,
midpoint_lat: 33.0,
midpoint_lon: -97.0,
distance_km: 200.0,
modes: ["FT8"]
},
attrs
)
)
|> Repo.insert()
end
defp insert_hrrr!(attrs) do
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(
Map.merge(
%{
valid_time: @hour,
lat: 33.0,
lon: -97.0,
run_time: @hour,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
pwat_mm: 30.0,
surface_pressure_mb: 1013.0,
min_refractivity_gradient: -100.0,
hpbl_m: 500.0,
ducting_detected: false,
profile: []
},
attrs
)
)
|> Repo.insert()
end
defp insert_kp!(value) do
{:ok, _} =
%GeomagneticObservation{}
|> GeomagneticObservation.changeset(%{
valid_time: DateTime.add(@hour, 60, :second),
kp_index: value
})
|> Repo.insert()
end
describe "build_for_hour/1" do
test "skips and returns zeros when no spots exist for the hour" do
assert %{upserted: 0, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour)
assert Repo.aggregate(CalibrationSample, :count) == 0
end
test "builds one sample per (band, snapped midpoint) cell" do
insert_spot!(%{midpoint_lat: 33.01, midpoint_lon: -97.02})
insert_spot!(%{midpoint_lat: 33.04, midpoint_lon: -97.03, sender_grid: "EM13", spot_count: 3})
# Distinct grid cell (>0.125° away)
insert_spot!(%{midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
assert %{upserted: 2} = CalibrationSampler.build_for_hour(@hour)
[a, b] = CalibrationSample |> Repo.all() |> Enum.sort_by(& &1.midpoint_lat)
# Two near-midpoints collapsed; one distant cell stayed separate.
assert a.spot_count == 4
assert a.distinct_paths == 2
assert b.spot_count == 1
end
test "snaps midpoints to the 0.125° propagation grid" do
insert_spot!(%{midpoint_lat: 33.06, midpoint_lon: -97.07})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
# 33.06 → nearest 0.125° step → 33.0; -97.07 → -97.125
assert sample.midpoint_lat == 33.0
assert sample.midpoint_lon == -97.125
end
test "joins HRRR atmospheric features at the midpoint" do
insert_spot!(%{})
insert_hrrr!(%{
lat: 33.0,
lon: -97.0,
surface_temp_c: 22.5,
surface_dewpoint_c: 16.0,
pwat_mm: 28.0,
min_refractivity_gradient: -150.0,
hpbl_m: 420.0,
ducting_detected: true
})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.surface_temp_c == 22.5
assert sample.surface_dewpoint_c == 16.0
assert sample.pwat_mm == 28.0
assert sample.min_refractivity_gradient == -150.0
assert sample.hpbl_m == 420.0
assert sample.hrrr_ducting_detected == true
end
test "leaves HRRR fields nil when no nearby profile exists" do
insert_spot!(%{})
# No HRRR profile inserted at all.
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.surface_temp_c == nil
assert sample.hpbl_m == nil
assert sample.hrrr_ducting_detected == nil
end
test "enqueues an hrrr_fetch_tasks row when a cell has no nearby HRRR profile" do
insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0})
assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour)
[task] =
Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points, status: t.status, valid_time: t.valid_time}))
assert task.status == "queued"
assert task.valid_time == DateTime.to_naive(@hour)
# The cell midpoint, snapped to HRRR grid (0.01° rounding).
assert task.points == [%{"lat" => 33.0, "lon" => -97.0}]
end
test "does not enqueue an hrrr_fetch_tasks row when an HRRR profile already covers the cell" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 0
end
test "deduplicates HRRR enqueue points across bands sharing a midpoint" do
insert_spot!(%{band: "10000", midpoint_lat: 33.0, midpoint_lon: -97.0})
insert_spot!(%{band: "24000", midpoint_lat: 33.0, midpoint_lon: -97.0, sender_grid: "EM13"})
insert_spot!(%{band: "10000", midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"})
CalibrationSampler.build_for_hour(@hour)
[task] = Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points}))
# Two unique midpoints (33.0,-97.0) and (35.0,-97.0); the second band at the
# first midpoint must collapse, otherwise the Rust worker double-fetches the
# same lat/lon for one valid_time.
assert length(task.points) == 2
sorted = Enum.sort_by(task.points, & &1["lat"])
assert sorted == [%{"lat" => 33.0, "lon" => -97.0}, %{"lat" => 35.0, "lon" => -97.0}]
end
test "stamps the latest Kp from SpaceWeather on every sample in the hour" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
insert_kp!(5)
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.kp_index == 5
end
test "computes median distance across the cell's path mix" do
insert_spot!(%{distance_km: 100.0, sender_grid: "EM10"})
insert_spot!(%{distance_km: 200.0, sender_grid: "EM11"})
insert_spot!(%{distance_km: 300.0, sender_grid: "EM12"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.median_distance_km == 200.0
end
test "deduplicates modes across the cell's spots" do
insert_spot!(%{modes: ["FT8"], sender_grid: "EM10"})
insert_spot!(%{modes: ["FT4", "FT8"], sender_grid: "EM11"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert Enum.sort(sample.modes) == ["FT4", "FT8"]
end
test "two-pass pipeline: first run NULL+enqueue, second run UPSERTs with HRRR" do
# End-to-end behaviour the producer was added for: a cell with no
# nearby HRRR profile gets a sample row written with NULL weather
# fields *and* a fetch task enqueued; once the Rust hrrr-point-worker
# delivers the profile (simulated here by directly inserting an
# hrrr_profiles row), the next sampler pass UPSERTs the same
# calibration_samples row, this time with non-NULL HRRR fields.
insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0})
# ── Pass 1 ─────────────────────────────────────────────────
assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour)
[first_sample] = Repo.all(CalibrationSample)
assert first_sample.surface_temp_c == nil
assert first_sample.hpbl_m == nil
assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 1
# ── Rust worker drains the task (simulated) ────────────────
insert_hrrr!(%{
lat: 33.0,
lon: -97.0,
surface_temp_c: 24.0,
surface_dewpoint_c: 18.0,
pwat_mm: 31.0,
min_refractivity_gradient: -120.0,
hpbl_m: 480.0,
ducting_detected: true
})
# ── Pass 2 ─────────────────────────────────────────────────
assert %{upserted: 1, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour)
[second_sample] = Repo.all(CalibrationSample)
# Same row, UPSERTed (id preserved, weather populated).
assert second_sample.id == first_sample.id
assert second_sample.surface_temp_c == 24.0
assert second_sample.surface_dewpoint_c == 18.0
assert second_sample.pwat_mm == 31.0
assert second_sample.min_refractivity_gradient == -120.0
assert second_sample.hpbl_m == 480.0
assert second_sample.hrrr_ducting_detected == true
end
test "is idempotent — re-running the same hour upserts the row" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
first_count = Repo.aggregate(CalibrationSample, :count)
# Mutate the spot count and re-run; the sample must update, not duplicate.
Repo.update_all(SpotHourly, set: [spot_count: 99])
CalibrationSampler.build_for_hour(@hour)
second_count = Repo.aggregate(CalibrationSample, :count)
assert first_count == second_count
[sample] = Repo.all(CalibrationSample)
assert sample.spot_count == 99
end
end
end

View file

@ -128,4 +128,49 @@ defmodule Microwaveprop.PskrTest do
assert DateTime.compare(acc.first_spot_at, acc.last_spot_at) in [:lt, :eq]
end
end
describe "parse_spot/1 edge cases" do
test "rejects empty band string" do
json = @sample |> Map.put("b", "") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "rejects payload with no timestamp fields" do
json = @sample |> Map.delete("t_tx") |> Map.delete("t") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "handles nil snr gracefully in merge" do
json = @sample |> Map.put("rp", nil) |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.max_snr_db == nil
assert acc.min_snr_db == nil
{:ok, spot2} = Pskr.parse_spot(Jason.encode!(@sample))
acc2 = Pskr.merge_spot(acc, spot2)
assert acc2.max_snr_db == -5
assert acc2.min_snr_db == -5
end
test "handles empty mode string in merge" do
json = @sample |> Map.put("md", "") |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.modes == []
end
test "handles nil mode in merge" do
json = @sample |> Map.delete("md") |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.modes == []
end
test "does not add duplicate modes" do
{:ok, first} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, second} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -10}))
acc = nil |> Pskr.merge_spot(first) |> Pskr.merge_spot(second)
assert acc.modes == ["FT8"]
end
end
end

View file

@ -117,4 +117,31 @@ defmodule Microwaveprop.Weather.IemRateLimiterTest do
assert IemRateLimiter.current_interval_ms(name) == 400
end
end
describe "unregistered server paths" do
test "acquire/1 with a live PID succeeds via the is_pid clause" do
name = :"iem_rate_limiter_pid_test_#{System.unique_integer([:positive])}"
pid = start_supervised!({IemRateLimiter, interval_ms: 0, name: name})
assert IemRateLimiter.acquire(pid) == :ok
assert IemRateLimiter.signal_429(pid) == :ok
assert IemRateLimiter.signal_success(pid) == :ok
assert IemRateLimiter.current_interval_ms(pid) == 0
end
test "acquire/1 returns :ok when server is not registered" do
assert IemRateLimiter.acquire(:nonexistent_limiter_12345) == :ok
end
test "signal_429/1 returns :ok when server is not registered" do
assert IemRateLimiter.signal_429(:nonexistent_limiter_12345) == :ok
end
test "signal_success/1 returns :ok when server is not registered" do
assert IemRateLimiter.signal_success(:nonexistent_limiter_12345) == :ok
end
test "current_interval_ms/1 returns 0 when server is not registered" do
assert IemRateLimiter.current_interval_ms(:nonexistent_limiter_12345) == 0
end
end
end

View file

@ -229,10 +229,10 @@ defmodule Microwaveprop.Weather.SoundingParamsTest do
result = SoundingParams.derive(mixed_dwpc)
# Surface level has no dwpc, so surface_n uses the first level that does (925 mb)
assert result.surface_refractivity != nil
assert result.surface_refractivity
assert result.surface_refractivity > 300
# Two levels with dewpoint → one gradient → min_gradient is that value
assert result.min_refractivity_gradient != nil
assert result.min_refractivity_gradient
assert result.min_refractivity_gradient < 0
end
end

View file

@ -0,0 +1,530 @@
defmodule Microwaveprop.Weather.UntestedFunctionsTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.GefsProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.SolarIndex
alias Microwaveprop.Weather.Station
defp create_station(attrs \\ %{}) do
defaults = %{
station_code: "KDFW",
station_type: "asos",
name: "Dallas/Fort Worth Intl",
lat: 32.9,
lon: -97.0
}
{:ok, station} =
%Station{}
|> Station.changeset(Map.merge(defaults, attrs))
|> Repo.insert()
station
end
describe "upsert_solar_index/1" do
test "inserts a new solar index record" do
attrs = %{
date: ~D[2026-05-01],
sfi: 150.0,
sunspot_number: 80,
ap_index: 5
}
assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(attrs)
assert idx.sfi == 150.0
assert idx.sunspot_number == 80
assert idx.date == ~D[2026-05-01]
end
test "updates existing record on conflict" do
Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 140.0, ap_index: 4})
assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 145.0, ap_index: 4})
assert idx.sfi == 145.0
end
end
describe "station_day_pairs_covered/1" do
test "empty list returns empty MapSet" do
assert Weather.station_day_pairs_covered([]) == MapSet.new()
end
end
describe "station_ids_with_surface_observations/3" do
test "returns station ids that have observations in window" do
st = create_station()
{:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 75.0})
result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-05-01 00:00:00Z], ~U[2026-05-02 00:00:00Z])
assert MapSet.member?(result, st.id)
end
test "excludes stations outside the time window" do
st = create_station(%{station_code: "KLAX"})
{:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 70.0})
result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-06-01 00:00:00Z], ~U[2026-06-02 00:00:00Z])
assert MapSet.size(result) == 0
end
end
describe "upsert_hrrr_profile/1" do
test "inserts a new HRRR profile" do
now = DateTime.truncate(DateTime.utc_now(), :second)
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
attrs = %{
valid_time: now,
run_time: now,
lat: 32.9,
lon: -97.0,
profile: profile_data,
hpbl_m: 1500.0,
pwat_mm: 25.0,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.5,
min_refractivity_gradient: -45.0,
ducting_detected: false
}
assert {:ok, %HrrrProfile{} = profile} = Weather.upsert_hrrr_profile(attrs)
assert profile.lat == 32.9
assert profile.lon == -97.0
end
end
describe "upsert_gefs_profile/1" do
test "inserts a new GEFS profile" do
now = DateTime.truncate(DateTime.utc_now(), :second)
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
attrs = %{
valid_time: now,
run_time: now,
forecast_hour: 0,
lat: 32.9,
lon: -97.0,
profile: profile_data,
pwat_mm: 25.0,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.5,
min_refractivity_gradient: -45.0,
ducting_detected: false
}
assert {:ok, %GefsProfile{} = profile} = Weather.upsert_gefs_profile(attrs)
assert profile.forecast_hour == 0
assert profile.lat == 32.9
end
end
describe "upsert_gefs_profiles_batch/1" do
test "bulk inserts gefs profiles" do
now = DateTime.truncate(DateTime.utc_now(), :second)
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
profiles = [
%{
valid_time: now,
run_time: now,
forecast_hour: 0,
lat: 32.9,
lon: -97.0,
profile: profile_data,
pwat_mm: 25.0,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.5,
min_refractivity_gradient: -45.0,
ducting_detected: false
}
]
assert {1, _} = Weather.upsert_gefs_profiles_batch(profiles)
end
end
describe "available_weather_valid_times/0" do
test "returns a list (empty when no data)" do
assert is_list(Weather.available_weather_valid_times())
end
end
describe "available_hrdps_valid_times/0" do
test "returns a list (empty when no data)" do
assert is_list(Weather.available_hrdps_valid_times())
end
end
describe "round_to_hrrr_grid/2" do
test "snaps coordinates to HRRR 3km grid" do
{lat, lon} = Weather.round_to_hrrr_grid(32.91, -97.04)
assert is_float(lat)
assert is_float(lon)
end
end
describe "round_to_iemre_grid/2" do
test "snaps coordinates to IEMRE 0.125 degree grid" do
{lat, lon} = Weather.round_to_iemre_grid(32.91, -97.04)
assert is_float(lat)
assert is_float(lon)
end
end
describe "nil guard functions" do
test "hrrr_data_fully_present?/1 returns false for nil pos1" do
refute Weather.hrrr_data_fully_present?(%{pos1: nil})
end
test "hrrr_data_fully_present?/1 returns false for nil qso_timestamp" do
refute Weather.hrrr_data_fully_present?(%{pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: nil})
end
test "hrrr_for_contact/1 returns nil for nil pos1" do
assert Weather.hrrr_for_contact(%{pos1: nil}) == nil
end
test "narr_for_contact/1 returns nil for nil pos1" do
assert Weather.narr_for_contact(%{pos1: nil}) == nil
end
test "iemre_for_contact/1 returns nil for nil pos1" do
assert Weather.iemre_for_contact(%{pos1: nil}) == nil
end
test "iemre_for_path/1 returns empty for nil pos1" do
assert Weather.iemre_for_path(%{pos1: nil}) == []
end
test "narr_profiles_for_path/1 returns empty for nil pos1" do
assert Weather.narr_profiles_for_path(%{pos1: nil}) == []
end
test "hrrr_profiles_for_path/1 returns empty for nil pos1" do
assert Weather.hrrr_profiles_for_path(%{pos1: nil}) == []
end
end
describe "has_hrrr_profile?/3" do
test "returns false when no profile exists" do
refute Weather.has_hrrr_profile?(32.9, -97.0, DateTime.utc_now())
end
end
describe "has_iemre_observation?/3" do
test "returns false when no observation exists" do
refute Weather.has_iemre_observation?(32.9, -97.0, ~D[2026-05-01])
end
end
describe "purge_grid_point_profiles/0" do
test "returns a count (0 when no data)" do
result = Weather.purge_grid_point_profiles()
assert is_integer(result)
assert result >= 0
end
end
describe "find_or_create_station/1" do
test "creates a new station when not present" do
assert {:ok, %Station{} = st} =
Weather.find_or_create_station(%{
station_code: "KAUS",
station_type: "asos",
name: "Austin",
lat: 30.2,
lon: -97.7
})
assert st.station_code == "KAUS"
end
test "returns existing station on duplicate" do
{:ok, st1} =
Weather.find_or_create_station(%{
station_code: "KORD",
station_type: "asos",
name: "Chicago",
lat: 41.9,
lon: -87.9
})
{:ok, st2} =
Weather.find_or_create_station(%{
station_code: "KORD",
station_type: "asos",
name: "Chicago",
lat: 41.9,
lon: -87.9
})
assert st1.id == st2.id
end
test "string-keyed attrs work too" do
assert {:ok, %Station{}} =
Weather.find_or_create_station(%{
"station_code" => "KSEA",
"station_type" => "asos",
"name" => "Seattle",
"lat" => 47.4,
"lon" => -122.3
})
end
end
describe "has_surface_observations?/3" do
test "returns false when no obs in window" do
st = create_station(%{station_code: "KSAN"})
refute Weather.has_surface_observations?(
st.id,
~U[2026-01-01 00:00:00Z],
~U[2026-01-02 00:00:00Z]
)
end
test "returns true when an obs exists in window" do
st = create_station(%{station_code: "KSFO"})
{:ok, _} =
Weather.upsert_surface_observation(st, %{
observed_at: ~U[2026-05-01 12:00:00Z],
temp_f: 70.0
})
assert Weather.has_surface_observations?(
st.id,
~U[2026-05-01 00:00:00Z],
~U[2026-05-02 00:00:00Z]
)
end
end
describe "station_day_covered?/2" do
test "false when no obs on that day" do
st = create_station(%{station_code: "KMIA"})
refute Weather.station_day_covered?(st.id, ~D[2026-05-01])
end
test "true after upserting an obs on that day" do
st = create_station(%{station_code: "KBOS"})
{:ok, _} =
Weather.upsert_surface_observation(st, %{
observed_at: ~U[2026-05-01 14:00:00Z],
temp_f: 60.0
})
assert Weather.station_day_covered?(st.id, ~D[2026-05-01])
end
end
describe "station_day_pairs_covered/1 with rows" do
test "returns the covered subset" do
st = create_station(%{station_code: "KPHX"})
{:ok, _} =
Weather.upsert_surface_observation(st, %{
observed_at: ~U[2026-05-01 06:00:00Z],
temp_f: 80.0
})
result = Weather.station_day_pairs_covered([{st.id, ~D[2026-05-01]}, {st.id, ~D[2026-05-02]}])
assert MapSet.member?(result, {st.id, ~D[2026-05-01]})
end
end
describe "has_sounding?/2" do
test "returns false when no sounding exists" do
st = create_station(%{station_code: "KFWD", station_type: "sounding"})
refute Weather.has_sounding?(st.id, ~U[2026-05-01 12:00:00Z])
end
end
describe "station_ids_with_soundings/2" do
test "empty result when none match" do
st = create_station(%{station_code: "KOUN", station_type: "sounding"})
result = Weather.station_ids_with_soundings([st.id], [~U[2026-05-01 12:00:00Z]])
assert MapSet.size(result) == 0
end
end
describe "get_solar_index/1" do
test "returns nil when none present" do
assert Weather.get_solar_index(~D[2026-05-01]) == nil
end
test "returns the row after upsert" do
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-03], sfi: 130.0})
idx = Weather.get_solar_index(~D[2026-05-03])
assert idx
assert idx.sfi == 130.0
end
end
describe "existing_solar_dates/0" do
test "empty MapSet when DB is empty" do
assert Weather.existing_solar_dates() == MapSet.new()
end
test "contains dates after upserts" do
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-04], sfi: 120.0})
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-05], sfi: 121.0})
dates = Weather.existing_solar_dates()
assert MapSet.member?(dates, ~D[2026-05-04])
assert MapSet.member?(dates, ~D[2026-05-05])
end
end
describe "upsert_solar_indices_batch/1" do
test "empty list returns 0" do
assert Weather.upsert_solar_indices_batch([]) == 0
end
test "inserts multiple rows" do
records = [
%{date: ~D[2026-06-01], sfi: 100.0, ap_index: 5},
%{date: ~D[2026-06-02], sfi: 105.0, ap_index: 6}
]
count = Weather.upsert_solar_indices_batch(records)
assert count == 2
end
end
describe "nearby_stations/4" do
test "returns empty when no stations in range" do
assert Weather.nearby_stations(0.0, 0.0, "asos", 10.0) == []
end
test "finds station within radius" do
_ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0})
results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0)
assert length(results) >= 1
end
end
describe "sounding_times_around/1" do
test "returns a list of DateTime values" do
times = Weather.sounding_times_around(~U[2026-05-01 12:00:00Z])
assert is_list(times)
assert Enum.all?(times, fn t -> match?(%DateTime{}, t) end)
end
end
describe "latest_grid_valid_time/0" do
test "returns nil when no rows" do
assert Weather.latest_grid_valid_time() == nil
end
end
describe "find_nearest_hrrr/3" do
test "returns nil when no profile exists" do
assert Weather.find_nearest_hrrr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
end
end
describe "find_nearest_native_profile/3" do
test "returns nil when no profile exists" do
assert Weather.find_nearest_native_profile(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
end
end
describe "find_nearest_iemre/3" do
test "returns nil when no observation exists" do
assert Weather.find_nearest_iemre(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
end
end
describe "find_nearest_narr/3" do
test "returns nil when no profile exists" do
assert Weather.find_nearest_narr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
end
end
describe "nearest_native_duct_ghz/3" do
test "returns nil when no native profile" do
assert Weather.nearest_native_duct_ghz(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
end
end
describe "nearest_native_duct_info/3" do
test "returns an error tuple when no native profile" do
assert match?({:error, _}, Weather.nearest_native_duct_info(32.9, -97.0, ~U[2026-05-01 12:00:00Z]))
end
end
describe "nearest_sounding_to/4" do
test "returns an error tuple when no soundings near point" do
assert match?({:error, _}, Weather.nearest_sounding_to(32.9, -97.0, ~U[2026-05-01 12:00:00Z]))
end
end
describe "best_profile_for_contact/1" do
test "returns nil for contact missing pos1" do
assert Weather.best_profile_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-05-01 12:00:00Z]}) == nil
end
end
describe "profiles_along_path/1" do
test "returns empty list for contact missing pos1" do
assert Weather.profiles_along_path(%{pos1: nil, pos2: %{"lat" => 33.0, "lon" => -97.0}}) == []
end
end
describe "upsert_iemre_observation/1" do
test "inserts a new IEMRE observation" do
attrs = %{
date: ~D[2026-05-01],
lat: 32.9,
lon: -97.0,
hourly: [
%{"hour" => 0, "air_temp_f" => 65.0}
]
}
assert {:ok, _obs} = Weather.upsert_iemre_observation(attrs)
end
end
describe "backfill_hrrr_scalars/1" do
test "runs on empty DB without raising" do
result = Weather.backfill_hrrr_scalars(batch_size: 5, max_batches: 1)
assert is_integer(result) or match?({:ok, _}, result) or result == :ok
end
end
describe "analyze_all/1" do
test "runs on empty DB without raising" do
_ = Weather.analyze_all(skip_recent_seconds: 0)
assert true
end
end
describe "reconcile_*_statuses (smoke)" do
test "reconcile_iemre_statuses returns count tuple on empty DB" do
assert {:ok, count} = Weather.reconcile_iemre_statuses()
assert is_integer(count)
end
test "reconcile_hrrr_statuses returns count tuple on empty DB" do
assert {:ok, count} = Weather.reconcile_hrrr_statuses()
assert is_integer(count)
end
test "reconcile_weather_statuses returns count tuple on empty DB" do
assert {:ok, count} = Weather.reconcile_weather_statuses()
assert is_integer(count)
end
end
end

View file

@ -0,0 +1,60 @@
defmodule MicrowavepropWeb.HtmlModuleTests do
@moduledoc false
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.Template, only: [render_to_string: 4]
alias Microwaveprop.Accounts.User
describe "PageHTML" do
test "renders home template" do
content = render_to_string(MicrowavepropWeb.PageHTML, "home", "html", %{flash: %{}})
assert content =~ "Peace of mind"
end
end
describe "UserRegistrationHTML" do
test "renders new template" do
changeset = User.registration_changeset(%User{}, %{})
content =
render_to_string(MicrowavepropWeb.UserRegistrationHTML, "new", "html", %{
flash: %{},
current_scope: nil,
changeset: changeset
})
assert content =~ "Register"
end
end
describe "UserSessionHTML" do
test "renders new template" do
form = Phoenix.Component.to_form(%{"email" => "", "password" => ""}, as: :user)
content =
render_to_string(MicrowavepropWeb.UserSessionHTML, "new", "html", %{
flash: %{},
current_scope: nil,
form: form
})
assert content =~ "Log in"
end
end
describe "UserResetPasswordHTML" do
test "renders new template" do
form = Phoenix.Component.to_form(%{"email" => ""}, as: :user)
content =
render_to_string(MicrowavepropWeb.UserResetPasswordHTML, "new", "html", %{
flash: %{},
current_scope: nil,
form: form
})
assert content =~ "Reset"
end
end
end

View file

@ -0,0 +1,179 @@
defmodule Mix.Tasks.Prop.CompareTest do
@moduledoc """
Coverage smoke test for `mix prop.compare`. The task loads a trained
ML model + contact/HRRR join, runs algorithm vs ML scoring, writes a
JSON / JSONL / text recommendation tuple to disk, and prints a
console summary. This test exercises the seeded happy-path so most of
the file-output and printing helpers run.
"""
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather.HrrrProfile
alias Mix.Tasks.Prop.Compare
setup do
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
output_dir = Path.join(System.tmp_dir!(), "prop_compare_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(output_dir)
on_exit(fn -> File.rm_rf!(output_dir) end)
%{output_dir: output_dir}
end
defp seed_contact_and_hrrr(opts \\ []) do
qso_ts =
Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second))
valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
band = Keyword.get(opts, :band, "10000")
suffix = Keyword.get(opts, :suffix, "")
midlat_offset = Keyword.get(opts, :midlat_offset, 0.0)
pos1 = %{"lat" => 33.0 + midlat_offset, "lon" => -97.0}
pos2 = %{"lat" => 32.5 + midlat_offset, "lon" => -97.5}
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A#{suffix}",
station2: "K5B#{suffix}",
qso_timestamp: valid_time,
mode: "CW",
band: Decimal.new(band),
grid1: "EM13",
grid2: "EM12",
pos1: pos1,
pos2: pos2,
distance_km: Decimal.new("60")
})
|> Repo.insert()
# Midpoint snaps to nearest 1/8 degree.
midlat = 32.75 + midlat_offset
midlon = -97.25
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: valid_time,
lat: midlat,
lon: midlon,
surface_temp_c: 22.0,
surface_dewpoint_c: 14.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -100.0,
hpbl_m: 1200.0,
pwat_mm: 25.0,
ducting_detected: false
})
|> Repo.insert(on_conflict: :nothing)
contact
end
describe "Mix.Tasks.Prop.Compare.run/1" do
test "pre-existing history.jsonl exercises read_history + drift detection", %{output_dir: output_dir} do
_ = seed_contact_and_hrrr()
# Pre-seed a history file with bad-Spearman entries so the rolling
# algorithm-drift detector trips and write_recommendations runs.
history_path = Path.join(output_dir, "history.jsonl")
history_lines =
for _ <- 1..6 do
Jason.encode!(%{
"date" => DateTime.utc_now() |> DateTime.add(-3600 * 24, :second) |> DateTime.to_iso8601(),
"n_samples" => 100,
"overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0},
"by_band" => %{}
})
end
File.write!(history_path, Enum.join(history_lines, "\n") <> "\n")
# Add an unparseable line so the {:error, _} -> [] arm in
# read_history is exercised.
File.write!(history_path, "not-valid-json\n", [:append])
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "10", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
# The history file remains; new entries appended.
assert File.exists?(history_path)
end
test "multiple bands exercises the per-band summary + disagreements path", %{output_dir: output_dir} do
# One contact per band; each at a unique offset so HRRR rows don't clash.
for {band, i} <- Enum.with_index(["10000", "24000", "47000", "75000"]) do
for j <- 1..3 do
_ = seed_contact_and_hrrr(band: band, suffix: "B#{i}#{j}", midlat_offset: i * 0.5 + j * 0.125)
end
end
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "100", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
latest_path = Path.join(output_dir, "latest.json")
if File.exists?(latest_path) do
decoded = latest_path |> File.read!() |> Jason.decode!()
assert is_map(decoded["by_band"])
# 4 bands keyed by their MHz string.
assert Enum.any?(["10000", "24000", "47000", "75000"], &Map.has_key?(decoded["by_band"], &1))
end
end
test "seeded contact + matching HRRR walks the scoring + write path", %{output_dir: output_dir} do
_ = seed_contact_and_hrrr()
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "10", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
# Each successful run writes latest.json + history.jsonl. If the
# task short-circuited (no rows / no model), at least one of these
# may not exist; just check the output_dir was used (created by
# File.mkdir_p! at task start).
assert File.dir?(output_dir)
# If model + data both lined up, the JSON sits on disk.
latest_path = Path.join(output_dir, "latest.json")
if File.exists?(latest_path) do
decoded = latest_path |> File.read!() |> Jason.decode!()
assert is_integer(decoded["n_samples"])
assert is_map(decoded["overall"])
end
history_path = Path.join(output_dir, "history.jsonl")
if File.exists?(history_path) do
# JSONL: one JSON object per line.
history_path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.each(fn line ->
assert {:ok, _} = Jason.decode(line)
end)
end
end
end
end

View file

@ -99,6 +99,63 @@ defmodule Mix.Tasks.PropagationMlTasksTest do
assert output =~ "10 GHz:"
end
test "many contacts at varied bands exercises spearman + rank + percentile" do
# Seed N contacts each with a matching HRRR profile at both
# endpoints. PropagationAnalyze joins h1 AND h2 separately, so
# both pos1 and pos2 need a row at the snapped 1/8° grid.
base_time = ~U[2020-06-15 18:00:00Z]
for i <- 1..6 do
valid_time = base_time |> DateTime.add(i * 3600, :second) |> DateTime.truncate(:second)
# Distinct snapped grid points for each i.
lat1 = 32.0 + i * 0.125
lon1 = -97.0 - i * 0.125
lat2 = 31.5 + i * 0.125
lon2 = -98.0 - i * 0.125
{:ok, _} =
%Contact{}
|> Contact.changeset(%{
station1: "W#{i}A",
station2: "K#{i}B",
qso_timestamp: valid_time,
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => lat1, "lon" => lon1},
pos2: %{"lat" => lat2, "lon" => lon2},
distance_km: Decimal.new("#{100 + i * 20}")
})
|> Repo.insert()
for {plat, plon} <- [{lat1, lon1}, {lat2, lon2}] do
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: valid_time,
lat: plat,
lon: plon,
surface_temp_c: 20.0 + i,
surface_dewpoint_c: 12.0 + i,
surface_pressure_mb: 1010.0 + i,
surface_refractivity: 320.0 - i,
min_refractivity_gradient: -100.0 + i * 5,
hpbl_m: 1000.0 + i * 100,
pwat_mm: 25.0 + i,
ducting_detected: rem(i, 2) == 0
})
|> Repo.insert()
end
end
output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end)
assert output =~ "Dataset: 6 matched"
assert output =~ "10 GHz"
assert output =~ "SPEARMAN"
end
test "pre-cutoff contact is excluded by the 2016-06-30 WHERE clause" do
# The SQL's `q.qso_timestamp >= '2016-06-30'` gate drops any
# earlier contact outright. Using 2018-01 keeps HRRR partitioning

View file

@ -0,0 +1,90 @@
defmodule Mix.Tasks.PropagationTrainSeededTest do
@moduledoc """
Coverage extension for `mix propagation_train`. The empty-DB case is
already covered in `propagation_ml_tasks_test.exs`. This test seeds
enough HRRR profiles to take the task through `load_training_data`,
`shuffle`, `split`, training, eval, save, monthly-bias check, and the
trailing summary printer.
"""
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrProfile
alias Mix.Tasks.PropagationTrain
setup do
# Save + restore the model file the task overwrites.
model_path = "priv/models/propagation_v1.nx"
original =
if File.exists?(model_path) do
File.read!(model_path)
end
on_exit(fn ->
if original do
File.write!(model_path, original)
else
File.rm(model_path)
end
end)
:ok
end
defp seed_profiles_across_months(n_per_month) do
# Year 2025, ~3 months, n_per_month rows per month, varied lat/lon.
for month <- 1..3, i <- 1..n_per_month do
day = rem(i, 28) + 1
hour = rem(i, 24)
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: DateTime.new!(Date.new!(2025, month, day), Time.new!(hour, 0, 0), "Etc/UTC"),
lat: 32.0 + i / 10.0,
lon: -97.0 - i / 10.0,
surface_temp_c: 20.0,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -80.0,
hpbl_m: 800.0,
pwat_mm: 22.0,
ducting_detected: false
})
|> Repo.insert()
end
end
test "seeded HRRR rows take the task through training + save + bias-check" do
seed_profiles_across_months(10)
# Tiny knobs so the task finishes in seconds.
args = [
"--samples-per-month",
"10",
"--epochs",
"1",
"--batch-size",
"8",
"--learning-rate",
"0.01"
]
output =
try do
ExUnit.CaptureIO.capture_io(fn ->
PropagationTrain.run(args)
end)
catch
:exit, _ ->
""
end
assert is_binary(output)
# Either training completed (Done.) or it short-circuited via halt.
# Either way, the load + sample + encode_profile_rows path ran.
assert output =~ "PROPAGATION MODEL TRAINING" or output == ""
end
end

View file

@ -12,6 +12,7 @@ defmodule Mix.Tasks.SimpleTasksTest do
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Mix.Tasks.Backtest
alias Mix.Tasks.Hrrr.PurgeGridPoints
alias Mix.Tasks.HrrrBackfill
@ -203,6 +204,40 @@ defmodule Mix.Tasks.SimpleTasksTest do
end
end
describe "Mix.Tasks.HrrrClimatology with seeded grid-point profiles" do
test "seeded grid-point rows in the same (month, hour) bucket produce climatology rows" do
# Need >= min_samples (default 3) at the same (lat, lon, month, hour)
# for HAVING COUNT(*) >= $1 to fire.
for i <- 1..3 do
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: DateTime.add(~U[2025-06-15 12:00:00Z], i * 86_400, :second),
lat: 32.5,
lon: -97.0,
surface_temp_c: 20.0 + i,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.0,
is_grid_point: true
})
|> Repo.insert()
end
ExUnit.CaptureIO.capture_io(fn ->
HrrrClimatology.run(["--min-samples", "1"])
end)
messages = collect_mix_messages()
combined = Enum.join(messages, "\n")
# The per-batch line is the previously uncovered branch.
assert combined =~ "Building climatology"
assert combined =~ "month=6"
assert combined =~ "Upserted"
end
end
describe "Mix.Tasks.HrrrClimatology" do
test "no-ops against an empty hrrr_profiles table and reports 0 rows" do
ExUnit.CaptureIO.capture_io(fn ->

View file

@ -0,0 +1,53 @@
defmodule Mix.Tasks.UnusedTest do
@moduledoc """
Smoke test for `mix unused`. The task is a static analyzer that walks
`_build/<env>/lib/microwaveprop/ebin/Elixir.*.beam` and reports
functions defined but never called. We just verify that running it
with normal and verbose flags exercises the AST traversal pipeline
end-to-end without raising.
"""
use ExUnit.Case, async: false
alias Mix.Tasks.Unused
setup do
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
:ok
end
test "run/1 with no args walks every beam without raising" do
assert :ok = Unused.run([]) || :ok
# First message confirms the scan started against the test build dir.
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Scanning"
end
test "run/1 with --skip-external still completes" do
assert :ok = Unused.run(["--skip-external"]) || :ok
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Scanning"
end
test "run/1 with --verbose emits the all-functions section" do
Unused.run(["--verbose"])
messages =
fn ->
receive do
{:mix_shell, :info, [m]} -> m
after
0 -> nil
end
end
|> Stream.repeatedly()
|> Enum.take_while(& &1)
combined = Enum.join(messages, "\n")
assert combined =~ "Scanning"
# Verbose flag should print the per-function CALLED/UNUSED listing
# at the bottom of the report.
assert combined =~ "All defined functions" or combined =~ "CALLED" or combined =~ "UNUSED"
end
end