#
# ISC License
#
# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado Boulder
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
import tempfile
from pathlib import Path
import pytest
from Basilisk.architecture import bskLogging
from Basilisk.simulation import spaceWeatherData
from Basilisk.utilities import SimulationBaseClass, macros, unitTestSupport
from Basilisk.architecture.bskLogging import BasiliskError
_CELESTRAK_CSV = "\n".join([
"DATE,BSRN,ND,KP1,KP2,KP3,KP4,KP5,KP6,KP7,KP8,KP_SUM,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,CP,C9,ISN,F10.7_OBS,F10.7_ADJ,F10.7_DATA_TYPE,F10.7_OBS_CENTER81,F10.7_OBS_LAST81,F10.7_ADJ_CENTER81,F10.7_ADJ_LAST81",
"2026-02-28,2626,4,20,27,13,13,20,17,20,23,153,7,12,5,5,7,6,7,9,7,0.4,2,58,140.7,138.1,OBS,141.7,144.2,139.2,139.9",
"2026-03-01,2626,5,27,27,23,20,13,7,17,13,147,12,12,9,7,5,3,6,5,7,0.4,2,80,147.0,144.4,OBS,141.1,143.9,138.6,139.7",
"2026-03-02,2626,6,13,7,10,3,17,10,7,13,80,5,3,4,2,6,4,3,5,4,0.1,0,81,147.6,145.0,OBS,140.3,143.9,138.0,139.7",
"2026-03-03,2626,7,7,27,20,23,20,33,33,50,213,3,12,7,9,7,18,18,48,15,0.9,4,83,143.5,141.1,OBS,139.5,144.0,137.2,139.8",
"2026-03-04,2626,8,23,30,17,20,7,7,13,10,127,9,15,6,7,3,3,5,4,6,0.3,1,68,141.2,138.8,OBS,138.5,144.3,136.3,140.1",
])
[docs]
def test_space_weather_data_celestrak_example_columns():
"""Validate parsing against the provided CelesTrak-style rows.
**Test Description**
This test uses the provided CelesTrak CSV rows and validates output values
against the supplied ground-truth weather channel values.
**Description of Variables Being Tested**
The test checks all 23 outputs in ``swDataOutMsgs`` against known values for
the epoch ``2026-03-04 21:48:00``.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s]
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(_CELESTRAK_CSV)
module.loadSpaceWeatherFile(str(file_path))
timeInitString = "2026 March 04 21:48:00.000000"
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg(timeInitString)
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
logs = []
for msg_index in range(23):
recorder = module.swDataOutMsgs[msg_index].recorder()
logs.append(recorder)
sim.AddModelToTask(unit_task_name, recorder)
sim.InitializeSimulation()
sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s]
sim.ExecuteSimulation()
expected = [6.0, 4.0, 5.0, 3.0, 3.0, 7.0, 6.0, 15.0, 9.0,
48.0, 18.0, 18.0, 7.0, 9.0, 7.0, 12.0,
3.0, 5.0, 3.0, 4.0, 6.0, 138.5, 143.5]
for msg_index in range(23):
assert abs(logs[msg_index].dataValue[0] - expected[msg_index]) < 1e-12
[docs]
def test_space_weather_data_stops_at_first_invalid_row():
"""Validate reading stops at the first invalid row (e.g., PRM monthly line).
**Test Description**
This test appends invalid PRM-style rows with missing AP fields after valid
daily rows and verifies that the valid section is still used correctly.
**Description of Variables Being Tested**
The test checks the 23 weather outputs against the same ground-truth values
as the valid CelesTrak example set.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s]
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
csv_text = "\n".join([
"DATE,BSRN,ND,KP1,KP2,KP3,KP4,KP5,KP6,KP7,KP8,KP_SUM,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,CP,C9,ISN,F10.7_OBS,F10.7_ADJ,F10.7_DATA_TYPE,F10.7_OBS_CENTER81,F10.7_OBS_LAST81,F10.7_ADJ_CENTER81,F10.7_ADJ_LAST81",
"2026-02-28,2626,4,20,27,13,13,20,17,20,23,153,7,12,5,5,7,6,7,9,7,0.4,2,58,140.7,138.1,OBS,141.7,144.2,139.2,139.9",
"2026-03-01,2626,5,27,27,23,20,13,7,17,13,147,12,12,9,7,5,3,6,5,7,0.4,2,80,147.0,144.4,OBS,141.1,143.9,138.6,139.7",
"2026-03-02,2626,6,13,7,10,3,17,10,7,13,80,5,3,4,2,6,4,3,5,4,0.1,0,81,147.6,145.0,OBS,140.3,143.9,138.0,139.7",
"2026-03-03,2626,7,7,27,20,23,20,33,33,50,213,3,12,7,9,7,18,18,48,15,0.9,4,83,143.5,141.1,OBS,139.5,144.0,137.2,139.8",
"2026-03-04,2626,8,23,30,17,20,7,7,13,10,127,9,15,6,7,3,3,5,4,6,0.3,1,68,141.2,138.8,OBS,138.5,144.3,136.3,140.1",
"2038-11-01,2797,16,,,,,,,,,,,,,,,,,,,,,64,95.7,94.3,PRM,96.0,95.4,94.7,96.1",
"2038-12-01,2798,19,,,,,,,,,,,,,,,,,,,,,62,95.9,93.3,PRM,96.2,95.9,93.8,95.1",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
module.loadSpaceWeatherFile(str(file_path))
timeInitString = "2026 March 04 21:48:00.000000"
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg(timeInitString)
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
logs = []
for msg_index in range(23):
recorder = module.swDataOutMsgs[msg_index].recorder()
logs.append(recorder)
sim.AddModelToTask(unit_task_name, recorder)
sim.InitializeSimulation()
sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s]
sim.ExecuteSimulation()
expected = [6.0, 4.0, 5.0, 3.0, 3.0, 7.0, 6.0, 15.0, 9.0,
48.0, 18.0, 18.0, 7.0, 9.0, 7.0, 12.0,
3.0, 5.0, 3.0, 4.0, 6.0, 138.5, 143.5]
for msg_index in range(23):
assert abs(logs[msg_index].dataValue[0] - expected[msg_index]) < 1e-12
[docs]
def test_space_weather_data_missing_required_column():
"""Validate missing required CSV columns lead to zero-state output publication.
**Test Description**
This test removes ``F10.7_OBS_CENTER81`` from the CSV header and checks that
the module cannot build a valid weather table and thus publishes zeros.
**Description of Variables Being Tested**
The test checks all 23 weather outputs and verifies every value is zero.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
csv_text = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS",
"2025-01-04,401,402,403,404,405,406,407,408,450,73",
"2025-01-05,301,302,303,304,305,306,307,308,350,72",
"2025-01-06,201,202,203,204,205,206,207,208,250,71",
"2025-01-07,101,102,103,104,105,106,107,108,150,70",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
with pytest.raises(BasiliskError, match="Space-weather file missing required column"):
module.loadSpaceWeatherFile(str(file_path))
[docs]
def test_space_weather_data_duplicate_date_rows():
"""Validate duplicate DATE rows are rejected and zero-state output is published.
**Test Description**
This test provides duplicate ``DATE`` rows in the weather table. The module
should reject the file and publish zero outputs.
**Description of Variables Being Tested**
The test checks all 23 weather outputs and verifies every value is zero.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
csv_text = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS,F10.7_OBS_CENTER81",
"2025-01-04,401,402,403,404,405,406,407,408,450,73,83",
"2025-01-04,301,302,303,304,305,306,307,308,350,72,82",
"2025-01-06,201,202,203,204,205,206,207,208,250,71,81",
"2025-01-07,101,102,103,104,105,106,107,108,150,70,80",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
with pytest.raises(BasiliskError, match="Duplicate DATE row found in space-weather table"):
module.loadSpaceWeatherFile(str(file_path))
[docs]
def test_space_weather_data_unsorted_rows():
"""Validate unsorted DATE rows are rejected and zero-state output is published.
**Test Description**
This test provides weather rows out of chronological order. The loader should
reject the table and the module should publish zero outputs.
**Description of Variables Being Tested**
The test checks all 23 weather outputs and verifies every value is zero.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
csv_text = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS,F10.7_OBS_CENTER81",
"2025-01-05,301,302,303,304,305,306,307,308,350,72,82",
"2025-01-04,401,402,403,404,405,406,407,408,450,73,83",
"2025-01-06,201,202,203,204,205,206,207,208,250,71,81",
"2025-01-07,101,102,103,104,105,106,107,108,150,70,80",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
with pytest.raises(BasiliskError, match="Space-weather DATE rows must be sorted in ascending order"):
module.loadSpaceWeatherFile(str(file_path))
[docs]
def test_reset_error_when_epoch_before_table():
"""Validate that Reset emits BSK_ERROR when the epoch date is not in the table.
**Test Description**
The simulation epoch is set to 2026-02-27, one day before the first row of
the loaded table (2026-02-28). The ``Reset`` probe calls ``computeSwState``
at initialisation time; the four-day look-back window cannot be assembled and
a ``BSK_ERROR`` is emitted, which Basilisk converts to a ``BasiliskError``.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``InitializeSimulation`` with the message
``"simulation start date is not covered"``.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s]
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(_CELESTRAK_CSV)
module.loadSpaceWeatherFile(str(file_path))
# epoch one day before the table start — D0 = 2026-02-27, not in table
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg("2026 February 27 00:00:00.000000")
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
with pytest.raises(BasiliskError, match="simulation start date is not covered"):
sim.InitializeSimulation()
[docs]
def test_stale_output_retained_on_missing_day():
"""Validate that outputs are not overwritten when a required day is missing.
**Test Description**
The simulation runs two stages with the same module instance. In stage 1
(t = 1 s, epoch 2026-03-04) the look-up succeeds and known values are
written. In stage 2 (t = 25 h + 1 s) D0 advances to 2026-03-05 which is
absent from the table; the module must log ``BSK_WARNING`` and return without
writing, leaving the outputs at the stage-1 values.
**Description of Variables Being Tested**
- ``swDataOutMsgs[0]`` (``ap_24_0``, AP_AVG): 6.0 after both stages.
- ``swDataOutMsgs[21]`` (``f107_1944_0``, F10.7c81): 138.5 after both stages.
"""
# Suppress the expected BSK_WARNING, not testing them at the moment.
bskLogging.setDefaultLogLevel(bskLogging.BSK_ERROR)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s]
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(_CELESTRAK_CSV)
module.loadSpaceWeatherFile(str(file_path))
# epoch on the last day in the table; after 24 h D0 = 2026-03-05 (absent)
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg("2026 March 04 00:00:00.000000")
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
recorder_avg = module.swDataOutMsgs[0].recorder()
recorder_f107c = module.swDataOutMsgs[21].recorder()
sim.AddModelToTask(unit_task_name, recorder_avg)
sim.AddModelToTask(unit_task_name, recorder_f107c)
sim.InitializeSimulation()
# Stage 1: successful look-up — D0 = 2026-03-04
sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s]
sim.ExecuteSimulation()
assert abs(recorder_avg.dataValue[0] - 6.0) < 1e-12
assert abs(recorder_f107c.dataValue[0] - 138.5) < 1e-12
# Stage 2: failed look-up — D0 = 2026-03-05 not in table, keep previous data
sim.ConfigureStopTime(macros.sec2nano(25 * 3600 + 1.0)) # [s]
sim.ExecuteSimulation()
assert abs(recorder_avg.dataValue[-1] - 6.0) < 1e-12
assert abs(recorder_f107c.dataValue[-1] - 138.5) < 1e-12
[docs]
def test_space_weather_data_epoch_update():
"""Validate internal epoch update against the provided CelesTrak-style rows.
**Test Description**
This test uses the provided CelesTrak CSV rows and validates output values
at a future epoch against the supplied ground-truth weather channel values.
**Description of Variables Being Tested**
The test checks all 23 outputs in ``swDataOutMsgs`` against known values for
the epoch ``2026-03-04 21:48:00`` starting the simulation at ``2026-03-03 18:48:00``
and propagating the simulation for 1 day and 3 hours.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(3600.0))) # [s]
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(_CELESTRAK_CSV)
module.loadSpaceWeatherFile(str(file_path))
timeInitString = "2026 March 03 18:48:00.000000"
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg(timeInitString)
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
logs = []
for msg_index in range(23):
recorder = module.swDataOutMsgs[msg_index].recorder()
logs.append(recorder)
sim.AddModelToTask(unit_task_name, recorder)
sim.InitializeSimulation()
sim.ConfigureStopTime(macros.sec2nano(3600*24 + 3600*3)) # [s] 1 day and 3 hours
sim.ExecuteSimulation()
expected = [6.0, 4.0, 5.0, 3.0, 3.0, 7.0, 6.0, 15.0, 9.0,
48.0, 18.0, 18.0, 7.0, 9.0, 7.0, 12.0,
3.0, 5.0, 3.0, 4.0, 6.0, 138.5, 143.5]
for msg_index in range(23):
assert abs(logs[msg_index].dataValue[-1] - expected[msg_index]) < 1e-12
[docs]
def test_space_weather_data_rejects_date_with_trailing_chars():
"""Validate that a DATE token with trailing characters is rejected.
**Test Description**
This test writes a CSV whose only data row has ``2026-03-04junk`` in the
DATE column. The parser must reject the row; the table is left empty and
``loadSpaceWeatherFile`` emits ``BSK_ERROR``.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile`` with the message
``"No valid weather rows were loaded"``.
"""
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
module.bskLogger.setLogLevel(bskLogging.BSK_ERROR)
csv_text = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS,F10.7_OBS_CENTER81",
"2026-03-04junk,9,15,6,7,3,3,5,4,6,141.2,138.5",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
module.loadSpaceWeatherFile(str(file_path))
[docs]
def test_space_weather_data_rejects_numeric_with_trailing_chars():
"""Validate that a numeric field with trailing characters is rejected.
**Test Description**
This test writes a CSV whose only data row has ``9abc`` in the ``AP1``
column. The strict numeric parser must reject the row; the table is left
empty and ``loadSpaceWeatherFile`` emits ``BSK_ERROR``.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile`` with the message
``"No valid weather rows were loaded"``.
"""
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
module.bskLogger.setLogLevel(bskLogging.BSK_ERROR)
csv_text = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS,F10.7_OBS_CENTER81",
"2026-03-04,9abc,15,6,7,3,3,5,4,6,141.2,138.5",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "space_weather.csv"
file_path.write_text(csv_text)
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
module.loadSpaceWeatherFile(str(file_path))
_MINIMAL_HEADER = "DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS,F10.7_OBS_CENTER81"
_VALID_ROW = "2026-03-04,9,15,6,7,3,3,5,4,6,141.2,138.5"
def _load_single_row_csv(row: str) -> spaceWeatherData.SpaceWeatherData:
"""Helper: write a single-data-row CSV and call loadSpaceWeatherFile."""
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
module.bskLogger.setLogLevel(bskLogging.BSK_ERROR)
csv_text = _MINIMAL_HEADER + "\n" + row
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "sw.csv"
file_path.write_text(csv_text)
module.loadSpaceWeatherFile(str(file_path))
return module
# ---------------------------------------------------------------------------
# Calendar-date validation
# ---------------------------------------------------------------------------
[docs]
def test_invalid_date_april_31_rejected():
"""April 31 does not exist — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2026-04-31`` is loaded. April has only
30 days, so the parser must reject the row and emit ``BSK_ERROR`` with
``"No valid weather rows were loaded"``.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-04-31,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_november_31_rejected():
"""November 31 does not exist — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2026-11-31`` is loaded. November has only
30 days, so the parser must reject the row and emit ``BSK_ERROR``.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-11-31,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_feb_30_rejected():
"""February 30 never exists — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2024-02-30`` is loaded. Even in the
leap year 2024, February has only 29 days, so the parser must reject it.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2024-02-30,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_feb_29_non_leap_rejected():
"""February 29 in a non-leap year must be rejected.
**Test Description**
A CSV with a single row dated ``2025-02-29`` is loaded. 2025 is not a
leap year (not divisible by 4), so February has only 28 days.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2025-02-29,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_feb_29_century_non_leap_rejected():
"""February 29 in a century year that is not a 400-multiple must be rejected.
**Test Description**
2100 is divisible by 4 but not by 400, so it is not a leap year and
February has only 28 days.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2100-02-29,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_valid_date_feb_29_leap_accepted():
"""February 29 in a leap year must be accepted.
**Test Description**
A four-row CSV that includes ``2024-02-29`` (2024 is a leap year) is
loaded and the simulation is run with the epoch on ``2024-03-02 12:00:00``
so that the look-back window covers 2024-02-29. The table must load
successfully and produce a finite ``ap_24_0`` output.
**Description of Variables Being Tested**
``swDataOutMsgs[0].dataValue`` is non-zero after a successful look-up.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0)))
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
# Build a 4-row window that straddles the leap day.
csv_text = "\n".join([
_MINIMAL_HEADER,
"2024-02-28,1,2,3,4,5,6,7,8,4,130.0,128.0",
"2024-02-29,9,10,11,12,13,14,15,16,12,131.0,129.0",
"2024-03-01,17,18,19,20,21,22,23,24,20,132.0,130.0",
"2024-03-02,25,26,27,28,29,30,31,32,28,133.0,131.0",
])
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "sw_leap.csv"
file_path.write_text(csv_text)
module.loadSpaceWeatherFile(str(file_path))
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg("2024 March 02 12:00:00.000000")
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
recorder = module.swDataOutMsgs[0].recorder()
sim.AddModelToTask(unit_task_name, recorder)
sim.InitializeSimulation()
sim.ConfigureStopTime(macros.sec2nano(1.0))
sim.ExecuteSimulation()
# AP_AVG for 2024-03-02 is 28
assert abs(recorder.dataValue[0] - 28.0) < 1e-12
[docs]
def test_valid_date_feb_29_quad_century_leap_accepted():
"""February 29 in a 400-multiple year (true leap) must be accepted.
**Test Description**
2000 is divisible by 400 and therefore is a leap year. A single row
dated ``2000-02-29`` must not be rejected by the date validator.
The test loads a four-row table that includes this date and verifies
``loadSpaceWeatherFile`` succeeds (no exception raised).
**Description of Variables Being Tested**
No exception from ``loadSpaceWeatherFile``.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
csv_text = "\n".join([
_MINIMAL_HEADER,
"2000-02-27,1,2,3,4,5,6,7,8,4,130.0,128.0",
"2000-02-28,9,10,11,12,13,14,15,16,12,131.0,129.0",
"2000-02-29,17,18,19,20,21,22,23,24,20,132.0,130.0",
"2000-03-01,25,26,27,28,29,30,31,32,28,133.0,131.0",
])
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "sw_2000_leap.csv"
file_path.write_text(csv_text)
# Must not raise
module.loadSpaceWeatherFile(str(file_path))
[docs]
def test_invalid_date_day_zero_rejected():
"""Day 0 is never valid — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2026-03-00`` is loaded. Day-of-month
zero is out of range for every month.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-03-00,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_month_zero_rejected():
"""Month 0 is never valid — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2026-00-15`` is loaded. Month zero is
out of the valid [1, 12] range.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-00-15,9,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_invalid_date_month_13_rejected():
"""Month 13 is never valid — the row must be rejected.
**Test Description**
A CSV with a single row dated ``2026-13-01`` is loaded. Month 13 is out
of the valid [1, 12] range.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-13-01,9,15,6,7,3,3,5,4,6,141.2,138.5")
# ---------------------------------------------------------------------------
# Non-finite numeric values
# ---------------------------------------------------------------------------
[docs]
def test_failed_reload_preserves_previous_table():
"""A failed second loadSpaceWeatherFile call must not destroy the first table.
**Test Description**
A valid CSV is loaded first, giving a working weather table. A second
call is then made with a file that has a missing required column. The
second load must fail (emitting ``BSK_ERROR``) while leaving the module
still able to serve data from the first load.
**Description of Variables Being Tested**
``swDataOutMsgs[0].dataValue`` equals the expected AP_AVG value from the
first table after the failed reload.
"""
bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)
unit_task_name = "unitTask"
unit_process_name = "unitProcess"
sim = SimulationBaseClass.SimBaseClass()
proc = sim.CreateNewProcess(unit_process_name)
proc.addTask(sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0)))
module = spaceWeatherData.SpaceWeatherData()
module.ModelTag = "spaceWeatherData"
bad_csv = "\n".join([
"DATE,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,F10.7_OBS", # F10.7_OBS_CENTER81 missing
"2026-03-04,9,15,6,7,3,3,5,4,6,141.2",
])
with tempfile.TemporaryDirectory() as temp_dir:
good_path = Path(temp_dir) / "good.csv"
bad_path = Path(temp_dir) / "bad.csv"
good_path.write_text(_CELESTRAK_CSV)
bad_path.write_text(bad_csv)
module.loadSpaceWeatherFile(str(good_path))
with pytest.raises(BasiliskError):
module.loadSpaceWeatherFile(str(bad_path))
# Table from the good file must still be intact
epochMsg = unitTestSupport.timeStringToGregorianUTCMsg("2026 March 04 21:48:00.000000")
module.epochInMsg.subscribeTo(epochMsg)
sim.AddModelToTask(unit_task_name, module)
recorder = module.swDataOutMsgs[0].recorder()
sim.AddModelToTask(unit_task_name, recorder)
sim.InitializeSimulation()
sim.ConfigureStopTime(macros.sec2nano(1.0))
sim.ExecuteSimulation()
assert abs(recorder.dataValue[0] - 6.0) < 1e-12
[docs]
def test_nan_in_ap_field_rejected():
"""A NaN string in an AP column must be rejected.
**Test Description**
A CSV row with ``nan`` in the ``AP1`` column is loaded. The strict
numeric parser must reject the non-finite value, leaving the table empty.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-03-04,nan,15,6,7,3,3,5,4,6,141.2,138.5")
[docs]
def test_inf_in_f107_field_rejected():
"""An Inf string in an F10.7 column must be rejected.
**Test Description**
A CSV row with ``inf`` in the ``F10.7_OBS`` column is loaded. The
strict numeric parser must reject the non-finite value.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-03-04,9,15,6,7,3,3,5,4,6,inf,138.5")
[docs]
def test_neg_inf_in_ap_avg_field_rejected():
"""-Inf in the AP_AVG column must be rejected.
**Test Description**
A CSV row with ``-inf`` in ``AP_AVG`` is loaded. Negative infinity is
non-finite and must be rejected by the strict numeric parser.
**Description of Variables Being Tested**
``BasiliskError`` raised during ``loadSpaceWeatherFile``.
"""
with pytest.raises(BasiliskError, match="No valid weather rows were loaded"):
_load_single_row_csv("2026-03-04,9,15,6,7,3,3,5,4,-inf,141.2,138.5")
if __name__ == "__main__":
test_space_weather_data_celestrak_example_columns()
test_space_weather_data_stops_at_first_invalid_row()
test_space_weather_data_missing_required_column()
test_space_weather_data_duplicate_date_rows()
test_space_weather_data_unsorted_rows()
test_reset_error_when_epoch_before_table()
test_stale_output_retained_on_missing_day()
test_space_weather_data_rejects_date_with_trailing_chars()
test_space_weather_data_rejects_numeric_with_trailing_chars()
test_invalid_date_april_31_rejected()
test_invalid_date_november_31_rejected()
test_invalid_date_feb_30_rejected()
test_invalid_date_feb_29_non_leap_rejected()
test_invalid_date_feb_29_century_non_leap_rejected()
test_valid_date_feb_29_leap_accepted()
test_valid_date_feb_29_quad_century_leap_accepted()
test_invalid_date_day_zero_rejected()
test_invalid_date_month_zero_rejected()
test_invalid_date_month_13_rejected()
test_nan_in_ap_field_rejected()
test_inf_in_f107_field_rejected()
test_neg_inf_in_ap_avg_field_rejected()
test_failed_reload_preserves_previous_table()