From 66c498ad71c2a62fe68f35dc99c6fc2e49914407 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Thu, 15 Oct 2026 08:57:16 +0200 Subject: [PATCH 1/2] fix: name the steps columns as in the FDS User Guide; GeomSlice array hint sim.steps stored the wall-clock time of CHID_steps.csv under "Time Step", which the FDS User Guide (section "Time Step Data") defines as the iteration number, and did not read the iteration number at all. It now has "Step" (iteration number) and "Wall Time" (UTC). "Time Step" keeps the wall-clock time for compatibility and warns once (FutureWarning) that it will become the iteration number. The TypeError for converting a GeomSlice to a numpy array recommended to_global, which GeomSlice does not have; it now names the data, vertices and faces properties. Co-Authored-By: Claude Opus 5.5 --- fdsreader/simulation.py | 43 ++++++++++++++++++--- fdsreader/slcf/geomslice.py | 1 + fdsreader/utils/array_mixin.py | 5 ++- tests/test_crash_regressions.py | 2 +- tests/test_steps_and_array_hints.py | 59 +++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 tests/test_steps_and_array_hints.py diff --git a/fdsreader/simulation.py b/fdsreader/simulation.py index 391a9ff1..c4cd462f 100644 --- a/fdsreader/simulation.py +++ b/fdsreader/simulation.py @@ -53,6 +53,32 @@ def _wall_times_to_utc(stamps: Sequence[str]) -> np.ndarray: return np.array(utc, dtype="datetime64[ms]") +class StepData(dict): + """The columns of CHID_steps.csv by name, see :attr:`Simulation.steps`. + + The key ``"Time Step"`` holds the wall-clock times for compatibility with earlier versions + and warns once when used: in the FDS User Guide the column ``Time Step`` is the iteration + number, available here as ``"Step"``, and the wall-clock time is ``"Wall Time"``. + """ + + _warned = False + + def __getitem__(self, key): + if key == "Time Step" and not StepData._warned: + StepData._warned = True + warnings.warn( + 'steps["Time Step"] holds the wall-clock time, not the time step number. Use ' + 'steps["Wall Time"] for the time and steps["Step"] for the number; "Time Step" will ' + "become the number, as in the FDS User Guide, in a future release.", + FutureWarning, + stacklevel=2, + ) + return super().__getitem__(key) + + def get(self, key, default=None): + return self[key] if key in self else default + + class Simulation: """Master class managing all data for a given simulation. @@ -80,7 +106,10 @@ class Simulation: :ivar geom_data: All geometry data by quantity combined into a :class:`GeometryCollection`. :ivar cpu: Dictionary mapping .csv header keys to numpy arrays containing cpu data. :ivar hrr: Dictionary mapping .csv header keys to numpy arrays containing hrr data. - :ivar steps: Dictionary mapping .csv header keys to numpy arrays containing steps data. + :ivar steps: The time step data of CHID_steps.csv as a :class:`StepData` dictionary: + ``"Step"`` (iteration number), ``"Wall Time"`` (date and time, UTC), ``"Step Size"`` and + ``"Simulation Time"`` (s), and ``"CPU Time"`` (s since FDS started). ``"Time Step"`` still + holds the wall-clock time, but warns: it will become the iteration number. :ivar ctrl: Dictionary mapping control (&CTRL) names to numpy arrays of their state over time. Only set if the simulation has a CTRL csv export. :ivar mass: Dictionary mapping species names to numpy arrays of their mass history. Only set @@ -1236,15 +1265,17 @@ def _load_HRR_data(self, file_path: str) -> Dict[str, np.ndarray]: return self._transform_csv_data(keys, values) @log_error("csv") - def _load_step_data(self, file_path: str) -> Dict[str, np.ndarray]: + def _load_step_data(self, file_path: str) -> "StepData": with open(file_path) as infile: infile.readline() keys = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")][2:] - wall_times = np.loadtxt(file_path, dtype=str, delimiter=",", ndmin=1, usecols=1, skiprows=2) - timesteps = _wall_times_to_utc(wall_times) + steps = np.loadtxt(file_path, dtype=int, delimiter=",", ndmin=1, usecols=0, skiprows=2) + wall_times = _wall_times_to_utc(np.loadtxt(file_path, dtype=str, delimiter=",", ndmin=1, usecols=1, skiprows=2)) float_values = np.loadtxt(file_path, delimiter=",", usecols=range(2, len(keys) + 2), ndmin=2, skiprows=2) - data = self._transform_csv_data(keys, float_values) - data["Time Step"] = timesteps + data = StepData(self._transform_csv_data(keys, float_values)) + data["Step"] = steps + data["Wall Time"] = wall_times + data["Time Step"] = wall_times # kept for compatibility, see StepData return data @log_error("csv") diff --git a/fdsreader/slcf/geomslice.py b/fdsreader/slcf/geomslice.py index ed69de0f..acbe9b9c 100644 --- a/fdsreader/slcf/geomslice.py +++ b/fdsreader/slcf/geomslice.py @@ -167,6 +167,7 @@ class GeomSlice(NumpyArrayMixin): _handled_functions = _HANDLED_FUNCTIONS _HANDLED_FUNCTIONS[np.mean] = NumpyArrayMixin.mean _HANDLED_FUNCTIONS[np.std] = NumpyArrayMixin.std + _global_array_hint = "use the 'data', 'vertices' and 'faces' properties, which combine all meshes." def _array_subitems(self): return self._subgeomslices.values() diff --git a/fdsreader/utils/array_mixin.py b/fdsreader/utils/array_mixin.py index bf13fad2..231a554e 100644 --- a/fdsreader/utils/array_mixin.py +++ b/fdsreader/utils/array_mixin.py @@ -36,6 +36,9 @@ class Slice(NumpyArrayMixin): #: (see class docstring). Left as an empty dict here only as a safe default. _handled_functions: Dict[Any, Any] = {} + #: How to get one array of all subitems, for the error message of :meth:`__array__`. + _global_array_hint = "use the 'to_global' method and use the returned numpy-array explicitly." + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if cls._handled_functions is NumpyArrayMixin._handled_functions: @@ -76,7 +79,7 @@ def __array__(self): raise TypeError( f"{name}s can not be converted to numpy arrays, but they support all typical numpy" " operations such as np.multiply. If a 'global' array containing all subitems is" - " required, use the 'to_global' method and use the returned numpy-array explicitly." + f" required, {self._global_array_hint}" ) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): diff --git a/tests/test_crash_regressions.py b/tests/test_crash_regressions.py index 69472fc4..4af84f1b 100644 --- a/tests/test_crash_regressions.py +++ b/tests/test_crash_regressions.py @@ -53,7 +53,7 @@ def test_steps_load_without_timezone_warning(monkeypatch): warnings.simplefilter("error") steps = Simulation(str(CASES_DIR / "steckler_data")).steps # The wall-clock times are written with an offset of +01:00 and stored as UTC - assert steps["Time Step"][0] == np.datetime64("2024-03-14T08:39:53.199") + assert steps["Wall Time"][0] == np.datetime64("2024-03-14T08:39:53.199") @pytest.mark.parametrize( diff --git a/tests/test_steps_and_array_hints.py b/tests/test_steps_and_array_hints.py new file mode 100644 index 00000000..ad2c332d --- /dev/null +++ b/tests/test_steps_and_array_hints.py @@ -0,0 +1,59 @@ +"""sim.steps follows the column names of the FDS User Guide (section "Time Step Data"), and the +error message for converting a container to a numpy array names a method the class has.""" + +import warnings +from pathlib import Path + +import numpy as np +import pytest + +from fdsreader import Simulation, settings +from fdsreader.simulation import StepData + +CASES_DIR = Path(__file__).parent / "cases" + + +@pytest.fixture +def steps(monkeypatch): + monkeypatch.setattr(settings, "ENABLE_CACHING", False) + monkeypatch.setattr(StepData, "_warned", False) + return Simulation(str(CASES_DIR / "steckler_data")).steps + + +def test_steps_columns(steps): + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert steps["Step"].dtype.kind == "i" + assert steps["Step"][:3].tolist() == [1, 2, 3] + assert steps["Wall Time"].dtype == np.dtype("datetime64[ms]") + assert steps["Wall Time"][0] == np.datetime64("2024-03-14T08:39:53.199") # +01:00 as UTC + assert steps["Simulation Time"][0] == pytest.approx(0.10823) + assert len({len(column) for column in steps.values()}) == 1 + + +def test_time_step_key_warns_once_and_still_holds_wall_time(steps): + with pytest.warns(FutureWarning, match="Wall Time") as record: + old = steps["Time Step"] + assert record[0].filename == __file__ # points at the caller, not into fdsreader + np.testing.assert_array_equal(old, steps["Wall Time"]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + steps["Time Step"] + + +def test_time_step_key_warns_through_get(steps): + with pytest.warns(FutureWarning): + assert steps.get("Time Step") is not None + assert steps.get("missing", 1) == 1 + + +def test_geomslice_array_hint_names_its_properties(): + geomslice = Simulation(str(CASES_DIR / "geomslice_data_fds611")).geomslices[0] + with pytest.raises(TypeError, match="'data', 'vertices' and 'faces'"): + np.asarray(geomslice) + + +def test_slice_array_hint_names_to_global(): + slc = Simulation(str(CASES_DIR / "steckler_data")).slices[0] + with pytest.raises(TypeError, match="to_global"): + np.asarray(slc) From e981d36773cc6c574a6564840799b7abb7b97677 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Thu, 15 Oct 2026 08:57:56 +0200 Subject: [PATCH 2/2] fix: bump the cache format for the new steps keys Co-Authored-By: Claude Opus 5.5 --- fdsreader/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdsreader/simulation.py b/fdsreader/simulation.py index c4cd462f..c6e4060d 100644 --- a/fdsreader/simulation.py +++ b/fdsreader/simulation.py @@ -120,7 +120,7 @@ class Simulation: # Bump when the pickled structure of a Simulation or its data classes changes, so caches # written by an older layout are parsed again instead of being loaded with missing attributes. - _CACHE_FORMAT = 2 + _CACHE_FORMAT = 3 _loading = False def __new__(cls, path: str):