feat(propagation): per-band weight calibration from full-corpus correlation

Ran recalibrate_algo.py against the full local prop_dev (81,994 contacts,
18.6M HRRR rows) and derived per-band composite weights for the nine
bands with >=200 matched contacts. Moisture (dewpoint/PWAT/surface N)
is consistently beneficial through 5.76 GHz, reverses at 24 GHz; rain
scales sqrt(rain_k) not linearly so 24 GHz gets 0.215 rain weight
instead of the linear-ratio 0.95. 10 GHz stays as the reference band
(defaults); 47+ GHz inherits defaults (n<200).

- BandConfig.weights/1 returns per-band override or default fallback
- @band_configs carries :weights on 222/432/902/1296/2304/3400/5760/24G
- Recalibrator.compute_factors/3 + fit(band_mhz:) band-aware fitting
- Scorer + ContactLive.Show pass band_config to weights/1
- algo.md Part 2d documents the 2026-04-18 analysis + derivation rule
- scripts/derive_band_weights.py turns correlations into weight maps
- report preserved at docs/algo-reports/2026-04-18-recalibration.md
This commit is contained in:
Graham McIntire 2026-04-18 10:19:00 -05:00
parent 41c9a5522f
commit 5fd37a17fd
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 1320 additions and 96 deletions

184
algo.md
View file

@ -808,6 +808,131 @@ This is the first *measured* signal in the algorithm — every other factor is a
---
## Part 2d: Full-Corpus Per-Band Recalibration — April 18 2026
Ran `scripts/recalibrate_algo.py` against the full local `prop_dev` (81,994 contacts 19912026, 18.6M `hrrr_profiles` rows 20182026, 354 `narr_profiles` rows 19912014 for the HRRR-era gap, 11.4K `hrrr_native_profiles`, 7.3K `nexrad_observations`, 26.9K `soundings`). The full report is preserved at `docs/algo-reports/2026-04-18-recalibration.md`.
### Matched corpus sizes
Each contact is joined to its nearest HRRR grid point at ±0.07° / ±1h (±0.25° / ±2h for the pre-2014 NARR fallback). `DISTINCT ON (c.id)` keeps a single nearest match per contact. Pre-2014 coverage is intentionally thin: of 207 pre-2014 contacts with `pos1`, 103 matched a NARR profile — the NARR backfill has only fired on the contacts the enqueuer has seen.
| Band | Contacts in DB | HRRR-matched | NARR-matched |
|---|---|---|---|
| 222 MHz | 7,595 | 5,392 | 0 |
| 432 MHz | 9,177 | 6,583 | 0 |
| 902 MHz | 1,794 | 1,317 | 0 |
| 1,296 MHz | 2,996 | 2,146 | 0 |
| 2,304 MHz | 749 | 564 | 0 |
| 3,400 MHz | 351 | 280 | 0 |
| 5,760 MHz | 328 | 246 | 0 |
| 10 GHz | 54,264 | 6,675 | 103 |
| 24 GHz | 3,806 | 613 | 0 |
| 47 GHz | 762 | 53 | 0 |
The per-band HRRR-match rate is 1275%; the rest are contacts whose timestamps predate the HRRR archive start (2014-10) or whose locations don't have a nearest grid point written. The NARR join only lights up at 10 GHz because pre-2014 QSOs happen to concentrate there in the corpus.
### Per-band Pearson correlations vs contact distance
Joining each contact to its nearest HRRR profile (±0.07° / ±1h) and computing Pearson `r` between distance_km and the HRRR field at the origin station:
| Band (n) | Temp | Dewpoint | Pressure | PWAT | Surface N | dN/dh | HPBL |
|---|---|---|---|---|---|---|---|
| 222 (5,392) | 0.086 | **+0.063** | 0.038 | **+0.077** | **+0.077** | +0.057 | 0.033 |
| 432 (6,583) | 0.089 | **+0.137** | +0.058 | **+0.124** | **+0.136** | +0.041 | 0.066 |
| 902 (1,317) | 0.060 | +0.096 | +0.044 | +0.049 | +0.079 | 0.014 | 0.010 |
| 1,296 (2,146) | 0.032 | +0.085 | +0.013 | +0.049 | +0.085 | 0.031 | 0.041 |
| 2,304 (564) | 0.114 | **+0.130** | 0.006 | +0.086 | **+0.154** | 0.110 | 0.074 |
| 3,400 (280) | 0.103 | **+0.150** | 0.025 | **+0.131** | **+0.176** | +0.046 | 0.023 |
| 5,760 (246) | 0.082 | +0.087 | +0.040 | **+0.137** | +0.105 | +0.021 | 0.083 |
| 10,000 (6,675) | +0.043 | 0.028 | **0.066** | +0.017 | +0.031 | 0.024 | 0.025 |
| 24,000 (613) | 0.007 | **0.208** | **0.178** | **0.172** | 0.103 | **0.251** | **0.201** |
| 47,000 (53) | +0.222 | **0.518** | **0.423** | 0.149 | +0.510 | 0.154 | **0.465** |
**Signs tell the story.** From 222 MHz through 5.76 GHz moisture (dewpoint, PWAT, surface refractivity) is consistently *positive* — higher column water → longer contacts, because water-vapor pressure lifts surface-layer refractivity without triggering the absorption penalty that kicks in above ~15 GHz. At 10 GHz the signs flip or collapse into noise. At 24 GHz all seven fields go negative and the magnitudes roughly triple: the same synoptic ridge that bends 222 MHz rays 250 km is also the ridge that loads 24 GHz air with 30+ mm of H₂O absorption. This is the algorithm's humidity-direction reversal made visible in the data.
`47 GHz (n=53)` is printed for interest only; the correlation magnitudes are eye-watering because the sample is tiny and AugSep contest clusters. **We do not use the 47 GHz row to fit weights.**
### Per-band weight derivation
Rule (encoded in `scripts/derive_band_weights.py`):
1. Start from the global default weight vector (`BandConfig.weights/0`, the 2026-04-11 gradient-descent fit).
2. For each of the five correlation-backed factors (humidity↔dewpoint, td_depression↔temp, refractivity↔dN/dh, pressure↔pressure, pwat↔PWAT) compute a multiplier:
```
s_band,factor = (|r_band| + 0.05) / (|r_10GHz| + 0.05)
```
then clamp to [0.5, 2.0]. The 0.05 floor is the noise level at which we can reliably distinguish a correlation from zero given typical n, and the clamp keeps a single noisy correlation from moving a weight more than 2× the prior.
3. For the five physics-only factors (rain, season, time-of-day, sky, wind) use predetermined per-band multipliers:
- **Rain** scales as √(rain_k / 10 GHz rain_k). Linear scaling (rain_k ratio 7× at 24 GHz) would swamp everything; sqrt keeps the weight jump proportionate to the fraction of conditions where rain actually drives the outcome.
- **Season** scales up at VHF/UHF (Es-like physics amplifies monthly variation that we don't model directly) and at mm-wave (summer humidity hurts).
- **Time-of-day** scales with frequency per Finding 8: 0.6× at 50 MHz, 1.0× at 10 GHz, 5.0× at 122 GHz+.
- **Sky, wind** held flat — no per-band evidence.
4. Multiply default weights by the multipliers, normalize to sum to 1.0, round to 4 decimals.
Bands with fewer than 200 matched contacts (47+ GHz) and bands with zero contacts (50, 144 MHz) inherit the default vector via `BandConfig.weights/1` fallback.
### Resulting per-band weight matrix
| Band | humid | tod | td | refr | sky | season | wind | rain | pwat | press |
|---|---|---|---|---|---|---|---|---|---|---|
| **default** | 0.1243 | 0.0496 | 0.0978 | 0.1049 | 0.0800 | 0.1112 | 0.0800 | 0.1362 | 0.1128 | 0.1032 |
| 222 MHz | 0.1593 | 0.0350 | 0.1250 | 0.1401 | 0.0706 | 0.1276 | 0.0706 | **0.0120** | **0.1916** | 0.0681 |
| 432 MHz | **0.2061** | 0.0329 | 0.1200 | 0.1186 | 0.0663 | 0.1106 | 0.0663 | **0.0113** | **0.1870** | 0.0809 |
| 902 MHz | **0.2201** | 0.0437 | 0.1102 | 0.0888 | 0.0783 | 0.1197 | 0.0783 | **0.0133** | **0.1635** | 0.0839 |
| 1,296 MHz | **0.2131** | 0.0494 | 0.0898 | 0.1392 | 0.0797 | 0.1108 | 0.0797 | **0.0136** | 0.1678 | 0.0568 |
| 2,304 MHz | 0.1941 | 0.0387 | 0.1385 | **0.1638** | 0.0625 | 0.0868 | 0.0625 | 0.0336 | 0.1762 | 0.0432 |
| 3,400 MHz | 0.1996 | 0.0398 | 0.1251 | 0.1310 | 0.0642 | 0.0893 | 0.0642 | 0.0489 | 0.1811 | 0.0568 |
| 5,760 MHz | 0.1829 | 0.0423 | 0.1205 | 0.0881 | 0.0683 | 0.0949 | 0.0683 | 0.0822 | **0.1925** | 0.0602 |
| 10 GHz | *defaults (reference band)* | | | | | | | | | |
| 24 GHz | 0.1481 | 0.0532 | **0.0360** | 0.1250 | 0.0477 | 0.0729 | 0.0477 | **0.2147** | 0.1344 | 0.1203 |
Cells in bold are the factors that moved meaningfully from default — roughly, what the data says is *different* at this band. The overall pattern:
- **VHF/UHF (2221296 MHz):** PWAT and humidity up, rain and pressure down. These bands need moisture to duct; rain doesn't attenuate them; surface pressure is a weak synoptic proxy that cleaner moisture fields already capture.
- **Low microwave (2,3045,760 MHz):** Moisture still dominant; refractivity gradient picks up weight as HRRR's vertical resolution starts to matter; rain gradually climbs.
- **10 GHz:** Reference band, weights unchanged.
- **24 GHz:** Rain doubles-plus (0.215), td_depression collapses to 0.036 because the temperature signal alone is essentially zero (humidity via dewpoint already carries the moisture story). Refractivity holds because dN/dh shows the strongest correlation of any band at 24 GHz (0.25).
### Soundings refresh (n=26,933 total, 9,574 with refractivity gradient)
Monthly ducting probability from the expanded sounding corpus. Compared to the Part 2c snapshot (n=6,757), August's sample nearly doubled (2,572 → 5,007) as soundings backfill caught up with the QSO calendar. The headline findings hold: **March is still the worst month** (17.3% ducting), **JuneJuly the peak** (6770%).
| Month | Soundings | Ducting % | Avg dN/dh | Avg PWAT (mm) |
|---|---|---|---|---|
| Jan | 95 | 28.4% | 171 | 10.9 |
| Feb | 131 | 29.0% | 163 | 7.9 |
| Mar | 81 | **17.3%** | 148 | 9.4 |
| Apr | 134 | 31.3% | 176 | 13.5 |
| May | 311 | 51.8% | 286 | 25.6 |
| Jun | 380 | **69.5%** | 356 | 32.0 |
| Jul | 302 | 67.5% | 294 | 29.7 |
| Aug | 5,007 | 55.2% | 255 | 35.2 |
| Sep | 2,335 | 56.2% | 280 | 27.2 |
| Oct | 155 | 57.4% | 307 | 19.3 |
| Nov | 158 | 50.0% | 257 | 12.0 |
| Dec | 140 | 25.7% | 185 | 10.8 |
### What stayed, what left
- **Humidity direction flip at ~15 GHz** — preserved. Data shows it clearly (signs flip going from 5.76 GHz to 10 GHz and again from 10 GHz to 24 GHz).
- **Pressure is the strongest 10 GHz correlator** — preserved, but magnitude is now 0.066 (vs 0.180 reported in Part 2b); the newer matched corpus has cleaner spatial joins and less contest-seasonality bias.
- **HPBL is negative at every band we can fit** — preserved. Already folded into refractivity as a multiplier.
- **Per-band recalibration is statistically defensible** — the Part 2c moratorium lifts. 9 bands have n ≥ 200 matched contacts.
- **NARR historical calibration — deferred.** 103 pre-2014 matches at 10 GHz isn't enough to validate the weights against the earlier atmosphere; the NARR backfill queue is still draining. Re-run when `narr_profiles` crosses ~10K rows.
- **Commercial-link sensor calibration — deferred.** Zero DFW-zone contact overlap with the 20-day commercial_samples window. Re-run after the next contest season.
### Open items
1. **50 MHz and 144 MHz are unmodeled (0 contacts in corpus).** Both bands carry default weights but ranges and seasonal tables are pure physics priors. Any VHF calibration needs an import of 6 m and 2 m contacts from external logs before we can say anything.
2. **47+ GHz inherit defaults.** 47 GHz has n=53, below the 200-contact floor; 75 GHz has n=106 in the DB but only a handful HRRR-match. Re-fit when contest-season 47/75 GHz logs accumulate.
3. **Refractivity gradient signal is load-bearing only at 24 GHz.** At every other band, the gradient correlation rides the noise floor. Moving to native-resolution HRRR (`hrrr_native_profiles.best_duct_band_ghz`) is how this gets better; the data in this table shows 1015× cleaner gradients when we have a duct-supporting cell.
---
## Part 3: Band Configuration
```elixir
@ -1368,18 +1493,11 @@ Calibration plan once backfill is complete:
## Part 5: Composite Score
### Weights
### Weights — per-band since 2026-04-18
Recalibrated 2026-04-11 via gradient descent on 5,000 QSOs (loss 0.42 → 0.12, 72% improvement). Five additional upper-air factors (described at the end of Part 4) are queued for the next recalibration — they cannot be fit until the native-profile backfill reaches enough QSO hours to produce a representative training sample. Key changes from the April 2026 manual calibration:
The scoring weight vector is now band-specific. `BandConfig.weights(band_config)` returns either the override map stored on the band (for the nine bands with ≥200 matched HRRR samples in the 2026-04-18 full-corpus analysis) or the default vector shown below for everything else.
- **Rain: 8% → 13.6%** — Largest increase. Gradient descent found rain is a stronger discriminator than manual analysis suggested.
- **Season: 8% → 11.1%** — Seasonal patterns are more predictive than the correlation analysis indicated (correlations were suppressed by Aug/Sep dataset bias).
- **Refractivity: 8% → 10.5%** — Now sourced from native HRRR hybrid-sigma levels (10-50m resolution) when available, resolving thin surface ducts invisible to the 250m pressure-level product. Higher weight justified by higher-quality input data.
- **PWAT: 10% → 11.3%** — Confirmed as strong independent predictor.
- **Pressure: 15% → 10.3%** — Was overweighted; pressure is a proxy for frontal activity but partially redundant with rain and refractivity.
- **Humidity: 18% → 12.4%** — Partially captured by PWAT and Td depression.
- **Time of Day: 10% → 5.0%** — Contest timing bias inflated this factor; real diurnal effect is weaker at the dominant 10 GHz band.
- **Sky, Wind: 8%, 5% → 8%, 8%** — Hit minimum floor; gradient descent would go lower but physical rationale retains them.
**Default vector** (the April-11 gradient-descent fit, applied at 10 GHz as the reference band and to any band without enough data to fit its own vector):
| Factor | Weight | Source |
|--------|--------|--------|
@ -1387,31 +1505,51 @@ Recalibrated 2026-04-11 via gradient descent on 5,000 QSOs (loss 0.42 → 0.12,
| Humidity | 12.4% | Absolute humidity from surface T/Td |
| PWAT | 11.3% | HRRR precipitable water (column-integrated) |
| Season | 11.1% | Per-band monthly lookup tables |
| Refractivity | 10.5% | Native HRRR dM/dh (10-50m), fallback to pressure-level (250m) |
| Refractivity | 10.5% | Native HRRR dM/dh (1050m), fallback to pressure-level (250m) |
| Pressure | 10.3% | Surface pressure (frontal activity proxy) |
| Td Depression | 9.8% | Surface T minus Td (stability indicator) |
| Sky Cover | 8.0% | HRRR total cloud cover |
| Wind | 8.0% | HRRR 10m wind speed |
| Time of Day | 5.0% | Solar-time adjusted diurnal cycle |
**Per-band overrides** (see Part 2d for the derivation rule and full matrix):
| Band | humidity | tod | td | refr | sky | season | wind | rain | pwat | press |
|---|---|---|---|---|---|---|---|---|---|---|
| 222 MHz | 0.1593 | 0.0350 | 0.1250 | 0.1401 | 0.0706 | 0.1276 | 0.0706 | 0.0120 | 0.1916 | 0.0681 |
| 432 MHz | 0.2061 | 0.0329 | 0.1200 | 0.1186 | 0.0663 | 0.1106 | 0.0663 | 0.0113 | 0.1870 | 0.0809 |
| 902 MHz | 0.2201 | 0.0437 | 0.1102 | 0.0888 | 0.0783 | 0.1197 | 0.0783 | 0.0133 | 0.1635 | 0.0839 |
| 1.296 GHz | 0.2131 | 0.0494 | 0.0898 | 0.1392 | 0.0797 | 0.1108 | 0.0797 | 0.0136 | 0.1678 | 0.0568 |
| 2.304 GHz | 0.1941 | 0.0387 | 0.1385 | 0.1638 | 0.0625 | 0.0868 | 0.0625 | 0.0336 | 0.1762 | 0.0432 |
| 3.4 GHz | 0.1996 | 0.0398 | 0.1251 | 0.1310 | 0.0642 | 0.0893 | 0.0642 | 0.0489 | 0.1811 | 0.0568 |
| 5.76 GHz | 0.1829 | 0.0423 | 0.1205 | 0.0881 | 0.0683 | 0.0949 | 0.0683 | 0.0822 | 0.1925 | 0.0602 |
| 10 GHz | *defaults (reference)* | | | | | | | | | |
| 24 GHz | 0.1481 | 0.0532 | 0.0360 | 0.1250 | 0.0477 | 0.0729 | 0.0477 | 0.2147 | 0.1344 | 0.1203 |
Bands inheriting defaults: 50, 144 (0 contacts), 47+ GHz (<200 matched contacts). The `Scorer` composite is now:
```elixir
# Recalibrated 2026-04-11 via gradient descent on 5000 QSOs (loss 0.42 → 0.12)
def composite_score(factors) do
def composite_score(conditions, band_config) do
factors = compute_factors(conditions, band_config)
weights = BandConfig.weights(band_config)
round(
factors.rain * 0.1362 +
factors.humidity * 0.1243 +
factors.pwat * 0.1128 +
factors.season * 0.1112 +
factors.refractivity * 0.1049 +
factors.pressure * 0.1032 +
factors.td_depression * 0.0978 +
factors.sky * 0.08 +
factors.wind * 0.08 +
factors.time_of_day * 0.0496
factors.rain * weights.rain +
factors.humidity * weights.humidity +
factors.pwat * weights.pwat +
factors.season * weights.season +
factors.refractivity * weights.refractivity +
factors.pressure * weights.pressure +
factors.td_depression * weights.td_depression +
factors.sky * weights.sky +
factors.wind * weights.wind +
factors.time_of_day * weights.time_of_day
)
end
```
All weight vectors sum to 1.0 within round-off (verified by `BandConfigTest`). Re-run `scripts/recalibrate_algo.py` + `scripts/derive_band_weights.py` whenever the contact or HRRR corpus grows materially — the defaults in this section are a snapshot of the 2026-04-18 local `prop_dev` state.
### Score Tiers with Per-Band Range Estimates
Range estimates are for CW mode. For SSB/phone, reduce by ~25% at 10 GHz, ~15% at 24 GHz, ~50% at 47 GHz, ~70% at 75+ GHz. For FM, reduce by ~40%.

View file

@ -0,0 +1,442 @@
# Recalibration Analysis Report — 2026-04-18
> Auto-generated by `scripts/recalibrate_algo.py`. This report is the
> input for `algo.md` updates — diff against the prior dated section to
> see what's actually moved.
Connection: `postgresql://postgres@localhost:5432/prop_dev`
Statement timeout: 60min
## Row counts and date ranges
| tbl | n | lo | hi |
|:---------------------|-------------:|:-----------|:-----------|
| contacts | 81994.000 | 1991-05-04 | 2026-03-07 |
| hrrr_native_profiles | 11472.000 | 2019-03-09 | 2025-09-21 |
| hrrr_profiles | 18591740.000 | 2018-08-04 | 2026-04-05 |
| iemre_observations | 28438.000 | | |
| narr_profiles | 354.000 | 1991-05-04 | 2014-09-19 |
| nexrad_observations | 7286.000 | 2019-08-17 | 2024-09-22 |
| propagation_scores | nan | | |
| rtma_observations | 0.000 | | |
| soundings | 26933.000 | 1990-06-07 | 2026-04-18 |
| surface_observations | 599046.000 | 1991-05-03 | 2026-04-18 |
| terrain_profiles | 81994.000 | | |
## Data gaps (these should NOT be zero in a healthy prod)
- **narr_rows**: 354
- **narr_pre2014_rows**: 354
- **hrrr_climatology_rows**: 0
- **rtma_rows**: 0
- **metar5_rows**: 0
- **hrrr_complete_contacts**: 67260
- **hrrr_pending_contacts**: 14734
- **pre2014_contacts**: 207
## Contacts by band (≥50 MHz)
| band_mhz | contacts | avg_km | max_km |
|-----------:|-----------:|---------:|---------:|
| 222 | 7595 | 229.000 | 2306.000 |
| 432 | 9177 | 200.000 | 1752.000 |
| 902 | 1794 | 171.000 | 915.000 |
| 1296 | 2996 | 160.000 | 1752.000 |
| 2304 | 749 | 153.000 | 655.000 |
| 3400 | 351 | 151.000 | 643.000 |
| 5760 | 328 | 125.000 | 505.000 |
| 10000 | 54264 | 212.000 | 2393.000 |
| 24000 | 3806 | 95.000 | 710.000 |
| 47000 | 762 | 65.000 | 343.000 |
| 75000 | 106 | 60.000 | 289.000 |
| 122000 | 35 | 27.000 | 139.000 |
| 134000 | 5 | 65.000 | 157.000 |
| 142000 | 4 | 47.000 | 79.700 |
| 145000 | 2 | 40.000 | 79.600 |
| 241000 | 14 | 31.000 | 114.400 |
| 288000 | 1 | 1.000 | 1.246 |
| 322000 | 1 | 1.000 | 1.400 |
| 403000 | 1 | 1.000 | 1.400 |
| 411000 | 1 | 0.000 | 0.050 |
## Monthly sounding ducting
| month | soundings | ducting_pct | avg_min_grad | avg_pwat |
|--------:|------------:|--------------:|---------------:|-----------:|
| 1 | 95 | 28.400 | -171.400 | 10.900 |
| 2 | 131 | 29.000 | -163.100 | 7.900 |
| 3 | 81 | 17.300 | -148.100 | 9.400 |
| 4 | 134 | 31.300 | -175.600 | 13.500 |
| 5 | 311 | 51.800 | -286.400 | 25.600 |
| 6 | 380 | 69.500 | -355.900 | 32.000 |
| 7 | 302 | 67.500 | -293.800 | 29.700 |
| 8 | 5007 | 55.200 | -255.400 | 35.200 |
| 9 | 2335 | 56.200 | -279.800 | 27.200 |
| 10 | 155 | 57.400 | -307.100 | 19.300 |
| 11 | 158 | 50.000 | -256.700 | 12.000 |
| 12 | 140 | 25.700 | -185.200 | 10.800 |
## HRRR native-profile duct distribution (best supportable band)
| band_bin | profiles | avg_inv_top_m | avg_theta_e_jump | avg_richardson |
|:-----------|-----------:|----------------:|-------------------:|-----------------:|
| <5 GHz | 1432 | 2097.000 | 5.400 | 18.420 |
| 15-30 GHz | 10 | 2091.000 | 5.900 | 8.690 |
| 30-75 GHz | 4 | 3860.000 | 5.000 | 12.120 |
| 5-15 GHz | 98 | 4408.000 | 25.700 | 12.550 |
| none | 9928 | 11497.000 | 80.500 | 38.270 |
## Contact ↔ HRRR per-band Pearson correlations (matched n=23885)
| band_mhz | n | rho_pr | rho_dpc | rho_pwat | rho_sref | rho_grad | rho_tc | rho_hpbl |
|-----------:|---------:|---------:|----------:|-----------:|-----------:|-----------:|---------:|-----------:|
| 222.000 | 5392.000 | -0.038 | 0.063 | 0.077 | 0.077 | 0.057 | -0.086 | -0.033 |
| 432.000 | 6583.000 | 0.058 | 0.137 | 0.124 | 0.136 | 0.041 | -0.089 | -0.066 |
| 902.000 | 1317.000 | 0.044 | 0.096 | 0.049 | 0.079 | -0.014 | -0.060 | -0.010 |
| 1296.000 | 2146.000 | 0.013 | 0.085 | 0.049 | 0.085 | -0.031 | -0.032 | -0.041 |
| 2304.000 | 564.000 | -0.006 | 0.130 | 0.086 | 0.154 | -0.110 | -0.114 | -0.074 |
| 3400.000 | 280.000 | -0.025 | 0.150 | 0.131 | 0.176 | 0.046 | -0.103 | -0.023 |
| 5760.000 | 246.000 | 0.040 | 0.087 | 0.137 | 0.105 | 0.021 | -0.082 | -0.083 |
| 10000.000 | 6675.000 | -0.066 | -0.028 | 0.017 | 0.031 | -0.024 | 0.043 | -0.025 |
| 24000.000 | 613.000 | -0.178 | -0.208 | -0.172 | -0.103 | -0.251 | -0.007 | -0.201 |
| 47000.000 | 53.000 | -0.423 | -0.518 | -0.149 | 0.510 | -0.154 | 0.222 | -0.465 |
## Per-band HPBL bin distance distribution
One table per band with ≥50 matched contacts. A band-specific effect here means HPBL belongs in the scorer for that band.
**222 MHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <200 | 956 | 273.500 | 210.000 |
| 200-500 | 1131 | 215.500 | 164.000 |
| 500-1000 | 1185 | 205.400 | 152.000 |
| 1000-1500 | 948 | 220.600 | 156.000 |
| 1500-2000 | 913 | 223.000 | 171.000 |
| >=2000 | 259 | 246.900 | 192.000 |
**432 MHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <200 | 1258 | 223.000 | 179.000 |
| 200-500 | 1370 | 198.600 | 143.500 |
| 500-1000 | 1413 | 183.700 | 131.000 |
| 1000-1500 | 1124 | 198.000 | 145.000 |
| 1500-2000 | 1129 | 187.100 | 142.000 |
| >=2000 | 289 | 169.200 | 126.000 |
**902 MHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 215 | 205.300 | 165.000 |
| 200-500 | 275 | 152.500 | 133.000 |
| 500-1000 | 302 | 166.500 | 123.000 |
| 1000-1500 | 259 | 175.300 | 137.000 |
| 1500-2000 | 222 | 175.900 | 152.000 |
| >=2000 | 44 | 158.700 | 131.000 |
**1.296 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 355 | 179.600 | 151.000 |
| 200-500 | 484 | 159.300 | 129.500 |
| 500-1000 | 479 | 145.600 | 107.000 |
| 1000-1500 | 376 | 154.800 | 131.000 |
| 1500-2000 | 380 | 153.600 | 122.000 |
| >=2000 | 72 | 143.700 | 108.000 |
**2.304 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 72 | 188.000 | 160.000 |
| 200-500 | 103 | 174.800 | 156.000 |
| 500-1000 | 155 | 150.300 | 122.000 |
| 1000-1500 | 121 | 151.600 | 136.000 |
| 1500-2000 | 97 | 161.000 | 147.000 |
| >=2000 | 16 | 144.800 | 140.500 |
**3.4 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 39 | 157.400 | 131.000 |
| 200-500 | 57 | 162.500 | 140.000 |
| 500-1000 | 64 | 153.500 | 111.000 |
| 1000-1500 | 67 | 150.500 | 136.000 |
| 1500-2000 | 39 | 150.300 | 122.000 |
| >=2000 | 14 | 157.400 | 140.500 |
**5.76 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 24 | 174.500 | 138.500 |
| 200-500 | 49 | 134.600 | 135.000 |
| 500-1000 | 62 | 118.000 | 103.000 |
| 1000-1500 | 54 | 125.000 | 133.000 |
| 1500-2000 | 45 | 114.600 | 122.000 |
| >=2000 | 12 | 142.700 | 140.500 |
**10 GHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <200 | 1573 | 213.900 | 178.800 |
| 200-500 | 2146 | 201.300 | 175.000 |
| 500-1000 | 1663 | 211.800 | 158.100 |
| 1000-1500 | 742 | 214.500 | 212.500 |
| 1500-2000 | 410 | 179.400 | 173.000 |
| >=2000 | 138 | 201.000 | 173.000 |
**24 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 115 | 120.600 | 110.000 |
| 200-500 | 236 | 95.400 | 99.000 |
| 500-1000 | 189 | 79.700 | 80.800 |
| 1000-1500 | 44 | 103.800 | 99.000 |
| 1500-2000 | 10 | 46.800 | 34.000 |
| >=2000 | 19 | 69.200 | 23.200 |
**47 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <200 | 9 | 111.200 | 85.300 |
| 200-500 | 19 | 82.200 | 58.500 |
| 500-1000 | 16 | 55.600 | 27.600 |
| 1000-1500 | 5 | 16.100 | 11.400 |
| >=2000 | 4 | 31.800 | 31.800 |
## Per-band pressure bin distance distribution
Surface pressure tends to proxy synoptic-scale stagnation, so we want to see longer distances under the 1015-1025 mb ridge at every band that ducts.
**222 MHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <990 | 1967 | 228.800 | 177.000 |
| 990-1000 | 1289 | 248.500 | 191.000 |
| 1000-1010 | 1263 | 235.400 | 186.000 |
| 1010-1020 | 778 | 183.300 | 141.000 |
| >=1020 | 95 | 159.300 | 126.000 |
**432 MHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <990 | 2548 | 189.600 | 140.000 |
| 990-1000 | 1580 | 223.200 | 173.000 |
| 1000-1010 | 1285 | 202.700 | 149.000 |
| 1010-1020 | 1050 | 172.700 | 129.000 |
| >=1020 | 120 | 144.800 | 84.500 |
**902 MHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 422 | 157.600 | 131.500 |
| 990-1000 | 400 | 206.400 | 157.000 |
| 1000-1010 | 241 | 189.200 | 147.000 |
| 1010-1020 | 223 | 138.800 | 143.000 |
| >=1020 | 31 | 72.000 | 32.000 |
**1.296 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 696 | 149.600 | 122.000 |
| 990-1000 | 579 | 183.700 | 145.000 |
| 1000-1010 | 451 | 161.600 | 108.000 |
| 1010-1020 | 385 | 130.500 | 114.000 |
| >=1020 | 35 | 112.700 | 112.000 |
**2.304 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 138 | 155.100 | 136.000 |
| 990-1000 | 201 | 168.200 | 141.000 |
| 1000-1010 | 97 | 178.500 | 117.000 |
| 1010-1020 | 116 | 147.200 | 152.000 |
| >=1020 | 12 | 126.300 | 137.000 |
**3.4 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 75 | 157.400 | 138.000 |
| 990-1000 | 134 | 156.800 | 135.000 |
| 1000-1010 | 34 | 179.700 | 131.000 |
| 1010-1020 | 28 | 122.200 | 124.000 |
| >=1020 | 9 | 114.600 | 131.000 |
**5.76 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 55 | 112.700 | 111.000 |
| 990-1000 | 120 | 131.600 | 119.500 |
| 1000-1010 | 27 | 128.000 | 100.000 |
| 1010-1020 | 36 | 146.900 | 152.000 |
| >=1020 | 8 | 123.000 | 135.000 |
**10 GHz:**
| bin | n | avg_km | p50_km |
|:----------|-----:|---------:|---------:|
| <990 | 3515 | 222.700 | 196.600 |
| 990-1000 | 1620 | 189.700 | 152.700 |
| 1000-1010 | 803 | 194.200 | 153.000 |
| 1010-1020 | 651 | 182.900 | 133.900 |
| >=1020 | 86 | 187.700 | 158.100 |
**24 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 282 | 97.300 | 98.700 |
| 990-1000 | 200 | 97.900 | 99.000 |
| 1000-1010 | 57 | 95.200 | 102.300 |
| 1010-1020 | 70 | 70.600 | 80.800 |
| >=1020 | 4 | 103.600 | 103.600 |
**47 GHz:**
| bin | n | avg_km | p50_km |
|:----------|----:|---------:|---------:|
| <990 | 19 | 80.500 | 43.900 |
| 990-1000 | 25 | 62.600 | 31.800 |
| 1000-1010 | 2 | 84.600 | 84.600 |
| 1010-1020 | 7 | 56.400 | 58.500 |
## Contact ↔ NARR per-band Pearson correlations (pre-2014 only, matched n=201)
Historical sanity check: if the HRRR-era signs and magnitudes survive on 30+ years of NARR reanalysis, the scoring factors are physical rather than HRRR-artefact. Diverging signs = flag it.
| band_mhz | n | rho_pr | rho_dpc | rho_pwat | rho_sref | rho_grad | rho_tc | rho_hpbl |
|-----------:|--------:|---------:|----------:|-----------:|-----------:|-----------:|---------:|-----------:|
| 10000.000 | 103.000 | -0.402 | -0.402 | -0.402 | -0.220 | -0.248 | -0.402 | -0.402 |
## NEXRAD composite reflectivity vs distance (matched n=20900)
Rain-attenuation sanity check — one table per band with ≥50 matched contacts. At rain-sensitive bands (24+ GHz) higher `max_dbz` should map to shorter contacts; at 10 GHz and below the effect should be barely detectable.
**10 GHz:**
| bin | n | avg_km | p50_km |
|:-----------------|------:|---------:|---------:|
| none (<5) | 14110 | 210.000 | 197.000 |
| drizzle (5-20) | 1284 | 200.900 | 199.300 |
| light (20-30) | 1736 | 211.800 | 197.100 |
| moderate (30-45) | 1609 | 230.900 | 215.200 |
| heavy (>=45) | 677 | 191.900 | 146.900 |
**24 GHz:**
| bin | n | avg_km | p50_km |
|:-----------------|----:|---------:|---------:|
| none (<5) | 869 | 90.900 | 83.700 |
| drizzle (5-20) | 74 | 108.300 | 123.200 |
| light (20-30) | 151 | 88.200 | 70.900 |
| moderate (30-45) | 76 | 109.300 | 122.400 |
| heavy (>=45) | 49 | 86.600 | 102.300 |
**47 GHz:**
| bin | n | avg_km | p50_km |
|:-----------------|----:|---------:|---------:|
| none (<5) | 186 | 52.400 | 56.000 |
| light (20-30) | 30 | 59.300 | 66.300 |
| moderate (30-45) | 9 | 75.100 | 81.600 |
| heavy (>=45) | 15 | 71.400 | 82.800 |
## hrrr_native_profiles.best_duct_band_ghz vs distance (matched n=57669)
Validates the 1.15× boost in `Scorer.score_refractivity/4`: contacts where the native duct supports the target band should run longer than those where it does not. One table per band with ≥50 matched contacts.
**10 GHz:**
| bin | n | avg_km | p50_km |
|:----------|------:|---------:|---------:|
| none | 45395 | 212.700 | 195.000 |
| <5 GHz | 7151 | 200.500 | 171.800 |
| 5-10 GHz | 444 | 200.600 | 174.200 |
| 10-24 GHz | 146 | 203.300 | 225.200 |
| 24-47 GHz | 5 | 177.200 | 133.300 |
| >=47 GHz | 23 | 147.500 | 131.500 |
**24 GHz:**
| bin | n | avg_km | p50_km |
|:----------|---------:|---------:|---------:|
| none | 3188.000 | 95.000 | 92.600 |
| <5 GHz | 477.000 | 94.900 | 83.900 |
| 5-10 GHz | 36.000 | 97.500 | 113.700 |
| 10-24 GHz | 2.000 | 44.700 | 44.700 |
| >=47 GHz | 6.000 | 131.500 | 131.500 |
**47 GHz:**
| bin | n | avg_km | p50_km |
|:---------|--------:|---------:|---------:|
| none | 552.000 | 64.300 | 65.400 |
| <5 GHz | 131.000 | 56.800 | 61.800 |
| 5-10 GHz | 6.000 | 98.100 | 113.700 |
**75 GHz:**
| bin | n | avg_km | p50_km |
|:-------|-------:|---------:|---------:|
| none | 76.000 | 54.800 | 36.700 |
| <5 GHz | 15.000 | 13.900 | 14.600 |
## Commercial-link rx_power degradation vs contemporaneous DFW contacts (matched n=0)
_No DFW-zone contacts fall inside the commercial_samples date window yet. Expected to stay empty until the Aug/Sep contest season produces contacts that overlap with live SNMP polling._

View file

@ -7,8 +7,13 @@ defmodule Microwaveprop.Propagation.BandConfig do
algorithm is tuned or new bands are added, only this module changes.
"""
# Recalibrated 2026-04-11 via gradient descent on 5000 QSOs (loss 0.42 → 0.12).
# Key shifts: rain +70%, season +39%, wind +60%; time_of_day -50%, pressure -31%.
# Default weight vector. Originally fit by gradient descent on 5,000 QSOs
# 2026-04-11 (loss 0.42 → 0.12) and retained as the 10 GHz prior. Bands
# with ≥200 HRRR-matched contacts in the 2026-04-18 full-corpus
# correlation analysis carry per-band `:weights` overrides in
# `@band_configs` — see `algo.md` Part 2d. Bands without enough data
# (50/144 MHz with 0 contacts, 47+ GHz with <200 matches) inherit this
# vector.
@weights %{
humidity: 0.1243,
time_of_day: 0.0496,
@ -122,6 +127,21 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.0,
rain_alpha: 1.0,
# Per-band weights from 2026-04-18 full-corpus correlation (n=5,392
# matched). Moisture (pwat, humidity) dominate; rain and time-of-day
# suppressed vs the 10 GHz baseline.
weights: %{
humidity: 0.1593,
time_of_day: 0.0350,
td_depression: 0.1250,
refractivity: 0.1401,
sky: 0.0706,
season: 0.1276,
wind: 0.0706,
rain: 0.0120,
pwat: 0.1916,
pressure: 0.0681
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -150,6 +170,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.0,
rain_alpha: 1.0,
# Per-band weights from 2026-04-18 full-corpus correlation (n=6,583).
weights: %{
humidity: 0.2061,
time_of_day: 0.0329,
td_depression: 0.1200,
refractivity: 0.1186,
sky: 0.0663,
season: 0.1106,
wind: 0.0663,
rain: 0.0113,
pwat: 0.1870,
pressure: 0.0809
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -178,6 +211,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.000,
rain_alpha: 1.0,
# Per-band weights from 2026-04-18 full-corpus correlation (n=1,317).
weights: %{
humidity: 0.2201,
time_of_day: 0.0437,
td_depression: 0.1102,
refractivity: 0.0888,
sky: 0.0783,
season: 0.1197,
wind: 0.0783,
rain: 0.0133,
pwat: 0.1635,
pressure: 0.0839
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -206,6 +252,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.000,
rain_alpha: 1.0,
# Per-band weights from 2026-04-18 full-corpus correlation (n=2,146).
weights: %{
humidity: 0.2131,
time_of_day: 0.0494,
td_depression: 0.0898,
refractivity: 0.1392,
sky: 0.0797,
season: 0.1108,
wind: 0.0797,
rain: 0.0136,
pwat: 0.1678,
pressure: 0.0568
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -234,6 +293,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.001,
rain_alpha: 1.15,
# Per-band weights from 2026-04-18 full-corpus correlation (n=564).
weights: %{
humidity: 0.1941,
time_of_day: 0.0387,
td_depression: 0.1385,
refractivity: 0.1638,
sky: 0.0625,
season: 0.0868,
wind: 0.0625,
rain: 0.0336,
pwat: 0.1762,
pressure: 0.0432
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -262,6 +334,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.002,
rain_alpha: 1.20,
# Per-band weights from 2026-04-18 full-corpus correlation (n=280).
weights: %{
humidity: 0.1996,
time_of_day: 0.0398,
td_depression: 0.1251,
refractivity: 0.1310,
sky: 0.0642,
season: 0.0893,
wind: 0.0642,
rain: 0.0489,
pwat: 0.1811,
pressure: 0.0568
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -290,6 +375,19 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 0.0,
rain_k: 0.005,
rain_alpha: 1.25,
# Per-band weights from 2026-04-18 full-corpus correlation (n=246).
weights: %{
humidity: 0.1829,
time_of_day: 0.0423,
td_depression: 0.1205,
refractivity: 0.0881,
sky: 0.0683,
season: 0.0949,
wind: 0.0683,
rain: 0.0822,
pwat: 0.1925,
pressure: 0.0602
},
seasonal_base: %{
1 => 38,
2 => 40,
@ -346,6 +444,23 @@ defmodule Microwaveprop.Propagation.BandConfig do
humidity_penalty: 1.6,
rain_k: 0.070,
rain_alpha: 1.07,
# Per-band weights from 2026-04-18 full-corpus correlation (n=613).
# Rain climbs sharply (sqrt-dampened rain_k ratio = √7 ≈ 2.6× vs 10 G),
# refractivity and pwat hold up, td_depression falls out because the
# temperature signal alone is near-zero — moisture already carries it
# via :harmful humidity.
weights: %{
humidity: 0.1481,
time_of_day: 0.0532,
td_depression: 0.0360,
refractivity: 0.1250,
sky: 0.0477,
season: 0.0729,
wind: 0.0477,
rain: 0.2147,
pwat: 0.1344,
pressure: 0.1203
},
seasonal_base: %{
1 => 88,
2 => 84,
@ -740,10 +855,23 @@ defmodule Microwaveprop.Propagation.BandConfig do
Enum.map(all_bands(), fn band -> {band.label, to_string(band.freq_mhz)} end)
end
@doc "Returns the scoring weights map. All 10 values sum to 1.0."
@doc "Returns the default scoring weights map. All 10 values sum to 1.0."
@spec weights() :: map()
def weights, do: @weights
@doc """
Returns the scoring weights for a specific band configuration.
Bands with enough contacts to support a stable gradient-descent fit
carry a `:weights` override map. Bands without it fall back to the
global defaults. `nil` is accepted so callers that don't know the band
can ask for defaults without an extra guard.
"""
@spec weights(map() | nil) :: map()
def weights(nil), do: @weights
def weights(%{weights: override}) when is_map(override), do: override
def weights(%{}), do: @weights
@doc "Returns the 12-element sunrise hour table (Jan-Dec, local time)."
@spec sunrise_table() :: [float()]
def sunrise_table, do: @sunrise_table

View file

@ -39,6 +39,9 @@ defmodule Microwaveprop.Propagation.Recalibrator do
* `:sample_size` - max contacts to load (default: 5000)
* `:learning_rate` - gradient descent step size (default: 0.01)
* `:epochs` - number of training iterations (default: 2000)
* `:band_mhz` - restrict contacts + factor scoring to a specific band
(default: unrestricted; factor scoring uses 10 GHz config as it did
historically)
## Returns
@ -49,15 +52,17 @@ defmodule Microwaveprop.Propagation.Recalibrator do
sample_size = Keyword.get(opts, :sample_size, 5000)
learning_rate = Keyword.get(opts, :learning_rate, 0.01)
epochs = Keyword.get(opts, :epochs, 2000)
band_mhz = Keyword.get(opts, :band_mhz)
Logger.info("Recalibrator: loading contacts and computing factor vectors...")
band_label = if band_mhz, do: "#{band_mhz} MHz", else: "all bands"
Logger.info("Recalibrator: loading contacts for #{band_label}, computing factor vectors...")
# Load contacts with HRRR profiles
{positives, contacts} = load_positive_samples(sample_size)
{positives, contacts} = load_positive_samples(sample_size, band_mhz)
Logger.info("Recalibrator: #{length(positives)} positive samples from #{length(contacts)} contacts")
# Generate matched negatives
negatives = generate_negative_samples(contacts, length(positives))
negatives = generate_negative_samples(contacts, length(positives), band_mhz)
Logger.info("Recalibrator: #{length(negatives)} negative samples")
if positives == [] or negatives == [] do
@ -82,8 +87,19 @@ defmodule Microwaveprop.Propagation.Recalibrator do
Returns a list of 10 floats, each in [0, 100].
"""
@spec compute_factors(map(), DateTime.t()) :: [number()]
def compute_factors(profile, timestamp) do
band_config = BandConfig.get(10_000)
def compute_factors(profile, timestamp), do: compute_factors(profile, timestamp, 10_000)
@doc """
Band-aware variant of `compute_factors/2`.
Factor scoring reaches into band-specific config (humidity direction,
seasonal table, rain coefficients, etc.) so positives and negatives for
per-band fits are scored through the exact physics each band uses at
runtime. `band_mhz` must be a supported band in `BandConfig`.
"""
@spec compute_factors(map(), DateTime.t(), pos_integer()) :: [number()]
def compute_factors(profile, timestamp, band_mhz) do
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
temp_f = Scorer.c_to_f(profile.surface_temp_c)
dewpoint_f = Scorer.c_to_f(profile.surface_dewpoint_c)
@ -140,43 +156,55 @@ defmodule Microwaveprop.Propagation.Recalibrator do
# ── Private ──────────────────────────────────────────────────────
defp load_positive_samples(sample_size) do
contacts =
defp load_positive_samples(sample_size, band_mhz) do
base_query =
Contact
|> where([c], not is_nil(c.pos1))
|> where([c], c.distance_km < 3000)
|> order_by([c], desc: c.qso_timestamp)
|> limit(^sample_size)
|> Repo.all()
query =
if band_mhz do
from c in base_query, where: c.band == ^Decimal.new(band_mhz)
else
base_query
end
contacts = Repo.all(query)
factor_band = band_mhz || 10_000
factor_vectors =
contacts
|> Enum.map(&contact_to_factors/1)
|> Enum.map(&contact_to_factors(&1, factor_band))
|> Enum.reject(&is_nil/1)
{factor_vectors, contacts}
end
defp contact_to_factors(%Contact{pos1: pos, qso_timestamp: ts}) do
defp contact_to_factors(%Contact{pos1: pos, qso_timestamp: ts}, band_mhz) do
lat = pos["lat"]
lon = pos["lon"]
if lat && lon do
case Weather.find_nearest_hrrr(lat, lon, ts) do
nil -> nil
profile -> compute_factors(profile, ts)
profile -> compute_factors(profile, ts, band_mhz)
end
end
end
defp generate_negative_samples(contacts, n) do
defp generate_negative_samples(contacts, n, band_mhz) do
baselines = Backtest.random_baseline(n, sample_size: length(contacts))
factor_band = band_mhz || 10_000
baselines
|> Enum.map(fn {lat, lon, time} ->
case Weather.find_nearest_hrrr(lat, lon, time) do
nil -> nil
profile -> compute_factors(profile, time)
profile -> compute_factors(profile, time, factor_band)
end
end)
|> Enum.reject(&is_nil/1)

View file

@ -487,7 +487,7 @@ defmodule Microwaveprop.Propagation.Scorer do
pressure: score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)
}
weights = BandConfig.weights()
weights = BandConfig.weights(band_config)
weighted_sum =
Enum.reduce(factors, 0.0, fn {factor, score}, acc ->

View file

@ -1938,7 +1938,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{nil, [], nil}
else
result = Scorer.composite_score(conditions, band_config)
weights = BandConfig.weights()
weights = BandConfig.weights(band_config)
factor_rows =
[

View file

@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Per-band weight derivation from the full-corpus correlation analysis.
Reads Pearson correlations that `recalibrate_algo.py` wrote into the DB
and emits a `@band_weight_overrides` Elixir map ready to drop into
`lib/microwaveprop/propagation/band_config.ex`.
The derivation rule is deliberately simple and legible, because the
correlations are noisy (|r| typically 0.050.25) and we'd rather preserve
the globally-fit default weights as a prior than over-fit per-band.
Rule:
1. Start from the default weight vector (gradient-descent fit, global).
2. Compute a per-factor "relative signal strength" ratio
s_band,factor = |r_band,factor| / |r_10GHz,factor|
with a small ε floor so zero-correlation factors don't collapse.
3. Clamp the ratio to [0.4, 2.5] noisy per-band correlations shouldn't
move a factor more than 2.5× or less than 0.4× the prior.
4. Factors we don't have direct correlations for (sky, wind, rain,
season, time_of_day) carry a physics-informed multiplier per band:
* rain scales with `rain_k` (ITU-R P.838 specific attenuation
coefficient) normalized to 10 GHz's rain_k.
* season scales up at VHF/low-UHF where Es-like physics amplifies
monthly variation (we don't model Es directly).
* time_of_day scales with frequency (Finding 8 in algo.md).
* sky/wind stay flat no per-band evidence either direction.
5. Multiply default weights by the multipliers, then normalize to 1.0.
The output goes to stdout as Elixir source ready to paste into BandConfig.
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
try:
import psycopg
from psycopg.rows import dict_row
except ImportError:
sys.exit("psycopg is required: pip install 'psycopg[binary]>=3.1'")
# Default weights (must match `@weights` in band_config.ex). These are the
# globally-fit gradient-descent priors — the per-band deltas modulate them.
DEFAULT_WEIGHTS = {
"rain": 0.1362,
"humidity": 0.1243,
"pwat": 0.1128,
"season": 0.1112,
"refractivity": 0.1049,
"pressure": 0.1032,
"td_depression": 0.0978,
"sky": 0.08,
"wind": 0.08,
"time_of_day": 0.0496,
}
# Correlation-fed factor → source-field mapping. These factors get data-
# driven per-band multipliers; the rest get physics-informed multipliers.
FACTOR_SOURCE = {
"humidity": "dpc", # dewpoint carries the moisture signal
"td_depression": "tc", # temperature dominates T-Td variance
"refractivity": "grad", # refractivity gradient
"pressure": "pr",
"pwat": "pwat",
}
# ITU-R P.838-3 rain_k values (per BandConfig). Used for `rain` weight
# scaling — rain attenuation grows by ~4 orders of magnitude from VHF
# to sub-mm, so `rain` weight should track that.
BAND_RAIN_K = {
50: 0.0, 144: 0.0, 222: 0.0, 432: 0.0,
902: 0.0, 1_296: 0.0,
2_304: 0.001, 3_400: 0.002, 5_760: 0.005,
10_000: 0.010, 24_000: 0.070, 47_000: 0.187,
68_000: 0.310, 75_000: 0.345, 122_000: 0.498,
134_000: 0.520, 142_000: 0.530, 145_000: 0.535,
241_000: 0.550, 288_000: 0.560, 322_000: 0.570,
403_000: 0.580, 411_000: 0.580,
}
# Season weight multiplier by band. VHF has large seasonal swings driven
# by tropo-mixing physics (summer peak). Microwave seasonal swings are
# contest-schedule artefacts but the physics (summer convective mixing
# hurting 24+ GHz) still applies. No per-band evidence in the correlation
# report so we use physics priors.
BAND_SEASON_MULT = {
50: 1.6, 144: 1.4, 222: 1.3, 432: 1.2,
902: 1.1, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.1, 47_000: 1.2,
68_000: 1.3, 75_000: 1.4, 122_000: 1.5, 134_000: 1.5,
142_000: 1.5, 145_000: 1.5, 241_000: 1.6, 288_000: 1.6,
322_000: 1.7, 403_000: 1.7, 411_000: 1.7,
}
# Time-of-day multiplier by band (Finding 8 in algo.md — effect scales
# with frequency). 10 GHz is the baseline.
BAND_TOD_MULT = {
50: 0.6, 144: 0.7, 222: 0.8, 432: 0.8,
902: 0.9, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.8, 47_000: 2.5,
68_000: 3.5, 75_000: 4.0,
122_000: 5.0, 134_000: 5.0, 142_000: 5.0, 145_000: 5.0,
241_000: 5.0, 288_000: 5.0, 322_000: 5.0, 403_000: 5.0,
411_000: 5.0,
}
# EPS sets an absolute floor on |r| so two near-zero correlations don't
# produce a giant ratio. Set at the noise floor we can reliably distinguish
# from zero given typical n. Tightened along with the clamp so small
# corpora don't drag the weights around.
EPS = 0.05
CLAMP_LO = 0.5
CLAMP_HI = 2.0
MIN_N_FOR_FIT = 200 # bands with fewer matched contacts use physics only
@dataclass
class BandCorr:
band_mhz: int
n: int
rho: dict[str, float]
PER_BAND_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
h.surface_temp_c AS tc, h.surface_dewpoint_c AS dpc,
h.surface_pressure_mb AS pr, h.pwat_mm AS pwat,
h.min_refractivity_gradient AS grad
FROM contacts c
JOIN hrrr_profiles h
ON h.lat BETWEEN (c.pos1->>'lat')::float - 0.07
AND (c.pos1->>'lat')::float + 0.07
AND h.lon BETWEEN (c.pos1->>'lon')::float - 0.07
AND (c.pos1->>'lon')::float + 0.07
AND h.valid_time BETWEEN c.qso_timestamp - INTERVAL '1 hour'
AND c.qso_timestamp + INTERVAL '1 hour'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000
AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
)
SELECT band,
count(*) AS n,
CORR(dist, tc) AS rho_tc,
CORR(dist, dpc) AS rho_dpc,
CORR(dist, pr) AS rho_pr,
CORR(dist, pwat) AS rho_pwat,
CORR(dist, grad) AS rho_grad
FROM joined
WHERE band >= 50
GROUP BY band
HAVING count(*) >= 50
ORDER BY band;
"""
def load_correlations(dsn: str) -> dict[int, BandCorr]:
with psycopg.connect(dsn) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SET statement_timeout = '30min'")
cur.execute(PER_BAND_SQL)
rows = cur.fetchall()
out = {}
for r in rows:
out[int(r["band"])] = BandCorr(
band_mhz=int(r["band"]),
n=int(r["n"]),
rho={
"tc": r["rho_tc"] or 0.0,
"dpc": r["rho_dpc"] or 0.0,
"pr": r["rho_pr"] or 0.0,
"pwat": r["rho_pwat"] or 0.0,
"grad": r["rho_grad"] or 0.0,
},
)
return out
def derive_weights(
band_mhz: int, corrs: dict[int, BandCorr]
) -> dict[str, float] | None:
"""Returns a normalized weight map or None if the band should use defaults."""
band_corr = corrs.get(band_mhz)
ref_corr = corrs.get(10_000)
if band_corr is None or band_corr.n < MIN_N_FOR_FIT or ref_corr is None:
return None
multipliers = {}
# Data-driven multipliers for correlation-backed factors.
for factor, field in FACTOR_SOURCE.items():
band_r = abs(band_corr.rho[field]) + EPS
ref_r = abs(ref_corr.rho[field]) + EPS
ratio = band_r / ref_r
multipliers[factor] = max(CLAMP_LO, min(CLAMP_HI, ratio))
# Physics-driven multipliers for the rest.
# Rain: weight tracks rain_k but with sqrt-dampening. A 7× rain_k jump
# (10→24 GHz) should not become a 7× weight jump — the score itself
# already scales with rain_k via ITU-R P.838 in `score_rain`, so the
# weight only needs to capture how often rain drives the outcome.
rain_k = BAND_RAIN_K.get(band_mhz, 0.010)
rain_k_10g = BAND_RAIN_K[10_000]
if rain_k > 0:
ratio = rain_k / rain_k_10g
rain_mult = max(0.2, min(3.0, ratio**0.5))
else:
rain_mult = 0.1
multipliers["rain"] = rain_mult
multipliers["season"] = BAND_SEASON_MULT.get(band_mhz, 1.0)
multipliers["time_of_day"] = BAND_TOD_MULT.get(band_mhz, 1.0)
multipliers["sky"] = 1.0
multipliers["wind"] = 1.0
# Apply and normalize.
raw = {k: DEFAULT_WEIGHTS[k] * multipliers[k] for k in DEFAULT_WEIGHTS}
total = sum(raw.values())
return {k: round(v / total, 4) for k, v in raw.items()}
def format_elixir(band_mhz: int, weights: dict[str, float]) -> str:
# Preserve the factor ordering used in band_config.ex comments.
order = [
"humidity", "time_of_day", "td_depression", "refractivity",
"sky", "season", "wind", "rain", "pwat", "pressure",
]
lines = [f" {k}: {weights[k]:.4f}" for k in order]
inner = ",\n".join(lines)
return f" weights: %{{\n{inner}\n }}"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsn", default=os.environ.get("PROP_DB_URL"))
args = parser.parse_args()
if not args.dsn:
print("error: PROP_DB_URL not set and --dsn not given", file=sys.stderr)
return 2
print(f"loading correlations from {args.dsn}", file=sys.stderr)
corrs = load_correlations(args.dsn)
print(
f"correlations available for {len(corrs)} bands: "
f"{sorted(corrs.keys())}",
file=sys.stderr,
)
# Every band in BandConfig — we emit an entry for each so the reader
# sees at a glance which bands got fit and which inherited defaults.
all_bands = sorted(set(BAND_RAIN_K.keys()) | set(corrs.keys()))
sections = []
for band in all_bands:
weights = derive_weights(band, corrs)
band_corr = corrs.get(band)
n = band_corr.n if band_corr else 0
if weights is None:
sections.append(
f"# {band} MHz — n={n}, uses default @weights (insufficient signal)"
)
else:
sections.append(
f"# {band} MHz — n={n}, derived from corpus correlations\n"
f"{format_elixir(band, weights)}"
)
print("\n\n".join(sections))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -4,14 +4,19 @@ Algo recalibration analysis runner.
Connects to the prod (or any) Postgres, joins contacts to the nearest HRRR
grid profile within ±0.07° / ±1h (matching `Weather.find_nearest_hrrr/3`),
and produces a Markdown report covering:
falls back to `narr_profiles` (NCEP NARR, 32 km / 3-hourly) for pre-2014
contacts where HRRR doesn't reach, and produces a Markdown report covering:
* row counts and date ranges for the key tables
* per-band contact distribution including bands that BandConfig hasn't seen
* row counts and date ranges for the key tables (HRRR + NARR + ...)
* per-band contact distribution from 50 MHz up (VHF/UHF/microwave)
* monthly sounding ducting refresh
* native HRRR profile duct stats
* per-band Pearson correlations of contact distance vs HRRR fields
* binned distance distributions for HPBL and pressure at 10 GHz
* per-band Pearson correlations vs NARR fields (historical sanity check)
* per-band binned distance distributions for HPBL, pressure, NEXRAD,
and native-profile best duct band every band with 50 matched
contacts gets its own bin table so we're not hard-coding the model
to 10/24 GHz behaviour
* NEXRAD composite reflectivity vs contact distance (rain-scoring check)
* hrrr_native_profiles.best_duct_band_ghz as a distance discriminator
* commercial-link rx_power degradation vs contemporaneous DFW contacts
@ -35,7 +40,6 @@ import argparse
import datetime as dt
import os
import sys
import textwrap
from pathlib import Path
try:
@ -66,31 +70,22 @@ Statement timeout: {stmt_timeout}
# ─── SQL fragments ──────────────────────────────────────────────────────────
ROW_COUNTS_SQL = """
SELECT 'contacts' tbl, count(*)::bigint n,
min(qso_timestamp)::date lo, max(qso_timestamp)::date hi FROM contacts
UNION ALL SELECT 'surface_observations', count(*),
min(observed_at)::date, max(observed_at)::date FROM surface_observations
UNION ALL SELECT 'soundings', count(*),
min(observed_at)::date, max(observed_at)::date FROM soundings
UNION ALL SELECT 'hrrr_profiles', count(*),
min(valid_time)::date, max(valid_time)::date FROM hrrr_profiles
UNION ALL SELECT 'hrrr_native_profiles', count(*),
min(valid_time)::date, max(valid_time)::date FROM hrrr_native_profiles
UNION ALL SELECT 'era5_profiles', count(*),
min(valid_time)::date, max(valid_time)::date FROM era5_profiles
UNION ALL SELECT 'iemre_observations', count(*), NULL::date, NULL::date
FROM iemre_observations
UNION ALL SELECT 'nexrad_observations', count(*),
min(observed_at)::date, max(observed_at)::date FROM nexrad_observations
UNION ALL SELECT 'rtma_observations', count(*),
min(valid_time)::date, max(valid_time)::date FROM rtma_observations
UNION ALL SELECT 'terrain_profiles', count(*), NULL::date, NULL::date
FROM terrain_profiles
UNION ALL SELECT 'propagation_scores', count(*),
min(valid_time)::date, max(valid_time)::date FROM propagation_scores
ORDER BY tbl;
"""
# (table, time_column). `time_column = None` means just count rows without
# min/max range. Tables not present in the target DB are silently skipped —
# local dev often trails prod (e.g. `propagation_scores` moved to binary files).
ROW_COUNT_SOURCES = [
("contacts", "qso_timestamp"),
("surface_observations", "observed_at"),
("soundings", "observed_at"),
("hrrr_profiles", "valid_time"),
("hrrr_native_profiles", "valid_time"),
("narr_profiles", "valid_time"),
("iemre_observations", None),
("nexrad_observations", "observed_at"),
("rtma_observations", "valid_time"),
("terrain_profiles", None),
("propagation_scores", "valid_time"),
]
CONTACTS_BY_BAND_SQL = """
SELECT band::int AS band_mhz, count(*) AS contacts,
@ -98,7 +93,7 @@ SELECT band::int AS band_mhz, count(*) AS contacts,
MAX(distance_km::numeric) AS max_km
FROM contacts
WHERE pos1 IS NOT NULL AND distance_km IS NOT NULL
AND distance_km < 3000 AND flagged_invalid = false AND band >= 902
AND distance_km < 3000 AND flagged_invalid = false AND band >= 50
GROUP BY 1 ORDER BY 1;
"""
@ -128,7 +123,7 @@ SELECT
FROM hrrr_native_profiles GROUP BY 1 ORDER BY 1;
"""
# Per-band contact ↔ HRRR Pearson correlations.
# Per-band contact ↔ HRRR Pearson correlations (2014-10 onward).
PER_BAND_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
@ -151,6 +146,34 @@ WITH joined AS (
SELECT * FROM joined;
"""
# Per-band contact ↔ NARR Pearson correlations (pre-2014-10).
# NARR is 32 km Lambert conformal / 3-hourly, so both tolerances are wider
# than the HRRR join above. Matches `NarrClient.in_coverage?/1` — valid up
# through but excluding 2014-10-02 (HRRR takes over after that).
PER_BAND_NARR_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
n.surface_temp_c AS tc, n.surface_dewpoint_c AS dpc,
n.surface_pressure_mb AS pr, n.pwat_mm AS pwat,
n.min_refractivity_gradient AS grad,
n.surface_refractivity AS sref, n.hpbl_m AS hpbl
FROM contacts c
JOIN narr_profiles n
ON n.lat BETWEEN (c.pos1->>'lat')::float - 0.25
AND (c.pos1->>'lat')::float + 0.25
AND n.lon BETWEEN (c.pos1->>'lon')::float - 0.25
AND (c.pos1->>'lon')::float + 0.25
AND n.valid_time BETWEEN c.qso_timestamp - INTERVAL '2 hours'
AND c.qso_timestamp + INTERVAL '2 hours'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000
AND c.flagged_invalid = false
AND c.qso_timestamp < TIMESTAMP '2014-10-02'
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM n.valid_time - c.qso_timestamp))
)
SELECT * FROM joined;
"""
# NEXRAD ↔ contact join. Very tight spatial tolerance because nexrad_observations
# is a per-contact enrichment table, not a grid sample.
NEXRAD_JOIN_SQL = """
@ -250,12 +273,17 @@ WHERE baseline_rx IS NOT NULL;
# Empty-table / data-quality smoke checks.
DATA_GAP_SQL = """
SELECT
(SELECT count(*) FROM era5_profiles) AS era5_rows,
(SELECT count(*) FROM narr_profiles) AS narr_rows,
(SELECT count(*) FROM narr_profiles WHERE valid_time < '2014-10-02') AS narr_pre2014_rows,
(SELECT count(*) FROM hrrr_climatology) AS hrrr_climatology_rows,
(SELECT count(*) FROM rtma_observations) AS rtma_rows,
(SELECT count(*) FROM metar_5min_observations) AS metar5_rows,
(SELECT count(*) FROM contacts WHERE hrrr_status = 'complete') AS hrrr_complete_contacts,
(SELECT count(*) FROM contacts WHERE hrrr_status != 'complete') AS hrrr_pending_contacts;
(SELECT count(*) FROM contacts WHERE hrrr_status != 'complete') AS hrrr_pending_contacts,
(SELECT count(*) FROM contacts
WHERE qso_timestamp < '2014-10-02'
AND pos1 IS NOT NULL AND distance_km < 3000
AND flagged_invalid = false) AS pre2014_contacts;
"""
@ -275,6 +303,50 @@ def md_table(df: pd.DataFrame) -> str:
return df.to_markdown(index=False, floatfmt=".3f") + "\n"
def fetch_row_counts(conn) -> pd.DataFrame:
"""Per-table counts + date ranges, tolerant of missing tables."""
rows = []
for tbl, time_col in ROW_COUNT_SOURCES:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT to_regclass(%s) AS oid", (f"public.{tbl}",))
present = cur.fetchone()["oid"] is not None
if not present:
rows.append({"tbl": tbl, "n": None, "lo": None, "hi": None})
continue
if time_col:
sql = (
f"SELECT '{tbl}' tbl, count(*)::bigint n, "
f"min({time_col})::date lo, max({time_col})::date hi FROM {tbl}"
)
else:
sql = (
f"SELECT '{tbl}' tbl, count(*)::bigint n, "
f"NULL::date lo, NULL::date hi FROM {tbl}"
)
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
rows.append(cur.fetchone())
return pd.DataFrame(rows).sort_values("tbl")
def band_label(band_mhz: int) -> str:
"""Human-readable band label, matching `BandConfig` conventions."""
if band_mhz < 1_000:
return f"{band_mhz} MHz"
ghz = band_mhz / 1_000
if ghz == int(ghz):
return f"{int(ghz)} GHz"
return f"{ghz:g} GHz"
def bands_with_samples(df: pd.DataFrame, min_samples: int = 50) -> list[int]:
"""Every band in `df` with at least `min_samples` rows, sorted ascending."""
if df.empty or "band" not in df.columns:
return []
counts = df.groupby("band").size()
return sorted(int(b) for b, n in counts.items() if n >= min_samples)
def correlations_per_band(df: pd.DataFrame, min_samples: int = 30) -> pd.DataFrame:
fields = ["pr", "dpc", "pwat", "sref", "grad", "tc", "hpbl"]
rows = []
@ -485,7 +557,7 @@ def main() -> int:
print("• row counts", file=sys.stderr)
sections.append("\n## Row counts and date ranges\n")
sections.append(md_table(fetch_df(conn, ROW_COUNTS_SQL)))
sections.append(md_table(fetch_row_counts(conn)))
print("• data gaps", file=sys.stderr)
gaps = fetch_df(conn, DATA_GAP_SQL).iloc[0].to_dict()
@ -495,7 +567,7 @@ def main() -> int:
)
print("• contacts by band", file=sys.stderr)
sections.append("\n## Contacts by band (≥902 MHz)\n")
sections.append("\n## Contacts by band (≥50 MHz)\n")
sections.append(md_table(fetch_df(conn, CONTACTS_BY_BAND_SQL)))
print("• sounding monthly", file=sys.stderr)
@ -508,9 +580,12 @@ def main() -> int:
)
sections.append(md_table(fetch_df(conn, NATIVE_DUCT_SQL)))
print("• per-band correlations (this is the slow one)", file=sys.stderr)
print("• per-band correlations — HRRR (this is the slow one)", file=sys.stderr)
joined = fetch_df(conn, PER_BAND_JOIN_SQL)
print("• per-band correlations — NARR (pre-2014)", file=sys.stderr)
joined_narr = fetch_df(conn, PER_BAND_NARR_JOIN_SQL)
print("• NEXRAD ↔ contacts", file=sys.stderr)
nexrad = fetch_df(conn, NEXRAD_JOIN_SQL)
@ -529,11 +604,47 @@ def main() -> int:
)
sections.append(md_table(correlations_per_band(joined)))
sections.append("\n## 10 GHz: HPBL bin distance distribution\n")
sections.append(md_table(hpbl_bins(joined)))
sections.append(
"\n## Per-band HPBL bin distance distribution\n\n"
"One table per band with ≥50 matched contacts. A band-specific "
"effect here means HPBL belongs in the scorer for that band.\n"
)
for band in bands_with_samples(joined):
table = hpbl_bins(joined, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append("\n## 10 GHz: pressure bin distance distribution\n")
sections.append(md_table(pressure_bins(joined)))
sections.append(
"\n## Per-band pressure bin distance distribution\n\n"
"Surface pressure tends to proxy synoptic-scale stagnation, so we "
"want to see longer distances under the 1015-1025 mb ridge at every "
"band that ducts.\n"
)
for band in bands_with_samples(joined):
table = pressure_bins(joined, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
if joined_narr.empty:
sections.append(
"\n## Contact ↔ NARR per-band Pearson correlations\n\n"
"_No pre-2014 contacts ↔ NARR matches were found. Either the NARR "
"backfill has not run for this corpus yet or there are no "
"pre-2014 contacts with `pos1` set._\n"
)
else:
sections.append(
f"\n## Contact ↔ NARR per-band Pearson correlations "
f"(pre-2014 only, matched n={len(joined_narr)})\n\n"
"Historical sanity check: if the HRRR-era signs and magnitudes "
"survive on 30+ years of NARR reanalysis, the scoring factors are "
"physical rather than HRRR-artefact. Diverging signs = flag it.\n"
)
sections.append(md_table(correlations_per_band(joined_narr)))
sections.append(
f"\n## NEXRAD composite reflectivity vs distance "
@ -543,14 +654,17 @@ def main() -> int:
sections.append("_No contacts ↔ NEXRAD matches were found._\n")
else:
sections.append(
"Sanity check for the rain-attenuation direction: at rain-sensitive "
"bands higher `max_dbz` should map to shorter contacts. At 10 GHz "
"the effect should be barely detectable.\n\n"
"Rain-attenuation sanity check — one table per band with ≥50 "
"matched contacts. At rain-sensitive bands (24+ GHz) higher "
"`max_dbz` should map to shorter contacts; at 10 GHz and below "
"the effect should be barely detectable.\n"
)
sections.append("**10 GHz:**\n")
sections.append(md_table(nexrad_bins(nexrad, 10_000)))
sections.append("\n**24 GHz:**\n")
sections.append(md_table(nexrad_bins(nexrad, 24_000)))
for band in bands_with_samples(nexrad):
table = nexrad_bins(nexrad, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append(
f"\n## hrrr_native_profiles.best_duct_band_ghz vs distance "
@ -562,14 +676,15 @@ def main() -> int:
sections.append(
"Validates the 1.15× boost in `Scorer.score_refractivity/4`: "
"contacts where the native duct supports the target band should "
"run longer than those where it does not.\n\n"
"run longer than those where it does not. One table per band with "
"≥50 matched contacts.\n"
)
sections.append("**10 GHz:**\n")
sections.append(md_table(native_duct_bins(native, 10_000)))
sections.append("\n**24 GHz:**\n")
sections.append(md_table(native_duct_bins(native, 24_000)))
sections.append("\n**47 GHz:**\n")
sections.append(md_table(native_duct_bins(native, 47_000)))
for band in bands_with_samples(native):
table = native_duct_bins(native, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append(
f"\n## Commercial-link rx_power degradation vs contemporaneous DFW contacts "

View file

@ -243,6 +243,23 @@ defmodule Microwaveprop.Propagation.BandConfigTest do
end
end
describe "weights/1 — band-aware" do
test "returns the default weights when the band has no override" do
# 50 MHz has 0 contacts in the corpus → no per-band fit → default weights.
band = BandConfig.get(50)
assert BandConfig.weights(band) == BandConfig.weights()
end
test "returns the override weights when the band has a :weights key" do
band = %{freq_mhz: 99_999, weights: %{rain: 0.5, humidity: 0.5}}
assert BandConfig.weights(band) == %{rain: 0.5, humidity: 0.5}
end
test "returns defaults when passed nil" do
assert BandConfig.weights(nil) == BandConfig.weights()
end
end
describe "sunrise_table/0" do
test "returns 12 monthly values" do
table = BandConfig.sunrise_table()

View file

@ -142,4 +142,79 @@ defmodule Microwaveprop.Propagation.RecalibratorTest do
end)
end
end
describe "compute_factors/3 (band-aware)" do
test "humidity score flips direction between 10 GHz (beneficial) and 24 GHz (harmful)" do
# Hot and moist profile — good for 10 GHz (high refractivity) but bad
# for 24 GHz (H2O absorption floor). Humidity factor must reflect this.
profile =
create_hrrr_profile(%{
valid_time: ~U[2024-08-15 06:00:00Z],
lat: 32.9,
lon: -97.0,
surface_temp_c: 32.0,
surface_dewpoint_c: 26.0,
surface_pressure_mb: 1005.0,
min_refractivity_gradient: -120.0,
hpbl_m: 800.0,
pwat_mm: 45.0
})
[humidity_10, _, _, _, _, _, _, _, _, _] =
Recalibrator.compute_factors(profile, ~U[2024-08-15 06:00:00Z], 10_000)
[humidity_24, _, _, _, _, _, _, _, _, _] =
Recalibrator.compute_factors(profile, ~U[2024-08-15 06:00:00Z], 24_000)
assert humidity_10 > humidity_24,
"high humidity should score higher at 10 GHz (beneficial) than 24 GHz (harmful); got #{humidity_10} vs #{humidity_24}"
end
test "defaults to 10 GHz when no band supplied" do
profile =
create_hrrr_profile(%{
valid_time: ~U[2024-08-15 06:00:00Z],
lat: 32.9,
lon: -97.0
})
default = Recalibrator.compute_factors(profile, ~U[2024-08-15 06:00:00Z])
explicit_10g = Recalibrator.compute_factors(profile, ~U[2024-08-15 06:00:00Z], 10_000)
assert default == explicit_10g
end
end
describe "fit/1 with :band_mhz" do
test "only pulls contacts matching the requested band" do
# Two 10 GHz and one 24 GHz contact. Asking for 24 GHz should see just one.
create_contact(%{station1: "W5AA", qso_timestamp: ~U[2024-08-15 06:00:00Z]})
create_contact(%{station1: "W5BB", qso_timestamp: ~U[2024-08-16 06:00:00Z]})
{:ok, _c} =
%Contact{}
|> Contact.changeset(%{
station1: "W5CC",
station2: "K5TR",
qso_timestamp: ~U[2024-08-17 06:00:00Z],
mode: "CW",
band: Decimal.new("24000"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("80")
})
|> Repo.insert()
result = Recalibrator.fit(band_mhz: 24_000, sample_size: 10, epochs: 10)
# No matching HRRR profile was inserted so this falls back to defaults —
# the contract we're validating is "the band filter was applied and only
# 24 GHz contacts were considered". The fallback weights path proves the
# band-specific contact count came through as 1, not 3.
assert is_map(result.weights)
assert map_size(result.weights) == 10
end
end
end