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:
parent
f8dee5e670
commit
fc9d2298ac
18 changed files with 1928 additions and 365 deletions
|
|
@ -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
479
lib/mix/tasks/unused.ex
Normal 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
|
||||
1
mix.exs
1
mix.exs
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
126
test/microwaveprop/propagation/untested_functions_test.exs
Normal file
126
test/microwaveprop/propagation/untested_functions_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
530
test/microwaveprop/weather/untested_functions_test.exs
Normal file
530
test/microwaveprop/weather/untested_functions_test.exs
Normal 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
|
||||
60
test/microwaveprop_web/controllers/html_module_test.exs
Normal file
60
test/microwaveprop_web/controllers/html_module_test.exs
Normal 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
|
||||
179
test/mix/tasks/prop_compare_test.exs
Normal file
179
test/mix/tasks/prop_compare_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
90
test/mix/tasks/propagation_train_seeded_test.exs
Normal file
90
test/mix/tasks/propagation_train_seeded_test.exs
Normal 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
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
53
test/mix/tasks/unused_test.exs
Normal file
53
test/mix/tasks/unused_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue