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%.
239 lines
10 KiB
Elixir
239 lines
10 KiB
Elixir
defmodule Microwaveprop.Weather.SoundingParamsTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.SoundingParams
|
|
|
|
# Realistic 3-level profile (surface, 925, 850 hPa)
|
|
@simple_profile [
|
|
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20}
|
|
]
|
|
|
|
# Profile with a temperature inversion (temp increases with height in one layer)
|
|
@inversion_profile [
|
|
%{"pres" => 1013.0, "hght" => 100, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 5},
|
|
%{"pres" => 980.0, "hght" => 400, "tmpc" => 22.0, "dwpc" => 14.0, "drct" => 190, "sknt" => 8},
|
|
%{"pres" => 950.0, "hght" => 700, "tmpc" => 26.0, "dwpc" => 10.0, "drct" => 200, "sknt" => 12},
|
|
%{"pres" => 925.0, "hght" => 900, "tmpc" => 20.0, "dwpc" => 8.0, "drct" => 210, "sknt" => 15},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 5.0, "drct" => 230, "sknt" => 20},
|
|
%{"pres" => 700.0, "hght" => 3100, "tmpc" => 2.0, "dwpc" => -5.0, "drct" => 250, "sknt" => 30},
|
|
%{"pres" => 500.0, "hght" => 5600, "tmpc" => -15.0, "dwpc" => -25.0, "drct" => 270, "sknt" => 40}
|
|
]
|
|
|
|
# Profile designed to create a duct (M decreases with height)
|
|
# Strong surface-based inversion with very dry air aloft → M decreases
|
|
@ducting_profile [
|
|
%{"pres" => 1013.0, "hght" => 0, "tmpc" => 30.0, "dwpc" => 25.0, "drct" => 180, "sknt" => 5},
|
|
%{"pres" => 1000.0, "hght" => 100, "tmpc" => 35.0, "dwpc" => 5.0, "drct" => 180, "sknt" => 5},
|
|
%{"pres" => 980.0, "hght" => 300, "tmpc" => 28.0, "dwpc" => 10.0, "drct" => 190, "sknt" => 8},
|
|
%{"pres" => 950.0, "hght" => 550, "tmpc" => 24.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 10},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 10.0, "drct" => 210, "sknt" => 12},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 5.0, "drct" => 230, "sknt" => 18},
|
|
%{"pres" => 700.0, "hght" => 3100, "tmpc" => 2.0, "dwpc" => -10.0, "drct" => 250, "sknt" => 25},
|
|
%{"pres" => 500.0, "hght" => 5600, "tmpc" => -18.0, "dwpc" => -30.0, "drct" => 270, "sknt" => 35}
|
|
]
|
|
|
|
describe "sat_vap_pres/1" do
|
|
test "returns known value at 20°C (~23.4 hPa)" do
|
|
result = SoundingParams.sat_vap_pres(20.0)
|
|
assert_in_delta result, 23.4, 0.5
|
|
end
|
|
|
|
test "returns known value at 0°C (~6.1 hPa)" do
|
|
result = SoundingParams.sat_vap_pres(0.0)
|
|
assert_in_delta result, 6.1, 0.2
|
|
end
|
|
end
|
|
|
|
describe "mixing_ratio/2" do
|
|
test "returns reasonable value for typical surface conditions" do
|
|
# At 15°C, 1013 hPa, mixing ratio ~10.7 g/kg
|
|
result = SoundingParams.mixing_ratio(15.0, 1013.0)
|
|
assert_in_delta result, 10.7, 1.0
|
|
end
|
|
end
|
|
|
|
describe "derive/1" do
|
|
test "returns nil for profiles with fewer than 3 valid levels" do
|
|
short = [
|
|
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0}
|
|
]
|
|
|
|
assert SoundingParams.derive(short) == nil
|
|
end
|
|
|
|
test "returns nil for empty profile" do
|
|
assert SoundingParams.derive([]) == nil
|
|
end
|
|
|
|
test "computes surface parameters" do
|
|
result = SoundingParams.derive(@simple_profile)
|
|
assert result
|
|
assert_in_delta result.surface_temp_c, 25.0, 0.01
|
|
assert_in_delta result.surface_dewpoint_c, 15.0, 0.01
|
|
assert_in_delta result.surface_pressure_mb, 1013.0, 0.01
|
|
end
|
|
|
|
test "computes surface refractivity (N-units)" do
|
|
result = SoundingParams.derive(@simple_profile)
|
|
# At 25°C, 15°C dewpoint, 1013 hPa — N should be ~330-350
|
|
assert result.surface_refractivity > 300
|
|
assert result.surface_refractivity < 400
|
|
end
|
|
|
|
test "computes dN/dh gradients" do
|
|
result = SoundingParams.derive(@simple_profile)
|
|
# Standard atmosphere dN/dh is about -40/km
|
|
assert result.min_refractivity_gradient < 0
|
|
end
|
|
|
|
test "captures sharp sub-10m inversions instead of skipping the layer" do
|
|
# HRRR native-level profiles routinely report 0/10/30/50 m thin layers;
|
|
# an inversion confined to the lowest 5m is exactly the kind of
|
|
# surface-based duct the propagation map cares about, so dropping any
|
|
# gradient with `dh < 10 m` quietly hides the strongest signals.
|
|
thin_layer_profile = [
|
|
%{"pres" => 1013.0, "hght" => 0.0, "tmpc" => 25.0, "dwpc" => 22.0},
|
|
%{"pres" => 1012.5, "hght" => 5.0, "tmpc" => 24.5, "dwpc" => 5.0},
|
|
%{"pres" => 1010.0, "hght" => 100.0, "tmpc" => 23.0, "dwpc" => 4.0},
|
|
%{"pres" => 950.0, "hght" => 600.0, "tmpc" => 18.0, "dwpc" => 0.0}
|
|
]
|
|
|
|
result = SoundingParams.derive(thin_layer_profile)
|
|
# The 0→5 m drop in dewpoint creates a strong negative dM/dh and
|
|
# therefore a sharp negative refractivity gradient. Without it we
|
|
# only see the broad 5→100 m baseline, which is much shallower.
|
|
assert result.min_refractivity_gradient < -200
|
|
end
|
|
|
|
test "detects temperature inversions" do
|
|
result = SoundingParams.derive(@inversion_profile)
|
|
assert result.inversions != []
|
|
|
|
inv = hd(result.inversions)
|
|
assert inv.strength > 0
|
|
assert inv.base >= 0
|
|
end
|
|
|
|
test "no inversions in standard lapse profile" do
|
|
# Simple profile has monotonic temperature decrease
|
|
result = SoundingParams.derive(@simple_profile)
|
|
assert result.inversions == []
|
|
end
|
|
|
|
test "computes precipitable water" do
|
|
result = SoundingParams.derive(@inversion_profile)
|
|
# PW should be positive and reasonable (5-80mm)
|
|
assert result.precipitable_water_mm > 0
|
|
assert result.precipitable_water_mm < 100
|
|
end
|
|
|
|
test "computes K-Index when standard levels available" do
|
|
result = SoundingParams.derive(@inversion_profile)
|
|
# K-Index should be computed (profile has 850, 700, 500 levels)
|
|
assert result.k_index
|
|
end
|
|
|
|
test "computes Lifted Index when 500 hPa available" do
|
|
result = SoundingParams.derive(@inversion_profile)
|
|
assert result.lifted_index
|
|
end
|
|
|
|
test "detects ducting in strong inversion profile" do
|
|
result = SoundingParams.derive(@ducting_profile)
|
|
assert result.ducting_detected == true
|
|
assert result.ducts != []
|
|
end
|
|
|
|
test "no ducting in standard profile" do
|
|
result = SoundingParams.derive(@simple_profile)
|
|
assert result.ducting_detected == false
|
|
assert result.ducts == []
|
|
end
|
|
|
|
test "keeps a surface duct that has not yet closed by the top of the profile" do
|
|
# Three sampled levels with monotonically decreasing modified
|
|
# refractivity (M). The profile starts inside a trapping layer
|
|
# and the duct never "tops out" before the data runs out. The
|
|
# previous code threw the duct away because it never observed
|
|
# the layer close — hiding exactly the surface ducts the
|
|
# propagation map needs to surface.
|
|
open_top_duct = [
|
|
%{"pres" => 1013.0, "hght" => 0.0, "tmpc" => 30.0, "dwpc" => 27.0},
|
|
%{"pres" => 1010.0, "hght" => 20.0, "tmpc" => 33.0, "dwpc" => 5.0},
|
|
%{"pres" => 1005.0, "hght" => 50.0, "tmpc" => 35.0, "dwpc" => -10.0}
|
|
]
|
|
|
|
result = SoundingParams.derive(open_top_duct)
|
|
assert result.ducting_detected
|
|
assert result.ducts != []
|
|
end
|
|
|
|
test "computes boundary layer depth" do
|
|
result = SoundingParams.derive(@inversion_profile)
|
|
# BL depth should be positive and reasonable
|
|
assert result.boundary_layer_depth_m > 0
|
|
assert result.boundary_layer_depth_m < 5000
|
|
end
|
|
|
|
test "returns level_count matching filtered profile" do
|
|
result = SoundingParams.derive(@simple_profile)
|
|
assert result.level_count == 3
|
|
end
|
|
|
|
test "filters out levels with nil pressure, temp, or height" do
|
|
profile_with_nils = [
|
|
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10},
|
|
%{"pres" => nil, "hght" => 500, "tmpc" => 22.0, "dwpc" => 13.0, "drct" => 190, "sknt" => 12},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20}
|
|
]
|
|
|
|
result = SoundingParams.derive(profile_with_nils)
|
|
assert result.level_count == 3
|
|
end
|
|
|
|
test "tolerates Rust-style keys (pres_mb, hght_m) via normalization" do
|
|
rust_profile = [
|
|
%{"pres_mb" => 1013.0, "hght_m" => 171, "tmpc" => 25.0, "dwpc" => 15.0},
|
|
%{"pres_mb" => 925.0, "hght_m" => 800, "tmpc" => 20.0, "dwpc" => 12.0},
|
|
%{"pres_mb" => 850.0, "hght_m" => 1500, "tmpc" => 15.0, "dwpc" => 8.0}
|
|
]
|
|
|
|
result = SoundingParams.derive(rust_profile)
|
|
assert result.level_count == 3
|
|
assert result.surface_refractivity > 300
|
|
assert result.min_refractivity_gradient < 0
|
|
end
|
|
|
|
test "returns nil refractivity when every level is missing dewpoint" do
|
|
no_dwpc = [
|
|
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0}
|
|
]
|
|
|
|
result = SoundingParams.derive(no_dwpc)
|
|
assert result.surface_refractivity == nil
|
|
assert result.min_refractivity_gradient == nil
|
|
end
|
|
|
|
test "computes refractivity from levels that have dewpoint, ignoring those that don't" do
|
|
mixed_dwpc = [
|
|
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0},
|
|
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0},
|
|
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0}
|
|
]
|
|
|
|
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
|
|
assert result.surface_refractivity > 300
|
|
# Two levels with dewpoint → one gradient → min_gradient is that value
|
|
assert result.min_refractivity_gradient
|
|
assert result.min_refractivity_gradient < 0
|
|
end
|
|
end
|
|
end
|