diff --git a/fdsreader/bndf/obstruction.py b/fdsreader/bndf/obstruction.py index ec3ed005..1f9c58e5 100644 --- a/fdsreader/bndf/obstruction.py +++ b/fdsreader/bndf/obstruction.py @@ -549,11 +549,16 @@ def get_coordinates( if orientation_int not in subobst_coords: continue co = subobst_coords[orientation_int][dim] - min_delta = min(min_delta, co[1] - co[0]) + if len(co) > 1: # a face one node wide has no spacing along this axis + min_delta = min(min_delta, co[1] - co[0]) coords[dim].update(co) coords[dim] = np.array(sorted(list(coords[dim]))) to_keep = np.full(coords[dim].shape, True) + if not math.isfinite(min_delta): # all faces one node wide: use the grid spacing + min_delta = min( + np.min(np.diff(subobst.mesh.coordinates[dim])) for subobst in self._all_subobstructions + ) for i in range(len(coords[dim]) - 1): if abs(coords[dim][i] - coords[dim][i + 1]) < min_delta / 2: to_keep[i + 1] = False diff --git a/fdsreader/export/slcf_exporter.py b/fdsreader/export/slcf_exporter.py index 83ea2d3e..dc638732 100644 --- a/fdsreader/export/slcf_exporter.py +++ b/fdsreader/export/slcf_exporter.py @@ -34,7 +34,9 @@ def export_slcf_raw(slc: Slice, output_dir: str, ordering: Literal["C", "F"] = " "Quantity": slc.quantity.name, } - filename_base = ("slice" + ("2D-" if slc2d else "3D-") + slc.id.lower()).replace(" ", "_").replace(".", "-") + # Slices without an ID are named after their first .sf file, which is unique per slice + name = slc.id or os.path.splitext(min(subslice.filename for subslice in slc.subslices))[0] + filename_base = ("slice" + ("2D-" if slc2d else "3D-") + name.lower()).replace(" ", "_").replace(".", "-") # Create all requested directories if they don't exist yet Path(os.path.join(output_dir, filename_base + "-data")).mkdir(parents=True, exist_ok=True) diff --git a/fdsreader/geom/geometry_collection.py b/fdsreader/geom/geometry_collection.py index 1b257511..3d0174be 100644 --- a/fdsreader/geom/geometry_collection.py +++ b/fdsreader/geom/geometry_collection.py @@ -19,7 +19,7 @@ def __init__(self, *geom_boundaries: Iterable[GeomBoundary]): @property def quantities(self) -> List[Quantity]: - return list({geom.name for geom in self}) + return list({geom.quantity for geom in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): """Filters all GeomBoundaries by a specific quantity.""" diff --git a/fdsreader/isof/isosurface_collection.py b/fdsreader/isof/isosurface_collection.py index fb5f8473..e3c00136 100644 --- a/fdsreader/isof/isosurface_collection.py +++ b/fdsreader/isof/isosurface_collection.py @@ -14,7 +14,7 @@ def __init__(self, *isosurfaces: Iterable[Isosurface]): @property def quantities(self) -> List[Quantity]: - return list({iso.name for iso in self}) + return list({iso.quantity for iso in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): """Filters all isosurfaces by a specific quantity.""" diff --git a/fdsreader/simulation.py b/fdsreader/simulation.py index 970358ec..f4fd6eca 100644 --- a/fdsreader/simulation.py +++ b/fdsreader/simulation.py @@ -5,6 +5,7 @@ import pickle import re import warnings +from datetime import datetime, timezone from typing import AnyStr, Dict, List, Sequence, TextIO, Tuple, Union import numpy as np @@ -41,6 +42,17 @@ def _plot3d_time(label: str, filename: str) -> float: return sign * (abs(int(seconds)) + int(hundredths) / 100) +def _wall_times_to_utc(stamps: Sequence[str]) -> np.ndarray: + """Parse the wall-clock time stamps of CHID_steps.csv as UTC, as numpy did before it + deprecated time zone offsets (and warned on every load).""" + utc = [] + for stamp in stamps: + time = datetime.fromisoformat(stamp.strip()) + # Without an offset the stamp stays as written; astimezone would assume the local time zone + utc.append(time.astimezone(timezone.utc).replace(tzinfo=None) if time.tzinfo else time) + return np.array(utc, dtype="datetime64[ms]") + + class Simulation: """Master class managing all data for a given simulation. @@ -235,7 +247,8 @@ def __init__(self, path: str): self.particles = self._particles self.particles._post_init() # If no evacs are simulates, initialize empty data container for consistency - if len(self._evacs) == 0: + # (also when Evac classes were registered but their data could not be loaded) + if not isinstance(self._evacs, EvacCollection): self.evacs = EvacCollection((), "", ()) self.meshes = MeshCollection(self._meshes) del ( @@ -259,6 +272,14 @@ def __init__(self, path: str): with open(Simulation._get_pickle_filename(self.root_path, self.chid), "wb") as pickle_file: pickle.dump(self, pickle_file, protocol=4) + if len(self.evacs) > 0: + warnings.warn( + "FDS+Evac has been removed from FDS, and fdsreader's evacuation support is deprecated: " + "it is no longer tested and may be removed in a future release.", + FutureWarning, + stacklevel=2, + ) + def parse_smv_file(self): # Global device list in registration order — used to resolve DEVICE_ACT indices. _devices_by_global_index: list[Device] = [] @@ -1175,9 +1196,8 @@ def _load_step_data(self, file_path: str) -> Dict[str, np.ndarray]: with open(file_path) as infile: infile.readline() keys = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")][2:] - timesteps = np.loadtxt( - file_path, dtype=np.dtype("datetime64[ms]"), delimiter=",", ndmin=1, usecols=1, skiprows=2 - ) + wall_times = np.loadtxt(file_path, dtype=str, delimiter=",", ndmin=1, usecols=1, skiprows=2) + timesteps = _wall_times_to_utc(wall_times) 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 diff --git a/tests/test_crash_regressions.py b/tests/test_crash_regressions.py new file mode 100644 index 00000000..69472fc4 --- /dev/null +++ b/tests/test_crash_regressions.py @@ -0,0 +1,125 @@ +"""Regression tests for small crashes and warnings found while writing the example notebooks.""" + +import os +import sys +import warnings +from pathlib import Path + +import numpy as np +import pytest + +from fdsreader import Simulation, settings +from fdsreader.evac import EvacCollection +from fdsreader.simulation import _wall_times_to_utc + +CASES_DIR = Path(__file__).parent / "cases" + +# datetime.fromisoformat accepts every ISO 8601 offset only from Python 3.11 on +_NEEDS_PY311 = pytest.mark.skipif(sys.version_info < (3, 11), reason="fromisoformat needs Python 3.11") + + +@pytest.mark.parametrize("case", ["bndf_data", "bndf_data_fds6100"]) +@pytest.mark.parametrize("obst_id", ["Wand_r", "Golf_brandfläche"]) +def test_get_coordinates_of_faces_one_node_wide(case, obst_id): + # Both obstructions are one cell thick, so some of their faces are one node wide. + obst = Simulation(str(CASES_DIR / case)).obstructions.get_by_id(obst_id) + coordinates = obst.get_coordinates() + assert set(coordinates) == set(obst.orientations) + for orientation, coords in coordinates.items(): + dim = ("x", "y", "z")[abs(orientation) - 1] + assert coords[dim] == pytest.approx([obst.bounding_box[dim][0 if orientation < 0 else 1]]) + for other in {"x", "y", "z"} - {dim}: + assert np.all(np.diff(coords[other]) > 0) + low, high = obst.bounding_box[other] + assert np.all((coords[other] >= low - 1e-6) & (coords[other] <= high + 1e-6)) + + +@pytest.mark.parametrize("case", ["geom_data", "geom_data_fds611"]) +def test_geometry_quantities(case): + geom_data = Simulation(str(CASES_DIR / case)).geom_data + assert set(geom_data.quantities) == {geom.quantity for geom in geom_data} + assert len(geom_data.quantities) > 0 + + +def test_isosurface_quantities(): + isosurfaces = Simulation(str(CASES_DIR / "steckler_data")).isosurfaces + assert set(isosurfaces.quantities) == {iso.quantity for iso in isosurfaces} + assert len(isosurfaces.quantities) > 0 + + +def test_steps_load_without_timezone_warning(monkeypatch): + monkeypatch.setattr(settings, "ENABLE_CACHING", False) # parse the steps file, not a cache + with warnings.catch_warnings(): + 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") + + +@pytest.mark.parametrize( + "stamp, utc", + [ + ("2024-03-14T09:39:53.199+01:00", "2024-03-14T08:39:53.199"), # the format FDS writes + ("2024-03-14T09:39:53.199-05:30", "2024-03-14T15:09:53.199"), + (" 2024-03-14T09:39:53.199", "2024-03-14T09:39:53.199"), + ("2024-03-14", "2024-03-14T00:00:00.000"), + pytest.param("2024-03-14T09:39:53.199+0100", "2024-03-14T08:39:53.199", marks=_NEEDS_PY311), + pytest.param("2024-03-14T09:39:53.199+01", "2024-03-14T08:39:53.199", marks=_NEEDS_PY311), + pytest.param("2024-03-14T09:39:53.199Z", "2024-03-14T09:39:53.199", marks=_NEEDS_PY311), + ], +) +def test_wall_times_to_utc(stamp, utc): + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert _wall_times_to_utc([stamp])[0] == np.datetime64(utc, "ms") + + +def test_wall_times_to_utc_per_row_offsets(): + # A run across the switch to daylight saving time changes the offset between rows + stamps = ["2024-03-31T01:59:59.000+01:00", "2024-03-31T03:00:01.000+02:00"] + utc = _wall_times_to_utc(stamps) + assert utc.dtype == np.dtype("datetime64[ms]") + assert utc[1] - utc[0] == np.timedelta64(2000, "ms") + assert _wall_times_to_utc([]).shape == (0,) + + +def test_export_of_slices_without_id(tmp_path): + pytest.importorskip("multiprocess") + pytest.importorskip("pathos") + from fdsreader.export import export_slcf_raw + + slc = Simulation(str(CASES_DIR / "steckler_data")).slices[0] + assert slc.id == "" + meta_path = export_slcf_raw(slc, str(tmp_path)) + stem = os.path.splitext(min(subslice.filename for subslice in slc.subslices))[0] + assert os.path.basename(meta_path) == f"slice2D-{stem.lower()}.yaml" + + +def test_evac_data_warns(monkeypatch): + monkeypatch.setattr(EvacCollection, "__len__", lambda self: 1) + with pytest.warns(FutureWarning, match="FDS\\+Evac") as record: + Simulation(str(CASES_DIR / "steckler_data")) + evac_warnings = [w for w in record if "FDS+Evac" in str(w.message)] + assert evac_warnings[0].filename == __file__ # points at the caller, not into fdsreader + + +def test_no_evac_warning_without_evac_data(): + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + Simulation(str(CASES_DIR / "steckler_data")) + + +def test_evac_classes_without_data_do_not_break_loading(monkeypatch, tmp_path): + import shutil + + monkeypatch.setattr(settings, "ENABLE_CACHING", False) + case = tmp_path / "steckler" + shutil.copytree(CASES_DIR / "steckler_data", case) + smv = next(case.glob("*.smv")) + # An Evac class definition without any EVA5 data block, as left by a failed Evac run + with open(smv, "a") as f: + f.write("CLASS_OF_HUMANS\n Adult % % \n 1.0 0.0 0.0\n 0\n") + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + sim = Simulation(str(case)) + assert len(sim.evacs) == 0