From 5c9b43d619b25d84f1d48ae06ee016211e6c75c6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 09:02:57 -0500 Subject: [PATCH] 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. --- algo.md | 27 ++++++++++++++++------ lib/microwaveprop/propagation/model.ex | 4 ++-- lib/mix/tasks/propagation_analyze.ex | 31 ++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/algo.md b/algo.md index a10c9413..38a645aa 100644 --- a/algo.md +++ b/algo.md @@ -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), diff --git a/lib/microwaveprop/propagation/model.ex b/lib/microwaveprop/propagation/model.ex index fab9a59c..5cba5751 100644 --- a/lib/microwaveprop/propagation/model.ex +++ b/lib/microwaveprop/propagation/model.ex @@ -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 diff --git a/lib/mix/tasks/propagation_analyze.ex b/lib/mix/tasks/propagation_analyze.ex index 1744a542..ba64e799 100644 --- a/lib/mix/tasks/propagation_analyze.ex +++ b/lib/mix/tasks/propagation_analyze.ex @@ -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"},