diff --git a/fdsreader/simulation.py b/fdsreader/simulation.py index 391a9ff..c6e4060 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 @@ -91,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): @@ -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 ed69de0..acbe9b9 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 bf13fad..231a554 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 69472fc..4af84f1 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 0000000..ad2c332 --- /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)