Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion fdsreader/bndf/obstruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion fdsreader/export/slcf_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion fdsreader/geom/geometry_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion fdsreader/isof/isosurface_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
28 changes: 24 additions & 4 deletions fdsreader/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 (
Expand All @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions tests/test_crash_regressions.py
Original file line number Diff line number Diff line change
@@ -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
Loading