Skip to content

Fix nonsense signal/SNR for HF paths longer than 7000 km#137

Merged
skyelaird merged 1 commit into
mainfrom
claude/fix-prediction-loss-calc
May 1, 2026
Merged

Fix nonsense signal/SNR for HF paths longer than 7000 km#137
skyelaird merged 1 commit into
mainfrom
claude/fix-prediction-loss-calc

Conversation

@skyelaird

Copy link
Copy Markdown
Owner

Summary

Fix two bugs in PredictionEngine that together produced the constant -0.7 dBW signal levels and absurd ±150–270 dB SNRs the user observed in the dashboard for every region beyond 7000 km.

Bug 1: long-path stub leaks into result

_evaluate_long_model() is a documented stub (# Simplified implementation - would need two Reflectrix objects. For now, return a basic prediction.) that returns an empty Prediction(). For any path ≥ 7000 km, predict() was calling _combine_short_and_long(short_pred, long_pred) with that stub.

A default Prediction() has signal.power_dbw = 0.0. _combine_short_and_long mixed the zero into the result:

  • Distance ≥ 10000 km: returned long_pred directly → all-zero signal, total_loss_db back-derived to tx_power_dbw - 0 ≈ 20 dB.
  • 7000 km ≤ distance < 10000 km: smooth-interpolated toward 0 dBW. With a typical short_pwr10 of −120 dBW, the math collapses to power_dbw ≈ 10·log10(r) ≈ −0.7 dBW — exactly the constant signal level the dashboard rendered.

Short paths weren't fixed by this either: current_conditions showed regions like CA/EU/UK/SA at 10 m with SNRs of −223 / −267 / −234 dB, where short-model predictions on a frequency far above MUF produced large total_loss_db values that propagated through correctly into the short pred but then got clobbered by the same combine-step writeback for any portion crossing 7000 km.

Until a real long-path model exists, this PR skips the combine step entirely and always uses the short prediction. The short model is already valid out past 10000 km; the F2 multi-hop tracking just gets less accurate, which is strictly preferable to the engine inventing signal.

Bug 2: TX power doesn't reach user-added antennas

predict() set tx_power_dbw on current_antenna once, before the per-frequency loop where select_antenna() swaps in the right antenna. At that early point current_antenna is still the isotropic default, so the user's vertical/yagi/dipole kept whatever placeholder its constructor set (the dashboard uses tx_power_dbw=10.0, comment says "Will be set from engine.params.tx_power" — but nothing ever did). Net effect: a configured 100 W (20 dBW) TX was being treated as 10 W on every dashboard prediction.

Move the assignment inside the per-frequency loop, after select_antenna().

Verified output

For Halifax → Africa equivalent (~9500 km) at 14.1 MHz, 100 W, vertical antennas:

metric before after
total_loss_db 10.07 171.13
power_dbw −0.07 −150.37
snr_db +154.73 +4.44
reliability 1.000 0.000

(SNR is now a normal-looking +4 dB, marginal-copy regime, as you'd expect for a 9500 km daytime path on 20 m.)

Test plan

  • Existing test suite passes — 318 → 322 tests, all green
  • test_voacap_validation.py still produces sensible numbers (loss 157.3 dB, SNR 26.2 dB to London at 4689 km)
  • validate_dvoacap.py reports VALIDATION SUCCESSFUL
  • New regression tests in tests/test_prediction_engine.py:
    • signal levels at 4500 / 8500 / 11000 km satisfy power_dbw < −50 dBW, snr_db < +100 dB, total_loss_db > 80 dB (every assertion was clearly violated by the stub-mixing bug)
    • params.tx_power propagates through to a user-added antenna whose constructor used a placeholder value
  • Refresh predictions on a real dashboard install, fetch /api/data, confirm signal_dbw now varies hour-to-hour and regions, and reliability is no longer pinned at 100% / 0%

Out of scope for this PR

  • The 10 m short-path SNR oddities (e.g. −267 dB to EU) likely come from a separate sign issue in the muf-penalty path. Worth a follow-up once we have data showing whether they persist after this fix.
  • Implementing a real _evaluate_long_model. The stub is preserved as-is; only its consumption is bypassed.

https://claude.ai/code/session_01BLfWHK9hf8k4aBBaQjfQa8


Generated by Claude Code

For any path >= 7000 km, predict() called _combine_short_and_long with a
long_pred produced by _evaluate_long_model -- which is a stub:

    def _evaluate_long_model(self, frequency: float) -> Prediction:
        \"\"\"Evaluate long path model (not fully implemented).\"\"\"
        # Simplified implementation - would need two Reflectrix objects
        # For now, return a basic prediction
        return Prediction()

A default Prediction() has signal.power_dbw = 0.0, power10 = 0.0, etc.
_combine_short_and_long happily mixed those zeros into the result:

* dist >= 10000 km: returned long_pred unchanged (all-zero signal),
  back-deriving total_loss_db = tx_power_dbw - 0 = ~20 dB.
* 7000 km <= dist < 10000 km: smooth-interp pulled power_dbw toward the
  long path's 0 dBW. With a typical short_pwr10 of -120 dBW, the math
  collapses to power_dbw ~= 10*log10(r) ~= -0.7 dBW, giving the constant
  signal_dbw the dashboard was rendering.

The dashboard reported this as flat -0.7 dBW signal and ~+155 dB SNR for
every region beyond 7000 km, with all 24 hours identical and reliability
pinned at 100%. Closer regions had the opposite failure mode -- short
predictions at 28+ MHz that legitimately couldn't propagate produced
total_loss values bounded by the muf-penalty term, which then surfaced
as -200 to -270 dB SNR via the same combine step writing back through
power_dbw - tx_power_dbw.

Until a real long-path model exists, skip the combine step entirely and
use the short prediction for every distance. Short-mode VOACAP results
are already valid out past 10000 km; the F2 multi-hop tracking just gets
less accurate, which is preferable to the engine inventing signal.

Also fix a secondary bug: tx_power_dbw was stamped onto current_antenna
before the per-frequency loop, where current_antenna still points at the
isotropic default. select_antenna() then swapped in a user-added antenna
whose tx_power_dbw came from its constructor (typically a placeholder
like 10 dBW for the dashboard). Move the assignment inside the loop,
after select_antenna(), so the real params.tx_power reaches whichever
antenna gets used.

Add regression tests asserting that:
* signal.power_dbw < -50 dBW, snr_db < +100 dB, total_loss_db > 80 dB
  for paths at 4500 / 8500 / 11000 km against a 100 W TX with isotropic
  antennas (each clearly violated by the stub-mixing bug),
* params.tx_power flows through to a user-added antenna, surviving its
  placeholder tx_power_dbw.

https://claude.ai/code/session_01BLfWHK9hf8k4aBBaQjfQa8
@skyelaird skyelaird marked this pull request as ready for review May 1, 2026 19:00
@skyelaird skyelaird merged commit 4347578 into main May 1, 2026
14 checks passed
@skyelaird skyelaird deleted the claude/fix-prediction-loss-calc branch May 1, 2026 19:00
@skyelaird skyelaird mentioned this pull request May 1, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants