Update analysis with solar time, refine algo.md time-of-day section

Solar time (longitude/15) replaces fixed CDT/CST offset for time-of-day
scoring. Correlation analysis shows dramatic improvement at higher
frequencies: 24 GHz rho jumps from 0.056 (UTC) to 0.188 (solar), and
75 GHz corrects from spurious -0.39 to physically correct +0.24.
This commit is contained in:
Graham McIntire 2026-04-01 09:02:57 -05:00
parent c12f8cf5ed
commit 5c9b43d619
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 51 additions and 11 deletions

27
algo.md
View file

@ -404,7 +404,7 @@ Commercial link data shows 1-5 dB daily variation even on perfectly clear, stabl
On short LOS paths (3-7 km), sub-refractive conditions (dN/dh > -40/km) produce the best signal — minimal multipath, clean beam coupling. On long beyond-LOS paths (50-500+ km), enhanced refraction/ducting is essential. The algorithm must handle both regimes.
### Finding 8: Time-of-Day Effect Scales with Frequency
### Finding 8: Time-of-Day Effect Scales with Frequency (Solar Time)
Night/dawn (22Z-10Z) enhancement vs afternoon baseline, from QSO distance data:
@ -417,6 +417,8 @@ Night/dawn (22Z-10Z) enhancement vs afternoon baseline, from QSO distance data:
At 10 GHz the effect is modest. At 47+ GHz it is the **dominant variable**, more important than most weather parameters. The 75 GHz result is from only 20 night/dawn QSOs but the 4.6x multiplier is consistent with strong ducting being the *only* path at that frequency.
**Update (April 2026):** Switching from fixed CDT/CST timezone to longitude-based **solar time** (`longitude / 15`) dramatically improves the time-of-day correlation at higher frequencies. Spearman correlation with distance: UTC hour rho=0.056 vs solar hour rho=0.188 at 24 GHz (3.4x improvement). At 75 GHz the UTC correlation was confounded by geographic longitude — solar time corrects this from rho=-0.39 to rho=+0.24.
### Finding 9: Ducting Peaks June-July, Not August
Monthly ducting probability from 3,901 soundings:
@ -772,16 +774,27 @@ def score_humidity(abs_humidity_gm3, band_config) do
end
```
### 2. Time of Day Score — Inversion Lifecycle
### 2. Time of Day Score — Solar Time, Inversion Lifecycle
The strongest diurnal predictor in the data. Dawn shows the highest P90 distances; late evening shows elevated averages.
Uses longitude-based **solar time** (`longitude / 15` offset) instead of a fixed timezone offset. This produces physically correct local time at every grid point across CONUS and dramatically improves correlation with QSO distance:
| Band | UTC Hour rho | Solar Hour rho | Improvement |
|------|-------------|---------------|-------------|
| 10 GHz | 0.007 | 0.016 | 2.4x (still weak — 10G ducts form at all times) |
| 24 GHz | 0.056 | **0.188** | 3.4x (now #5 predictor) |
| 47 GHz | -0.024 | **0.152** | Sign corrected (UTC was confounded by longitude) |
| 75 GHz | -0.392 | **0.239** | Sign corrected (western US at lower UTC ≠ better propagation) |
The UTC hour correlation at 75 GHz was spuriously negative because western US stations (lower UTC hours) happened to have longer paths — a geographic confound, not physics. Solar time corrects this.
At 24 GHz, the solar hour bins show a clear physical pattern: 03-05 solar (pre-dawn) has worst distances (57 km median), evening/night (18-23 solar) has best (107-140 km median) — consistent with nocturnal inversion formation.
```elixir
@sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25,
6.35, 6.65, 6.9, 7.1, 7.35, 7.45]
def score_time_of_day(utc_hour, utc_minute, month) do
offset = if month >= 3 and month <= 10, do: -5, else: -6 # CDT/CST
def score_time_of_day(utc_hour, utc_minute, month, longitude) do
offset = longitude / 15 # solar time offset from longitude
local = rem(utc_hour + utc_minute / 60 + offset + 24, 24)
sunrise = Enum.at(@sunrise_table, month - 1)
d = local - sunrise # hours relative to sunrise
@ -1134,7 +1147,7 @@ def compute_score(conditions, band_config, path_type \\ :beyond_los) do
humidity: score_humidity(conditions.abs_humidity, band_config),
wind: score_wind(conditions.wind_speed_kts),
sky: score_sky(conditions.sky_condition),
time_of_day: score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month) |> elem(0),
time_of_day: score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude) |> elem(0),
td_depression: score_td_depression(conditions.temp_f, conditions.dewpoint_f, band_config),
season: score_season(conditions.month, band_config),
pressure: score_pressure(conditions.slp, conditions.prev_slp),
@ -1339,7 +1352,7 @@ def predict_scores(current_obs, obs_3hr_ago, forecast, band_config) do
humidity: score_humidity(abs_hum, band_config),
wind: score_wind(projected_wind),
sky: score_sky(projected_sky),
time_of_day: score_time_of_day(future_time.hour, future_time.minute, month) |> elem(0),
time_of_day: score_time_of_day(future_time.hour, future_time.minute, month, longitude) |> elem(0),
td_depression: score_td_depression(projected_temp, projected_dp, band_config),
season: score_season(month, band_config),
pressure: score_pressure(projected_slp, current_obs.slp),

