diff --git a/src/dvoacap/prediction_engine.py b/src/dvoacap/prediction_engine.py index bf9cdde..549baff 100644 --- a/src/dvoacap/prediction_engine.py +++ b/src/dvoacap/prediction_engine.py @@ -227,8 +227,6 @@ def predict( self.utc_time = utc_time self.frequencies = frequencies.copy() - # Initialize transmit power - self.tx_antennas.current_antenna.tx_power_dbw = self._to_db(self.params.tx_power) self.muf_calculator.min_angle = self.params.min_angle # Allocate results array @@ -296,6 +294,12 @@ def predict( for f, freq in enumerate(self.frequencies): self.tx_antennas.select_antenna(freq) self.rx_antennas.select_antenna(freq) + # Stamp the configured TX power onto whichever antenna was just + # selected; setting it once before the loop only writes to the + # isotropic default and gets shadowed when select_antenna swaps + # in a user-added antenna whose own tx_power_dbw came from its + # constructor (typically a placeholder). + self.tx_antennas.current_antenna.tx_power_dbw = self._to_db(self.params.tx_power) # Compute noise distribution fof2 = self._profiles[-1].f2.fo @@ -311,11 +315,13 @@ def predict( # Evaluate short model prediction = self._evaluate_short_model(reflectrix, f) - # Combine with long model if needed - if self.path.dist >= self.RAD_7000_KM: - long_pred = self._evaluate_long_model(freq) - prediction = self._combine_short_and_long(prediction, long_pred) - + # Long-path model is not implemented (_evaluate_long_model is a + # stub that returns an empty Prediction). Calling + # _combine_short_and_long with that stub would let its zero-valued + # power_dbw pollute the smooth-interpolation branch and back- + # derive a near-zero total_loss for any path between 7000 and + # 10000 km, or hand back an all-zero prediction beyond 10000 km. + # Until the long-path model is real, always use the short result. self.predictions[f] = prediction def _compute_control_points(self) -> None: diff --git a/tests/test_prediction_engine.py b/tests/test_prediction_engine.py new file mode 100644 index 0000000..361dbd1 --- /dev/null +++ b/tests/test_prediction_engine.py @@ -0,0 +1,91 @@ +"""Regression tests for PredictionEngine. + +Guards against the long-path stub bug where _evaluate_long_model returned an +empty Prediction() and _combine_short_and_long mixed it into the result for +paths >= 7000 km, producing absurdly high signal_dbw / SNR for mid-range +distances and an all-zero prediction beyond 10000 km. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from dvoacap.path_geometry import GeoPoint +from dvoacap.prediction_engine import PredictionEngine + + +def _run_prediction(distance_km_target: float, freq_mhz: float = 14.1): + """Run a prediction along the equator at roughly the requested distance.""" + eng = PredictionEngine() + eng.params.ssn = 60 + eng.params.month = 5 + eng.params.tx_power = 100.0 + eng.params.tx_location = GeoPoint.from_degrees(0.0, 0.0) + + # Approximate longitude offset for a great-circle distance along the equator. + # Earth radius is 6370 km in the engine. + lon_deg = float(distance_km_target / 6370.0 * 180.0 / np.pi) + rx = GeoPoint.from_degrees(0.0, lon_deg) + + eng.predict(rx_location=rx, utc_time=18 / 24.0, frequencies=[freq_mhz]) + return eng.predictions[0], eng.path.dist * 6370.0 + + +@pytest.mark.parametrize("distance_km", [4500, 8500, 11000]) +def test_long_distance_signal_is_physically_plausible(distance_km): + """Signal level must reflect a real path-loss budget, not a stub bypass. + + For HF on 20 m at these distances, received power against a 100 W TX with + isotropic antennas should sit well below 0 dBW; SNR likewise capped well + below the dB regime that signaled the stub-mixing bug (>+100 dB). + """ + pred, actual_distance = _run_prediction(distance_km) + + assert pred.signal.power_dbw < -50.0, ( + f"Received power {pred.signal.power_dbw:.1f} dBW at " + f"{actual_distance:.0f} km is implausibly high for HF; long-path stub " + f"likely leaking back in." + ) + assert pred.signal.snr_db < 100.0, ( + f"SNR {pred.signal.snr_db:.1f} dB at {actual_distance:.0f} km is " + f"physically impossible; long-path stub bug regressed." + ) + assert pred.signal.total_loss_db > 80.0, ( + f"total_loss_db {pred.signal.total_loss_db:.1f} dB at " + f"{actual_distance:.0f} km is far too low for an HF skywave path." + ) + + +def test_tx_power_propagates_to_user_added_antenna(): + """tx_power_dbw must reach whichever antenna select_antenna picks, not + just the isotropic default that current_antenna points at before the + per-frequency loop.""" + from dvoacap.antenna_gain import VerticalMonopole + + eng = PredictionEngine() + eng.params.ssn = 60 + eng.params.month = 5 + eng.params.tx_power = 100.0 # 100 W -> 20 dBW + eng.params.tx_location = GeoPoint.from_degrees(0.0, 0.0) + + # Construct an antenna with a deliberately wrong placeholder power so the + # bug, if regressed, is unmistakable. + ant = VerticalMonopole( + low_frequency=14.0, high_frequency=14.5, tx_power_dbw=-30.0 + ) + eng.tx_antennas.add_antenna(ant) + eng.rx_antennas.add_antenna(ant) + + eng.predict( + rx_location=GeoPoint.from_degrees(0.0, 40.0), + utc_time=18 / 24.0, + frequencies=[14.1], + ) + + assert eng.tx_antennas.current_antenna.tx_power_dbw == pytest.approx( + 20.0, abs=1e-6 + ), ( + "params.tx_power=100W must be stamped onto the selected antenna; " + f"got {eng.tx_antennas.current_antenna.tx_power_dbw} dBW." + )