View file

@ -217,8 +217,8 @@ defmodule Microwaveprop.Propagation.Model do
:min_refractivity_gradient,
:hpbl_m,
:pwat_mm,
:utc_hour_sin,
:utc_hour_cos,
:solar_hour_sin,
:solar_hour_cos,
:month_sin,
:month_cos,
:log_freq_mhz

View file

@ -89,7 +89,10 @@ defmodule Mix.Tasks.PropagationAnalyze do
h2.ducting_detected AS h2_ducting,
-- Terrain
tp.verdict AS terrain_verdict
tp.verdict AS terrain_verdict,
-- Longitude for solar time
((q.pos1->>'lng')::float + (q.pos2->>'lng')::float) / 2.0 AS avg_longitude
FROM qsos q
@ -132,6 +135,18 @@ defmodule Mix.Tasks.PropagationAnalyze do
|> Map.put(:avg_hpbl, safe_avg(row[:h1_hpbl], row[:h2_hpbl]))
|> Map.put(:avg_pwat, safe_avg(row[:h1_pwat], row[:h2_pwat]))
|> Map.put(:either_ducting, row[:h1_ducting] == true or row[:h2_ducting] == true)
|> derive_solar_hour()
end
defp derive_solar_hour(row) do
case {row[:utc_hour], row[:avg_longitude]} do
{h, lng} when is_number(h) and is_number(lng) ->
solar = :math.fmod(h + lng / 15 + 24, 24)
Map.put(row, :solar_hour, solar)
_ ->
Map.put(row, :solar_hour, nil)
end
end
defp safe_avg(nil, nil), do: nil
@ -196,7 +211,8 @@ defmodule Mix.Tasks.PropagationAnalyze do
{:avg_hpbl, "HPBL (m)"},
{:avg_pwat, "PWAT (mm)"},
{:month, "Month"},
{:utc_hour, "UTC Hour"}
{:utc_hour, "UTC Hour"},
{:solar_hour, "Solar Hour"}
]
for band <- @bands do
@ -296,6 +312,17 @@ defmodule Mix.Tasks.PropagationAnalyze do
{18, 21, "18-20 UTC"},
{21, 24, "21-23 UTC"}
]},
{:solar_hour, "Solar Hour",
[
{0, 3, "00-02 Solar (night)"},
{3, 6, "03-05 Solar (pre-dawn)"},
{6, 9, "06-08 Solar (dawn)"},
{9, 12, "09-11 Solar (morning)"},
{12, 15, "12-14 Solar (midday)"},
{15, 18, "15-17 Solar (afternoon)"},
{18, 21, "18-20 Solar (evening)"},
{21, 24, "21-23 Solar (night)"}
]},
{:month, "Month",
[
{1, 2, "Jan"},