From 924951e9fee05f1aac60462e8feccce7aaf14710 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Sun, 3 May 2026 19:19:02 +0200 Subject: [PATCH 1/7] modernize: switch to setuptools-scm, add ruff, overhaul CI/CD and tests Build system: - Replace incremental version management with setuptools-scm (git-tag-based) - Remove fdsreader/_version.py, use importlib.metadata.version() at runtime - Raise minimum Python version to 3.10, add 3.11/3.12 classifiers - Replace requirements.txt (160+ packages) with requirements-dev.txt (5 packages) Linting: - Add ruff configuration (line-length=120, select E/F/W/I/UP) - Fix all ruff violations across the codebase (isinstance, wildcard imports, unused imports, explicit re-exports, fortran_data isinstance bug) - Fix circular import in evac/evac_collection.py Tests: - Add tests/conftest.py: set CWD to tests/cases/ so acceptance tests work when pytest is run from the project root - Replace unittest-style test_version_compatibility.py with pytest-based test_basic_read.py (6 tests covering chid, meshes, slices, devices, obstructions) - Extend all acceptance tests with robustness checks: non-empty time arrays, monotonically increasing times, data shape consistency, no NaN/Inf in arrays, quantity metadata present CI/CD: - Replace testsuite.yml with ci.yml: matrix testing on Python 3.10/3.11/3.12, separate lint job, codecov upload - Replace publish_n_pack.yml with release.yml: tag-driven OIDC publishing, beta tags go to TestPyPI, stable tags go to PyPI + GitHub Release - Add fds_compat.yml: weekly cron job that detects new FDS releases and runs compatibility checks automatically - Add .github/last_tested_fds_version.txt to track last tested FDS version Docs: - Add FDS version compatibility table to README - Add CI and codecov badges to README - Update release instructions (incremental -> git tag) --- .github/last_tested_fds_version.txt | 1 + .github/workflows/ci.yml | 53 +++ .github/workflows/fds_compat.yml | 81 +++++ .github/workflows/publish_n_pack.yml | 5 +- .github/workflows/release.yml | 65 ++++ .github/workflows/testsuite.yml | 42 --- MANIFEST.in | 2 +- README.md | 51 ++- fdsreader/__init__.py | 11 +- fdsreader/_version.py | 11 - fdsreader/bndf/__init__.py | 8 +- fdsreader/bndf/obstruction.py | 373 +++++++++++--------- fdsreader/bndf/obstruction_collection.py | 16 +- fdsreader/bndf/utils.py | 5 +- fdsreader/devc/__init__.py | 5 +- fdsreader/devc/device.py | 27 +- fdsreader/devc/device_collection.py | 25 +- fdsreader/evac/__init__.py | 5 +- fdsreader/evac/evac_collection.py | 226 +++++++------ fdsreader/evac/evacuation.py | 37 +- fdsreader/export/__init__.py | 8 +- fdsreader/export/obst_exporter.py | 60 ++-- fdsreader/export/sim_exporter.py | 17 +- fdsreader/export/slcf_exporter.py | 56 +-- fdsreader/export/smoke3d_exporter.py | 51 ++- fdsreader/fds_classes/__init__.py | 11 +- fdsreader/fds_classes/mesh.py | 95 ++++-- fdsreader/fds_classes/mesh_collection.py | 7 +- fdsreader/fds_classes/surface.py | 17 +- fdsreader/fds_classes/ventilation.py | 23 +- fdsreader/geom/__init__.py | 6 +- fdsreader/geom/geometry.py | 75 ++--- fdsreader/geom/geometry_collection.py | 18 +- fdsreader/isof/__init__.py | 5 +- fdsreader/isof/isosurface.py | 176 +++++----- fdsreader/isof/isosurface_collection.py | 18 +- fdsreader/part/__init__.py | 5 +- fdsreader/part/particle.py | 15 +- fdsreader/part/particle_collection.py | 49 +-- fdsreader/pl3d/__init__.py | 5 +- fdsreader/pl3d/pl3d.py | 143 ++++---- fdsreader/pl3d/plot3D_collection.py | 19 +- fdsreader/simulation.py | 411 +++++++++++++---------- fdsreader/slcf/__init__.py | 11 +- fdsreader/slcf/geomslice.py | 124 +++---- fdsreader/slcf/geomslice_collection.py | 27 +- fdsreader/slcf/slice.py | 392 ++++++++++----------- fdsreader/slcf/slice_collection.py | 27 +- fdsreader/smoke3d/__init__.py | 5 +- fdsreader/smoke3d/smoke3D_collection.py | 16 +- fdsreader/smoke3d/smoke3d.py | 161 +++++---- fdsreader/utils/__init__.py | 10 +- fdsreader/utils/data.py | 25 +- fdsreader/utils/dimension.py | 19 +- fdsreader/utils/extent.py | 46 ++- fdsreader/utils/fortran_data.py | 43 ++- fdsreader/utils/misc.py | 12 +- pyproject.toml | 75 ++++- requirements-dev.txt | 5 + tests/acceptance_tests/test_bndf.py | 40 ++- tests/acceptance_tests/test_devc.py | 32 +- tests/acceptance_tests/test_geom.py | 30 +- tests/acceptance_tests/test_isof.py | 37 +- tests/acceptance_tests/test_part.py | 33 +- tests/acceptance_tests/test_pl3d.py | 31 +- tests/acceptance_tests/test_slcf.py | 44 ++- tests/acceptance_tests/test_smoke3d.py | 42 ++- tests/conftest.py | 9 + tests/test_basic_read.py | 45 +++ tests/test_version_compatibility.py | 16 - 70 files changed, 2200 insertions(+), 1496 deletions(-) create mode 100644 .github/last_tested_fds_version.txt create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/fds_compat.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/testsuite.yml delete mode 100644 fdsreader/_version.py create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_basic_read.py delete mode 100644 tests/test_version_compatibility.py diff --git a/.github/last_tested_fds_version.txt b/.github/last_tested_fds_version.txt new file mode 100644 index 00000000..26c4cb5f --- /dev/null +++ b/.github/last_tested_fds_version.txt @@ -0,0 +1 @@ +FDS-6.8.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a3ce405e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [master] + paths: + - "fdsreader/**" + - "tests/**" + - "pyproject.toml" + - ".github/workflows/ci.yml" + pull_request: + paths: + - "fdsreader/**" + - "tests/**" + - "pyproject.toml" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check fdsreader/ + - run: ruff format --check fdsreader/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + run: pip install ".[dev]" + - name: Prepare test cases + run: cd tests/cases && for f in *.tgz; do tar -xzvf "$f"; done + - name: Run tests + run: pytest tests/ --cov=fdsreader --cov-report=xml --cov-report=term-missing + - name: Upload coverage + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml diff --git a/.github/workflows/fds_compat.yml b/.github/workflows/fds_compat.yml new file mode 100644 index 00000000..71f1d29f --- /dev/null +++ b/.github/workflows/fds_compat.yml @@ -0,0 +1,81 @@ +name: FDS Compatibility Check + +on: + workflow_dispatch: + inputs: + fds_version: + description: "FDS version tag (e.g. FDS-6.10.1) — leave empty for latest" + required: false + schedule: + - cron: "0 6 * * 1" # Every Monday 06:00 UTC + +jobs: + detect-version: + runs-on: ubuntu-latest + outputs: + run_compat: ${{ steps.check.outputs.run_compat }} + fds_version: ${{ steps.check.outputs.fds_version }} + steps: + - uses: actions/checkout@v4 + - id: check + run: | + if [ -n "${{ inputs.fds_version }}" ]; then + echo "fds_version=${{ inputs.fds_version }}" >> $GITHUB_OUTPUT + echo "run_compat=true" >> $GITHUB_OUTPUT + else + LATEST=$(gh api repos/firemodels/fds/releases/latest --jq '.tag_name') + LAST=$(cat .github/last_tested_fds_version.txt 2>/dev/null || echo "none") + echo "fds_version=$LATEST" >> $GITHUB_OUTPUT + if [ "$LATEST" != "$LAST" ]; then + echo "run_compat=true" >> $GITHUB_OUTPUT + else + echo "run_compat=false" >> $GITHUB_OUTPUT + fi + fi + env: + GH_TOKEN: ${{ github.token }} + + check-compat: + needs: detect-version + if: needs.detect-version.outputs.run_compat == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Download FDS + run: | + VERSION=${{ needs.detect-version.outputs.fds_version }} + wget -q https://github.com/firemodels/fds/releases/download/${VERSION}/FDS-${VERSION#FDS-}_SMV-${VERSION#FDS-}_lnx.sh \ + -O fds_install.sh || \ + wget -q https://github.com/firemodels/fds/releases/download/${VERSION}/FDS-6.10.1_SMV-6.10.1_lnx.sh \ + -O fds_install.sh + echo "" | bash fds_install.sh --prefix=$HOME/fds + - name: Run test simulations + run: | + FDS=$HOME/fds/bin/fds_openmp + for dir in tests/cases/fds_inputs/*.fds; do + name=$(basename "$dir" .fds) + mkdir -p /tmp/fds_out/$name + cd /tmp/fds_out/$name + $FDS "$GITHUB_WORKSPACE/$dir" || true + cd $GITHUB_WORKSPACE + done + - name: Install fdsreader and run tests + run: | + pip install ".[dev]" + cd tests/cases && for f in *.tgz; do tar -xzvf "$f"; done + cd ../.. + pytest tests/ -v + - name: Update tracked FDS version + if: success() + run: | + echo "${{ needs.detect-version.outputs.fds_version }}" > .github/last_tested_fds_version.txt + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/last_tested_fds_version.txt + git diff --staged --quiet || git commit -m "chore: update last tested FDS version to ${{ needs.detect-version.outputs.fds_version }}" + git push diff --git a/.github/workflows/publish_n_pack.yml b/.github/workflows/publish_n_pack.yml index 9df9fa75..e282f24d 100644 --- a/.github/workflows/publish_n_pack.yml +++ b/.github/workflows/publish_n_pack.yml @@ -1,6 +1,9 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI -on: push +on: + push: + tags: + - "v*" jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c9a36ea7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install build + - run: python -m build + - uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-testpypi: + if: contains(github.ref, 'b') || contains(github.ref, 'a') || contains(github.ref, 'rc') + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/fdsreader + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + if: "!contains(github.ref, 'a') && !contains(github.ref, 'b') && !contains(github.ref, 'rc')" + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/fdsreader + permissions: + id-token: write + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release create '${{ github.ref_name }}' dist/** \ + --repo '${{ github.repository }}' \ + --generate-notes diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml deleted file mode 100644 index ec25b003..00000000 --- a/.github/workflows/testsuite.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: FDSreader testsuite - -on: - workflow_dispatch: {} - push: - branches: - - master - paths: - - 'fdsreader/**' - pull_request: - paths: - - 'fdsreader/**' - -permissions: - contents: read - -jobs: - run-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - lfs: true - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest numpy - pip install ${{ github.workspace }} - - name: Prepare test cases - run: | - cd ${{ github.workspace }}/tests/cases/ - for f in *.tgz; do tar -xzvf "$f"; done - - name: Test with pytest - run: | - cd ${{ github.workspace }}/tests/cases - pytest ../acceptance_tests/ diff --git a/MANIFEST.in b/MANIFEST.in index 66cabd65..38a101ad 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include requirements.txt +include requirements-dev.txt include README.md \ No newline at end of file diff --git a/README.md b/README.md index 205836f6..f71182c0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # FDSReader > Fast and easy-to-use Python reader for FDS data -[![PyPI version](https://badge.fury.io/py/fdsreader.png)](https://badge.fury.io/py/fdsreader) +[![PyPI version](https://badge.fury.io/py/fdsreader.png)](https://badge.fury.io/py/fdsreader) +[![CI](https://github.com/FireDynamics/fdsreader/actions/workflows/ci.yml/badge.svg)](https://github.com/FireDynamics/fdsreader/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/FireDynamics/fdsreader/branch/master/graph/badge.svg)](https://codecov.io/gh/FireDynamics/fdsreader) +## FDS Version Compatibility + +| fdsreader | FDS 6.7 | FDS 6.8 | FDS 6.9 | FDS 6.10 | +|-------------------|---------|---------|---------|----------| +| ≤ 1.11.x | ✅ | ✅ | ✅ | ⚠️ (Geometry bug, [#TODO](https://github.com/FireDynamics/fdsreader/issues)) | +| 1.12.x *(planed)* | ✅ | ✅ | ✅ | ✅ | + +_Tested against FDS outputs. If you find a compatibility issue please [open an issue](https://github.com/FireDynamics/fdsreader/issues)._ ## Installation @@ -52,34 +62,17 @@ documentation of all classes check the API Documentation below. ## API Documentation [https://firedynamics.github.io/fdsreader/](https://firedynamics.github.io/fdsreader/) -Deployment now follows the [Python Packaging User Guide's recommendation](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/). - -With this setup, deployments to both TestPyPI and PyPI are automated. Every push to GitHub triggers a deployment to TestPyPI, simplifying the testing of new changes and validating the CI pipeline. Therefore, it is necessary to set the package version to `.dev` to avoid blocking version numbers. - -### Deploying an Untested/Unstable Version: -1. Execute: `python3 -m incremental.update fdsreader --dev` -2. Push changes to GitHub. - -If you are sure your changes are stable push a GitHub Tag to perform deployment -to PyPI and to pack a GitHub Release -Deploying a tested/stable version: -1. set the new version with `python3 -m incremental.update fdsreader --newversion=` -2. Push changes to GitHub -3. Create tag `git tag -a v -m "Version "` -4. Push Tag to GitHub with `git push origin tag ` - - -### Manual deployment -It is also possible to deploy to PyPI and Github pages manually using the following steps: -1. python setup.py sdist bdist_wheel -2. twine upload dist/* -3. sphinx-build -b html docs docs/build -4. cd .. && mkdir gh-pages && cd gh-pages -5. git init && git remote add origin git@github.com:FireDynamics/fdsreader.git -6. git fetch origin gh-pages:gh-pages -7. git checkout gh-pages -8. cp -r ../fdsreader/docs/build/* . -9. git add . && git commit -m "..." && git push origin HEAD:gh-pages +## Releasing a new version + +Versioning is handled automatically via Git tags using `setuptools-scm`. + +```bash +# New release +git tag -a v1.12.0 -m "Version 1.12.0" +git push origin v1.12.0 +``` + +This triggers the release workflow which builds the package and publishes it to PyPI. ## Meta diff --git a/fdsreader/__init__.py b/fdsreader/__init__.py index bf8a3c55..b152f717 100644 --- a/fdsreader/__init__.py +++ b/fdsreader/__init__.py @@ -1,6 +1,9 @@ -from . import _version -__version__ = str(_version.__version__.public()) +from importlib.metadata import PackageNotFoundError, version -from .simulation import Simulation +try: + __version__ = version("fdsreader") +except PackageNotFoundError: + __version__ = "unknown" -from . import settings +from . import settings as settings +from .simulation import Simulation as Simulation diff --git a/fdsreader/_version.py b/fdsreader/_version.py deleted file mode 100644 index 5a76cd7b..00000000 --- a/fdsreader/_version.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Provides fdsreader version information. -""" - -# This file is auto-generated! Do not edit! -# Use `python -m incremental.update fdsreader` to change this file. - -from incremental import Version - -__version__ = Version("fdsreader", 1, 11, 7) -__all__ = ["__version__"] diff --git a/fdsreader/bndf/__init__.py b/fdsreader/bndf/__init__.py index 1439627a..0480f135 100644 --- a/fdsreader/bndf/__init__.py +++ b/fdsreader/bndf/__init__.py @@ -1,3 +1,5 @@ -from .obstruction import Obstruction, SubObstruction, Patch, Boundary - -from .obstruction_collection import ObstructionCollection +from .obstruction import Boundary as Boundary +from .obstruction import Obstruction as Obstruction +from .obstruction import Patch as Patch +from .obstruction import SubObstruction as SubObstruction +from .obstruction_collection import ObstructionCollection as ObstructionCollection diff --git a/fdsreader/bndf/obstruction.py b/fdsreader/bndf/obstruction.py index d9ba6a41..ba8c8b00 100644 --- a/fdsreader/bndf/obstruction.py +++ b/fdsreader/bndf/obstruction.py @@ -1,16 +1,15 @@ import math import os -from typing import List, Dict, Tuple, Union, Sequence -from typing_extensions import Literal -import numpy as np +# Unfortunately, this is necessary due to a cyclic reference. "Mesh" is only needed for static type hints anyway +from typing import TYPE_CHECKING, Dict, List, Sequence, Tuple, Union + +import numpy as np +from typing_extensions import Literal -from fdsreader.utils import Extent, Quantity, Dimension import fdsreader.utils.fortran_data as fdtype from fdsreader import settings - -# Unfortunately, this is necessary due to a cyclic reference. "Mesh" is only needed for static type hints anyway -from typing import TYPE_CHECKING +from fdsreader.utils import Dimension, Extent, Quantity if TYPE_CHECKING: from fdsreader.fds_classes import Mesh @@ -27,8 +26,18 @@ class Patch: :ivar _n_t: Total number of time steps for which output data has been written. """ - def __init__(self, file_path: str, dimension: Dimension, extent: Extent, orientation: int, cell_centered: bool, - patch_offset: int, initial_offset: int, n_t: int, mesh: 'Mesh'): + def __init__( + self, + file_path: str, + dimension: Dimension, + extent: Extent, + orientation: int, + cell_centered: bool, + patch_offset: int, + initial_offset: int, + n_t: int, + mesh: "Mesh", + ): self.file_path = file_path self.dimension = dimension self.extent = extent @@ -39,7 +48,7 @@ def __init__(self, file_path: str, dimension: Dimension, extent: Extent, orienta self._time_offset = -1 self._n_t = n_t self.mesh = mesh - self._boundary_parent: 'Boundary' = None + self._boundary_parent: Boundary = None def n_t(self, count_duplicates=True) -> int: """Get the number of timesteps for which data was output. @@ -53,31 +62,28 @@ def n_t(self, count_duplicates=True) -> int: @property def shape(self) -> Tuple: - """Convenience function to calculate the shape of the array containing data for this patch. - """ + """Convenience function to calculate the shape of the array containing data for this patch.""" return self.dimension.shape(self.cell_centered) @property def size(self) -> int: - """Convenience function to calculate the number of data points in the array for this patch. - """ + """Convenience function to calculate the number of data points in the array for this patch.""" return self.dimension.size(self.cell_centered) def _post_init(self, time_offset: int): - """Fully initialize the patch as soon as the number of timesteps is known. - """ + """Fully initialize the patch as soon as the number of timesteps is known.""" self._time_offset = time_offset - def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[Literal['x', 'y', 'z'], np.ndarray]: + def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[Literal["x", "y", "z"], np.ndarray]: """Returns a dictionary containing a numpy ndarray with coordinates for each dimension. - For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. + For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. - :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. + :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. """ # orientation = ('x', 'y', 'z')[self.orientation - 1] if self.orientation != 0 else '' # coords = {'x': set(), 'y': set(), 'z': set()} - coords: Dict[Literal['x', 'y', 'z'], np.ndarray] = {} - for dim in ('x', 'y', 'z'): + coords: Dict[Literal["x", "y", "z"], np.ndarray] = {} + for dim in ("x", "y", "z"): co = self.mesh.coordinates[dim].copy() # In case the slice is cell-centered, we will shift the coordinates by half a cell # and remove the last coordinate @@ -93,20 +99,20 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[Literal['x @property def data(self): - """Method to load the quantity data for a single patch. - """ + """Method to load the quantity data for a single patch.""" if not hasattr(self, "_data"): - dtype_data = fdtype.new((('f', self.dimension.size(cell_centered=False)),)) + dtype_data = fdtype.new((("f", self.dimension.size(cell_centered=False)),)) if self._n_t == -1: self._n_t = (os.stat(self.file_path).st_size - self._initial_offset) // self._time_offset self._data = np.empty((self.n_t(count_duplicates=True),) + self.shape) - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: for t in range(self.n_t(count_duplicates=True)): infile.seek(self._initial_offset + self._patch_offset + t * self._time_offset) data = np.fromfile(infile, dtype_data, 1)[0][1].reshape( - self.dimension.shape(cell_centered=False), order='F') + self.dimension.shape(cell_centered=False), order="F" + ) if self.cell_centered: self._data[t, :] = data[:-1, :-1] else: @@ -116,8 +122,7 @@ def data(self): return self._data def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data @@ -137,8 +142,15 @@ class Boundary: :ivar n_t: Total number of time steps for which output data has been written. """ - def __init__(self, quantity: Quantity, cell_centered: bool, times: Sequence[float], patches: List[Patch], - lower_bounds: np.ndarray, upper_bounds: np.ndarray): + def __init__( + self, + quantity: Quantity, + cell_centered: bool, + times: Sequence[float], + patches: List[Patch], + lower_bounds: np.ndarray, + upper_bounds: np.ndarray, + ): self.quantity = quantity self.cell_centered = cell_centered self._patches = patches @@ -146,7 +158,6 @@ def __init__(self, quantity: Quantity, cell_centered: bool, times: Sequence[floa self.lower_bounds = lower_bounds self.upper_bounds = upper_bounds - def n_t(self, count_duplicates=True) -> int: """Get the number of timesteps for which data was output. :param count_duplicates: If true, return the total number of data points, even if there is @@ -157,24 +168,22 @@ def n_t(self, count_duplicates=True) -> int: @property def orientations(self): - """Return all orientations for which there is data available. - """ + """Return all orientations for which there is data available.""" return [p.orientation for p in self._patches] def get_nearest_timestep(self, time: float) -> int: - """Calculates the nearest timestep for which data has been output for this obstruction. - """ + """Calculates the nearest timestep for which data has been output for this obstruction.""" idx = np.searchsorted(self.times, time, side="left") - if time > 0 and (idx == len(self.times) or math.fabs( - time - self.times[idx - 1]) < math.fabs(time - self.times[idx])): + if time > 0 and ( + idx == len(self.times) or math.fabs(time - self.times[idx - 1]) < math.fabs(time - self.times[idx]) + ): return idx - 1 else: return idx @property def data(self) -> Dict[int, Patch]: - """The :class:`Patch` in each direction (-3=-z, -2=-y, -1=-x, 1=x, 2=y, 3=y). - """ + """The :class:`Patch` in each direction (-3=-z, -2=-y, -1=-x, 1=x, 2=y, 3=y).""" return {p.orientation: p for p in self._patches} def vmin(self, orientation: Literal[-3, -2, -1, 0, 1, 2, 3] = 0) -> float: @@ -204,8 +213,7 @@ def vmax(self, orientation: Literal[-3, -2, -1, 0, 1, 2, 3] = 0) -> float: return float(np.max(self.data[orientation].data)) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for p in self._patches: p.clear_cache() @@ -223,13 +231,16 @@ class SubObstruction: :ivar show_times: List with points in time from when on the SubObstruction will be shown. """ - def __init__(self, side_surfaces: Tuple, bound_indices: Tuple[int, int, int, int, int, int], - extent: Extent, mesh: 'Mesh'): + def __init__( + self, side_surfaces: Tuple, bound_indices: Tuple[int, int, int, int, int, int], extent: Extent, mesh: "Mesh" + ): self.extent = extent self.side_surfaces = side_surfaces - self.bound_indices = {'x': (bound_indices[0], bound_indices[1]), - 'y': (bound_indices[2], bound_indices[3]), - 'z': (bound_indices[4], bound_indices[5])} + self.bound_indices = { + "x": (bound_indices[0], bound_indices[1]), + "y": (bound_indices[2], bound_indices[3]), + "z": (bound_indices[4], bound_indices[5]), + } self.mesh = mesh self._boundary_data: Dict[int, Boundary] = dict() @@ -237,12 +248,22 @@ def __init__(self, side_surfaces: Tuple, bound_indices: Tuple[int, int, int, int self.hide_times = list() self.show_times = list() - def _add_patches(self, bid: int, cell_centered: bool, quantity: str, short_name: str, unit: str, - patches: List[Patch], times: Sequence[float], lower_bounds: np.ndarray, - upper_bounds: np.ndarray): + def _add_patches( + self, + bid: int, + cell_centered: bool, + quantity: str, + short_name: str, + unit: str, + patches: List[Patch], + times: Sequence[float], + lower_bounds: np.ndarray, + upper_bounds: np.ndarray, + ): if bid not in self._boundary_data: - self._boundary_data[bid] = Boundary(Quantity(quantity, short_name, unit), cell_centered, times, - patches, lower_bounds, upper_bounds) + self._boundary_data[bid] = Boundary( + Quantity(quantity, short_name, unit), cell_centered, times, patches, lower_bounds, upper_bounds + ) # Add reference to parent boundary class in patches for patch in patches: patch._boundary_parent = self._boundary_data[bid] @@ -252,32 +273,32 @@ def _add_patches(self, bid: int, cell_centered: bool, quantity: str, short_name: @property def orientations(self): - """Return all orientations for which there is data available. - """ + """Return all orientations for which there is data available.""" if self.has_boundary_data: return next(iter(self._boundary_data.values())).orientations return [] - def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ - int, Dict[Literal['x', 'y', 'z'], np.ndarray]]: + def get_coordinates( + self, ignore_cell_centered: bool = False + ) -> Dict[int, Dict[Literal["x", "y", "z"], np.ndarray]]: """Returns a dictionary containing a numpy ndarray with coordinates for each dimension. - For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. + For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. - :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. + :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. """ if self.has_boundary_data: - return {orientation: patch.get_coordinates(ignore_cell_centered) for orientation, patch in - next(iter(self._boundary_data.values())).data.items()} + return { + orientation: patch.get_coordinates(ignore_cell_centered) + for orientation, patch in next(iter(self._boundary_data.values())).data.items() + } return {} - def get_nearest_index(self, dimension: Literal['x', 'y', 'z'], orientation: int, value: float) -> int: - """Get the nearest mesh coordinate index in a specific dimension for a specific orientation. - """ + def get_nearest_index(self, dimension: Literal["x", "y", "z"], orientation: int, value: float) -> int: + """Get the nearest mesh coordinate index in a specific dimension for a specific orientation.""" if self.has_boundary_data: coords = self.get_coordinates()[orientation][dimension] idx = np.searchsorted(coords, value, side="left") - if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs( - value - coords[idx])): + if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs(value - coords[idx])): return idx - 1 else: return idx @@ -288,13 +309,16 @@ def has_boundary_data(self): return len(self._boundary_data) != 0 def get_data(self, quantity: Union[str, Quantity]): - if type(quantity) == Quantity: + if isinstance(quantity, Quantity): quantity = quantity.name - return next(b for b in self._boundary_data.values() if - b.quantity.name.lower() == quantity.lower() or b.quantity.short_name.lower() == quantity.lower()) + return next( + b + for b in self._boundary_data.values() + if b.quantity.name.lower() == quantity.lower() or b.quantity.short_name.lower() == quantity.lower() + ) def __getitem__(self, item): - if type(item) == int: + if isinstance(item, int): return self._boundary_data[item] return self.get_data(item) @@ -318,15 +342,13 @@ def n_t(self, count_duplicates=True) -> int: @property def times(self): - """Return all timesteps for which boundary data is available, if any. - """ + """Return all timesteps for which boundary data is available, if any.""" if self.has_boundary_data: return next(iter(self._boundary_data.values())).times return np.array([]) def get_visible_times(self, times: Sequence[float]) -> np.ndarray: - """Returns a ndarray filtering all time steps when the SubObstruction is visible/not hidden. - """ + """Returns a ndarray filtering all time steps when the SubObstruction is visible/not hidden.""" ret = list() hidden = False for time in times: @@ -357,8 +379,7 @@ def vmax(self, quantity: Union[str, Quantity], orientation: Literal[-3, -2, -1, return np.nan def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for bndf in self._boundary_data.values(): bndf.clear_cache() @@ -386,8 +407,14 @@ class Obstruction: (ranging from 0.0 to 1.0). """ - def __init__(self, oid: str, color_index: int, block_type: int, texture_origin: Tuple[float, float, float], - rgba: Union[Tuple[()], Tuple[float, float, float, float]] = ()): + def __init__( + self, + oid: str, + color_index: int, + block_type: int, + texture_origin: Tuple[float, float, float], + rgba: Union[Tuple[()], Tuple[float, float, float, float]] = (), + ): self.id = oid self.color_index = color_index self.block_type = block_type @@ -399,18 +426,21 @@ def __init__(self, oid: str, color_index: int, block_type: int, texture_origin: @property def bounding_box(self) -> Extent: - """:class:`Extent` object representing the bounding box around the Obstruction. - """ + """:class:`Extent` object representing the bounding box around the Obstruction.""" extents = [sub.extent for sub in self._subobstructions.values()] - return Extent(min(extents, key=lambda e: e.x_start).x_start, max(extents, key=lambda e: e.x_end).x_end, - min(extents, key=lambda e: e.y_start).y_start, max(extents, key=lambda e: e.y_end).y_end, - min(extents, key=lambda e: e.z_start).z_start, max(extents, key=lambda e: e.z_end).z_end) + return Extent( + min(extents, key=lambda e: e.x_start).x_start, + max(extents, key=lambda e: e.x_end).x_end, + min(extents, key=lambda e: e.y_start).y_start, + max(extents, key=lambda e: e.y_end).y_end, + min(extents, key=lambda e: e.z_start).z_start, + max(extents, key=lambda e: e.z_end).z_end, + ) @property def orientations(self): - """Return all orientations for which there is data available. - """ + """Return all orientations for which there is data available.""" if self.has_boundary_data: orientations = set() for subobst in self._subobstructions.values(): @@ -431,33 +461,32 @@ def n_t(self, count_duplicates=True) -> int: @property def times(self): - """Return all timesteps for which boundary data is available, if any. - """ + """Return all timesteps for which boundary data is available, if any.""" for subobst in self._subobstructions.values(): if subobst.has_boundary_data: return subobst.times return np.array([]) def get_visible_times(self, times: Sequence[float]): - """Returns an ndarray filtering all time steps when theSubObstruction is visible/not hidden. - """ + """Returns an ndarray filtering all time steps when theSubObstruction is visible/not hidden.""" for subobst in self._subobstructions.values(): return subobst.get_visible_times(times) return np.array([]) - def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ - int, Dict[Literal['x', 'y', 'z'], np.ndarray]]: + def get_coordinates( + self, ignore_cell_centered: bool = False + ) -> Dict[int, Dict[Literal["x", "y", "z"], np.ndarray]]: """Returns a dictionary containing a numpy ndarray with coordinates for each dimension. - For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. + For cell-centered boundary data, the coordinates can be adjusted to represent cell-centered coordinates. - :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. + :param ignore_cell_centered: Whether to shift the coordinates when the bndf is cell_centered or not. """ if self.has_boundary_data: all_coords = dict() for orientation_int in self.orientations: - orientation = ('x', 'y', 'z')[abs(orientation_int) - 1] - coords = {'x': set(), 'y': set(), 'z': set()} - for dim in ('x', 'y', 'z'): + orientation = ("x", "y", "z")[abs(orientation_int) - 1] + coords = {"x": set(), "y": set(), "z": set()} + for dim in ("x", "y", "z"): if orientation == dim: bounding_box_index = 0 if orientation_int < 0 else 1 coords[dim] = np.array([self.bounding_box[dim][bounding_box_index]]) @@ -486,9 +515,11 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ mesh = subobst.mesh mesh_coords = mesh.coordinates[dim] idx = np.searchsorted(mesh_coords, single_coordinate, side="left") - if idx > 0 and (idx == mesh_coords.size or math.fabs( - single_coordinate - mesh_coords[idx - 1]) < math.fabs( - single_coordinate - mesh_coords[idx])): + if idx > 0 and ( + idx == mesh_coords.size + or math.fabs(single_coordinate - mesh_coords[idx - 1]) + < math.fabs(single_coordinate - mesh_coords[idx]) + ): idx = idx + 1 if mesh_coords[idx] - single_coordinate < nearest_coordinate - single_coordinate: nearest_coordinate = mesh_coords[idx] @@ -498,14 +529,12 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ return all_coords return dict() - def get_nearest_index(self, dimension: Literal['x', 'y', 'z'], orientation: int, value: float) -> int: - """Get the nearest mesh coordinate index in a specific dimension for a specific orientation. - """ + def get_nearest_index(self, dimension: Literal["x", "y", "z"], orientation: int, value: float) -> int: + """Get the nearest mesh coordinate index in a specific dimension for a specific orientation.""" if self.has_boundary_data: coords = self.get_coordinates()[orientation][dimension] idx = np.searchsorted(coords, value, side="left") - if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs( - value - coords[idx])): + if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs(value - coords[idx])): return idx - 1 else: return idx @@ -513,8 +542,7 @@ def get_nearest_index(self, dimension: Literal['x', 'y', 'z'], orientation: int, @property def quantities(self) -> List[Quantity]: - """Get a list of all quantities for which boundary data exists. - """ + """Get a list of all quantities for which boundary data exists.""" if self.has_boundary_data: qs = set() for subobst in self._subobstructions.values(): @@ -524,45 +552,45 @@ def quantities(self) -> List[Quantity]: return [] @property - def meshes(self) -> List['Mesh']: - """Returns a list of all meshes this slice cuts through. - """ + def meshes(self) -> List["Mesh"]: + """Returns a list of all meshes this slice cuts through.""" return [subobst.mesh for subobst in self._subobstructions.values()] def filter_by_orientation(self, orientation: Literal[-3, -2, -1, 0, 1, 2, 3] = 0) -> List[SubObstruction]: """Filter all SubObstructions by a specific orientation. All returned SubObstructions will contain boundary data - in the specified orientation. + in the specified orientation. """ if self.has_boundary_data: - return [subobst for subobst in self._subobstructions.values() if - orientation in subobst.orientations] + return [subobst for subobst in self._subobstructions.values() if orientation in subobst.orientations] return [] - def get_boundary_data(self, quantity: Union[Quantity, str], - orientation: Literal[-3, -2, -1, 0, 1, 2, 3] = 0) -> Dict[str, Boundary]: + def get_boundary_data( + self, quantity: Union[Quantity, str], orientation: Literal[-3, -2, -1, 0, 1, 2, 3] = 0 + ) -> Dict[str, Boundary]: """Gets the boundary data for a specific quantity of all SubObstructions. :param quantity: The quantity to filter by. :param orientation: Optionally filter by a specific orientation as well (-3=-z, -2=-y, -1=-x, 1=x, 2=y, 3=z). A value of 0 indicates to no filter. """ - if type(quantity) == Quantity: + if isinstance(quantity, Quantity): quantity = quantity.name - ret = {subobst.mesh.id: subobst.get_data(quantity) for subobst in self._subobstructions.values() if - subobst.has_boundary_data} + ret = { + subobst.mesh.id: subobst.get_data(quantity) + for subobst in self._subobstructions.values() + if subobst.has_boundary_data + } if orientation == 0: return ret return {mesh: bndf for mesh, bndf in ret.items() if orientation in bndf.data.keys()} def get_nearest_timestep(self, time: float, visible_only: bool = False) -> int: - """Calculates the nearest timestep for which data has been output for this obstruction. - """ + """Calculates the nearest timestep for which data has been output for this obstruction.""" if self.has_boundary_data: times = self.get_visible_times(self.times) if visible_only else self.times idx = np.searchsorted(times, time, side="left") - if time > 0 and (idx == len(times) or math.fabs( - time - times[idx - 1]) < math.fabs(time - times[idx])): + if time > 0 and (idx == len(times) or math.fabs(time - times[idx - 1]) < math.fabs(time - times[idx])): return idx - 1 else: return idx @@ -570,7 +598,7 @@ def get_nearest_timestep(self, time: float, visible_only: bool = False) -> int: def get_nearest_patch(self, x: float = None, y: float = None, z: float = None): """Gets the patch of the :class:`SubObstruction` that has the least distance to the given point. - If there are multiple patches with the same distance, a random one will be selected. + If there are multiple patches with the same distance, a random one will be selected. """ if self.has_boundary_data: d_min = np.finfo(float).max @@ -587,11 +615,11 @@ def get_nearest_patch(self, x: float = None, y: float = None, z: float = None): patches_min.append(patch) if x is not None: - patches_min.sort(key=lambda patch: (patch.extent.x_end - patch.extent.x_start)) + patches_min.sort(key=lambda patch: patch.extent.x_end - patch.extent.x_start) if y is not None: - patches_min.sort(key=lambda patch: (patch.extent.y_end - patch.extent.y_start)) + patches_min.sort(key=lambda patch: patch.extent.y_end - patch.extent.y_start) if z is not None: - patches_min.sort(key=lambda patch: (patch.extent.z_end - patch.extent.z_start)) + patches_min.sort(key=lambda patch: patch.extent.z_end - patch.extent.z_start) if len(patches_min) > 0: return patches_min[0] @@ -599,15 +627,15 @@ def get_nearest_patch(self, x: float = None, y: float = None, z: float = None): @property def has_boundary_data(self): - """Whether boundary data has been output in the simulation. - """ + """Whether boundary data has been output in the simulation.""" return any(subobst.has_boundary_data for subobst in self._subobstructions.values()) - def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dict[ - int, Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]]: + def get_global_boundary_data_arrays( + self, quantity: Union[str, Quantity] + ) -> Dict[int, Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]]: """Creates a global numpy ndarray from all subobstruction's boundary data for each orientation. - :param quantity: The quantity's name or short name for which to load the global arrays. + :param quantity: The quantity's name or short name for which to load the global arrays. """ if not self.has_boundary_data: return dict() @@ -618,9 +646,10 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic result = dict() for orientation_int in self.orientations: subobst_sets = [list(), list()] - dim = ['x', 'y', 'z'][abs(orientation_int) - 1] + dim = ["x", "y", "z"][abs(orientation_int) - 1] random_subobst = next( - subobst for subobst in self._subobstructions.values() if orientation_int in subobst.get_coordinates()) + subobst for subobst in self._subobstructions.values() if orientation_int in subobst.get_coordinates() + ) base_coord = random_subobst.get_coordinates(ignore_cell_centered=False)[orientation_int][dim][0] for subobst in self._subobstructions.values(): @@ -638,9 +667,9 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic first_result_grid = None for subobsts in subobst_sets: - coord_min = {'x': math.inf, 'y': math.inf, 'z': math.inf} - coord_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} - for dim in ('x', 'y', 'z'): + coord_min = {"x": math.inf, "y": math.inf, "z": math.inf} + coord_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} + for dim in ("x", "y", "z"): for subobst in subobsts: patch_extent = subobst.get_data(quantity).data[orientation_int].extent co = subobst.get_coordinates(ignore_cell_centered=True)[orientation_int][dim] @@ -650,14 +679,16 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic # The global grid will use the finest mesh as base and duplicate values of the coarser meshes # Therefore we first find the finest mesh and calculate the step size in each dimension - step_sizes_min = {'x': coord_max['x'] - coord_min['x'], - 'y': coord_max['y'] - coord_min['y'], - 'z': coord_max['z'] - coord_min['z']} - step_sizes_max = {'x': 0, 'y': 0, 'z': 0} + step_sizes_min = { + "x": coord_max["x"] - coord_min["x"], + "y": coord_max["y"] - coord_min["y"], + "z": coord_max["z"] - coord_min["z"], + } + step_sizes_max = {"x": 0, "y": 0, "z": 0} steps = dict() - global_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} + global_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): for subobst in subobsts: subobst_coords = subobst.get_coordinates(ignore_cell_centered=True)[orientation_int] if len(subobst_coords[dim]) <= 1: @@ -668,36 +699,41 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic step_sizes_max[dim] = max(step_size, step_sizes_max[dim]) global_max[dim] = max(subobst_coords[dim][-1], global_max[dim]) - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): if step_sizes_min[dim] == 0: step_sizes_min[dim] = math.inf steps[dim] = 1 else: steps[dim] = max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), 1) + ( - 0 if cell_centered else 1) + 0 if cell_centered else 1 + ) - grid = np.full((self.n_t(count_duplicates=False), steps['x'], steps['y'], steps['z']), np.nan) + grid = np.full((self.n_t(count_duplicates=False), steps["x"], steps["y"], steps["z"]), np.nan) - start_coordinates = {'x': coord_min['x'], 'y': coord_min['y'], 'z': coord_min['z']} + start_coordinates = {"x": coord_min["x"], "y": coord_min["y"], "z": coord_min["z"]} start_idx = dict() end_idx = dict() for subobst in subobsts: - patch_data = np.expand_dims(subobst.get_data(quantity).data[orientation_int].data, - axis=abs(orientation_int)) + patch_data = np.expand_dims( + subobst.get_data(quantity).data[orientation_int].data, axis=abs(orientation_int) + ) subobst_coords = subobst.get_coordinates(ignore_cell_centered=True)[orientation_int] for axis in (0, 1, 2): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] if axis == abs(orientation_int) - 1: start_idx[dim] = 0 end_idx[dim] = 1 continue n_repeat = max( - int(round((subobst_coords[dim][1] - subobst_coords[dim][0]) / step_sizes_min[dim])), 1) + int(round((subobst_coords[dim][1] - subobst_coords[dim][0]) / step_sizes_min[dim])), 1 + ) start_idx[dim] = int( - round((subobst_coords[dim][0] - start_coordinates[dim]) / step_sizes_min[dim])) + round((subobst_coords[dim][0] - start_coordinates[dim]) / step_sizes_min[dim]) + ) end_idx[dim] = int( - round((subobst_coords[dim][-1] - start_coordinates[dim]) / step_sizes_min[dim])) + round((subobst_coords[dim][-1] - start_coordinates[dim]) / step_sizes_min[dim]) + ) # We ignore border points unless they are actually on the border of the simulation space as all # other border points actually appear twice, as the subobstructions overlap. This only @@ -723,12 +759,19 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic if not cell_centered and subobst_coords[dim][-1] == global_max[dim]: patch_data = np.concatenate((patch_data, temp_data), axis=axis + 1) - grid[:, start_idx['x']: end_idx['x'], start_idx['y']: end_idx['y'], - start_idx['z']: end_idx['z']] = patch_data.reshape( - (self.n_t(count_duplicates=False), end_idx['x'] - start_idx['x'], end_idx['y'] - start_idx['y'], - end_idx['z'] - start_idx['z'])) - - # Remove empty dimensions, but make sure to note remove the time dimension if there is only a single timestep + grid[ + :, start_idx["x"] : end_idx["x"], start_idx["y"] : end_idx["y"], start_idx["z"] : end_idx["z"] + ] = patch_data.reshape( + ( + self.n_t(count_duplicates=False), + end_idx["x"] - start_idx["x"], + end_idx["y"] - start_idx["y"], + end_idx["z"] - start_idx["z"], + ) + ) + + # Remove empty dimensions, but make sure to note remove the time dimension if there is only a single + # timestep grid = np.squeeze(grid) if len(grid.shape) == 2: grid = grid[np.newaxis, :, :] @@ -739,8 +782,7 @@ def get_global_boundary_data_arrays(self, quantity: Union[str, Quantity]) -> Dic return result def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for s in self._subobstructions.values(): s.clear_cache() @@ -763,13 +805,12 @@ def vmax(self, quantity: Union[str, Quantity], orientation: Literal[-3, -2, -1, return max(s.vmax(quantity, orientation) for s in self._subobstructions.values()) return np.nan - def __getitem__(self, key: Union[int, str, 'Mesh']): - """Gets either the nth :class:`SubObstruction` or the one with the given mesh-id. - """ + def __getitem__(self, key: Union[int, str, "Mesh"]): + """Gets either the nth :class:`SubObstruction` or the one with the given mesh-id.""" - if type(key) == int: + if isinstance(key, int): return tuple(self._subobstructions.values())[key] - elif type(key) == str: + elif isinstance(key, str): return self._subobstructions[key] return self._subobstructions[key.id] @@ -777,5 +818,9 @@ def __eq__(self, other): return self.id == other.id def __repr__(self, *args, **kwargs): - return f"Obstruction(id={self.id}, Bounding-Box={self.bounding_box}, SubObstructions={len(self._subobstructions)}" + ( - f", Quantities={[q.short_name for q in self.quantities]}" if self.has_boundary_data else "") + ")" + return ( + f"Obstruction(id={self.id}, Bounding-Box={self.bounding_box}, " + f"SubObstructions={len(self._subobstructions)}" + + (f", Quantities={[q.short_name for q in self.quantities]}" if self.has_boundary_data else "") + + ")" + ) diff --git a/fdsreader/bndf/obstruction_collection.py b/fdsreader/bndf/obstruction_collection.py index ee4cfbc1..fd3da6df 100644 --- a/fdsreader/bndf/obstruction_collection.py +++ b/fdsreader/bndf/obstruction_collection.py @@ -1,4 +1,5 @@ -from typing import Iterable, Tuple, List +from typing import Iterable, List + import numpy as np from fdsreader.bndf import Obstruction @@ -7,7 +8,7 @@ class ObstructionCollection(FDSDataCollection): """Collection of :class:`Obstruction` objects. Offers extensive functionality for filtering and - using obstructions as well as dependent such as :class:`Boundary`. + using obstructions as well as dependent such as :class:`Boundary`. """ def __init__(self, *obstructions: Iterable[Obstruction]): @@ -22,18 +23,15 @@ def quantities(self) -> List[Quantity]: return list(qs) def filter_by_boundary_data(self): - """Filters all obstructions for which output data exists. - """ + """Filters all obstructions for which output data exists.""" return ObstructionCollection(x for x in self._elements if x.has_boundary_data) def get_by_id(self, obst_id: str): - """Get the obstruction with corresponding id if it exists. - """ + """Get the obstruction with corresponding id if it exists.""" return next((obst for obst in self._elements if obst.id == obst_id), None) def get_nearest(self, x: float = None, y: float = None, z: float = None) -> Obstruction: - """Filters the obstruction with the shortest distance to the given point. - """ + """Filters the obstruction with the shortest distance to the given point.""" d_min = np.finfo(float).max obst_min = None @@ -50,4 +48,4 @@ def get_nearest(self, x: float = None, y: float = None, z: float = None) -> Obst return obst_min def __repr__(self): - return "ObstructionCollection(" + super(ObstructionCollection, self).__repr__() + ")" + return "ObstructionCollection(" + super().__repr__() + ")" diff --git a/fdsreader/bndf/utils.py b/fdsreader/bndf/utils.py index 4a73aa46..9be1aeb3 100644 --- a/fdsreader/bndf/utils.py +++ b/fdsreader/bndf/utils.py @@ -4,8 +4,7 @@ def sort_patches_cartesian(patches_in: List[Patch]): - """Returns all patches (of same orientation!) sorted in cartesian coordinates. - """ + """Returns all patches (of same orientation!) sorted in cartesian coordinates.""" patches = patches_in.copy() if len(patches) != 0: patches_cart = [[patches[0]]] @@ -30,4 +29,4 @@ def sort_patches_cartesian(patches_in: List[Patch]): else: patches_cart.append([patch]) return patches_cart - return patches \ No newline at end of file + return patches diff --git a/fdsreader/devc/__init__.py b/fdsreader/devc/__init__.py index 7e92e5cc..912ec140 100644 --- a/fdsreader/devc/__init__.py +++ b/fdsreader/devc/__init__.py @@ -1,3 +1,2 @@ -from .device import Device - -from .device_collection import DeviceCollection \ No newline at end of file +from .device import Device as Device +from .device_collection import DeviceCollection as DeviceCollection diff --git a/fdsreader/devc/device.py b/fdsreader/devc/device.py index 85e5bf09..f4a16dad 100644 --- a/fdsreader/devc/device.py +++ b/fdsreader/devc/device.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Tuple from fdsreader.utils import Quantity @@ -12,8 +12,14 @@ class Device: :ivar orientation: The direction the device was facing. :ivar data: All data the device measured. """ - def __init__(self, device_id: str, quantity: Quantity, position: Tuple[float, float, float], - orientation: Tuple[float, float, float]): + + def __init__( + self, + device_id: str, + quantity: Quantity, + position: Tuple[float, float, float], + orientation: Tuple[float, float, float], + ): self.id = device_id self.quantity = quantity self.position = position @@ -32,33 +38,28 @@ def data(self): @property def quantity_name(self): - """Alias for :class:`Device`.quantity.name. - """ + """Alias for :class:`Device`.quantity.name.""" return self.quantity.name @property def unit(self): - """Alias for :class:`Device`.quantity.unit. - """ + """Alias for :class:`Device`.quantity.unit.""" return self.quantity.unit @property def xyz(self): - """Alias for :class:`Device`.position. - """ + """Alias for :class:`Device`.position.""" return self.position def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data def __eq__(self, other): - if type(other) == str: + if isinstance(other, str): return self.id == other return self.id == other.id def __repr__(self): return f"Device(id='{self.id}', xyz={self.position}, quantity={self.quantity})" - diff --git a/fdsreader/devc/device_collection.py b/fdsreader/devc/device_collection.py index e54f1952..a4f69600 100644 --- a/fdsreader/devc/device_collection.py +++ b/fdsreader/devc/device_collection.py @@ -1,38 +1,39 @@ - -from typing import Iterable, Union, List +from typing import Iterable, List, Union from fdsreader.devc import Device from fdsreader.utils.data import FDSDataCollection class DeviceCollection(FDSDataCollection): - """Collection of :class:`Device` objects. Offers additional functionality for working on devices using pandas. - """ + """Collection of :class:`Device` objects. Offers additional functionality for working on devices using pandas.""" def __init__(self, *devices: Iterable[Device]): super().__init__(*devices) def __getitem__(self, key) -> Union[Device, List[Device]]: - if type(key) == int: + if isinstance(key, int): return self._elements[key] else: - return next(devc for devc in self._elements if (devc.id == key if type(devc) == Device else devc[0].id == key)) + return next( + devc for devc in self._elements if (devc.id == key if isinstance(devc, Device) else devc[0].id == key) + ) def __contains__(self, value: Union[Device, str]): - id_matching = any((devc.id == value if type(devc) == Device else devc[0].id == value) for devc in self._elements) + id_matching = any( + (devc.id == value if isinstance(devc, Device) else devc[0].id == value) for devc in self._elements + ) return value in self._elements or id_matching def to_pandas_dataframe(self): - """Returns a pandas DataFrame with device-IDs as column names and device data as column values. - """ + """Returns a pandas DataFrame with device-IDs as column names and device data as column values.""" import pandas as pd + data = dict() for devc in self: - if type(devc) == Device: + if isinstance(devc, Device): data[devc.id] = devc.data - elif type(devc) == list: + elif isinstance(devc, list): # It might be the case that there are multiple devices with the same name for i, list_devc in enumerate(devc): data[list_devc.id + "_" + str(i)] = list_devc.data return pd.DataFrame(data) - diff --git a/fdsreader/evac/__init__.py b/fdsreader/evac/__init__.py index ddd92a28..20c3b5ad 100644 --- a/fdsreader/evac/__init__.py +++ b/fdsreader/evac/__init__.py @@ -1,3 +1,2 @@ -from .evacuation import Evacuation - -from .evac_collection import EvacCollection +from .evac_collection import EvacCollection as EvacCollection +from .evacuation import Evacuation as Evacuation diff --git a/fdsreader/evac/evac_collection.py b/fdsreader/evac/evac_collection.py index 383564bc..ecf7af12 100644 --- a/fdsreader/evac/evac_collection.py +++ b/fdsreader/evac/evac_collection.py @@ -1,31 +1,31 @@ import logging import os -from typing import Iterable, Dict, List, Union +from typing import Dict, Iterable, List, Union + import numpy as np -from fdsreader.evac import Evacuation -from fdsreader.fds_classes import Mesh -from fdsreader.utils.data import FDSDataCollection, Quantity import fdsreader.utils.fortran_data as fdtype from fdsreader import settings +from fdsreader.evac.evacuation import Evacuation +from fdsreader.utils.data import FDSDataCollection, Quantity class EvacCollection(FDSDataCollection): """Collection of :class:`Evacuation` objects. Next to agent-class specific data (such as - trajectories) lots of other data such as FED-data is provided via this class. - Note: Evac support was removed from FDS in all versions after 6.7.7! - - :ivar times: List of all time steps of the simulation. - :ivar z_offsets: The offset in z-direction for each mesh where the evac plane lays. - :ivar all_agents: Number of all agents per time step. - :ivar agents_inside_mesh: Number of all agents per time step inside a specific mesh. - :ivar number_of_deads: Number of dead agents per time step. - :ivar fed_max: FED max per time step. - :ivar fed_max_alive: FED max alive per time step. - :ivar exit_counters: Exit counts per time step. - :ivar target_exit_counters: Target exit counts per time step. - :ivar door_counters: Door counts per time step. - :ivar target_door_counters: Target door counts per time step. + trajectories) lots of other data such as FED-data is provided via this class. + Note: Evac support was removed from FDS in all versions after 6.7.7! + + :ivar times: List of all time steps of the simulation. + :ivar z_offsets: The offset in z-direction for each mesh where the evac plane lays. + :ivar all_agents: Number of all agents per time step. + :ivar agents_inside_mesh: Number of all agents per time step inside a specific mesh. + :ivar number_of_deads: Number of dead agents per time step. + :ivar fed_max: FED max per time step. + :ivar fed_max_alive: FED max alive per time step. + :ivar exit_counters: Exit counts per time step. + :ivar target_exit_counters: Target exit counts per time step. + :ivar door_counters: Door counts per time step. + :ivar target_door_counters: Target door counts per time step. """ def __init__(self, evacs: Iterable[Evacuation], base_path: str, times: Iterable[float]): @@ -52,8 +52,7 @@ def __init__(self, evacs: Iterable[Evacuation], base_path: str, times: Iterable[ @property def quantities(self) -> List[Quantity]: - """Gives a list of all quantities that are written out for some (or sometimes all) human classes. - """ + """Gives a list of all quantities that are written out for some (or sometimes all) human classes.""" qs = set() for evac in self: for q in evac.quantities: @@ -64,10 +63,10 @@ def _load_csv_data(self): file_path = self._base_path + ".csv" if not os.path.exists(file_path): return - with open(file_path, 'r') as infile: - units = [unit.replace('"', '').replace('\n', '').strip() for unit in infile.readline().split(',')] - names = [name.replace('"', '').replace('\n', '').strip() for name in infile.readline().split(',')] - values = np.genfromtxt(infile, delimiter=',', dtype=np.float32, autostrip=True) + with open(file_path) as infile: + units = [unit.replace('"', "").replace("\n", "").strip() for unit in infile.readline().split(",")] + names = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")] + values = np.genfromtxt(infile, delimiter=",", dtype=np.float32, autostrip=True) dtypes = [int] * len(names) dtypes[0] = float dtypes[-2:] = (float, float) @@ -80,24 +79,26 @@ def _load_csv_data(self): self.times = data["EVAC_Time"] self.all_agents = data["AllAgents"] - self.agents_inside_mesh = {names[i]: data[names[i]] for i in range(len(names)) if - units[i] == "AgentsInsideMesh"} + self.agents_inside_mesh = { + names[i]: data[names[i]] for i in range(len(names)) if units[i] == "AgentsInsideMesh" + } self.number_of_deads = data["Number_of_Deads"] self.fed_max = data["FED_max"] self.fed_max_alive = data["FED_max_alive"] self.exit_counters = {names[i]: data[names[i]] for i in range(len(names)) if units[i] == "ExitCounter"} - self.target_exit_counters = {names[i]: data[names[i]] for i in range(len(names)) if - units[i] == "TargetExitCounter"} + self.target_exit_counters = { + names[i]: data[names[i]] for i in range(len(names)) if units[i] == "TargetExitCounter" + } self.door_counters = {names[i]: data[names[i]] for i in range(len(names)) if units[i] == "DoorCounter"} - self.target_door_counters = {names[i]: data[names[i]] for i in range(len(names)) if - units[i] == "TargetDoorCounter"} + self.target_door_counters = { + names[i]: data[names[i]] for i in range(len(names)) if units[i] == "TargetDoorCounter" + } @property def xyz(self) -> List[np.ndarray]: - """List of xyz-data for each mesh. - """ + """List of xyz-data for each mesh.""" if not hasattr(self, "_xyz"): self._load_xyz_data() return self._xyz @@ -110,20 +111,22 @@ def _load_xyz_data(self): self._load_fed_data(0) return - with open(file_path, 'rb') as infile: - dtype_meta = fdtype.new((('i', 6),)) - dtype_grid_meta = fdtype.new((('i', 4),)) - dtype_grid_data = fdtype.new((('f', 3),)) + with open(file_path, "rb") as infile: + dtype_meta = fdtype.new((("i", 6),)) + dtype_grid_meta = fdtype.new((("i", 4),)) + dtype_grid_data = fdtype.new((("f", 3),)) file_format = fdtype.read(infile, fdtype.INT, 1)[0][0][0] if file_format != -4: - logging.warning("The evac xyz file (" + file_path + ") was written in an unsupported file format. " - "Please submit an issue on Github!") + logging.warning( + "The evac xyz file (" + file_path + ") was written in an unsupported file format. " + "Please submit an issue on Github!" + ) meta = fdtype.read(infile, dtype_meta, 1)[0][0] n_grids = meta[0] n_corrs = meta[2] - n_devc = fdtype.read(infile, fdtype.INT, 1)[0][0][0] + fdtype.read(infile, fdtype.INT, 1)[0][0][0] n_i = list() n_j = list() @@ -151,32 +154,28 @@ def _load_xyz_data(self): @property def fed_grid(self) -> Dict[str, List[np.ndarray]]: - """ - """ + """ """ if not hasattr(self, "_fed_grid"): self._load_xyz_data() return self._fed_grid @property def fed_corr(self) -> Dict[str, List[np.ndarray]]: - """ - """ + """ """ if not hasattr(self, "_fed_corr"): self._load_xyz_data() return self._fed_corr @property def devc(self) -> Dict[str, Union[List[np.ndarray], np.ndarray]]: - """ - """ + """ """ if not hasattr(self, "_devc"): self._load_xyz_data() return self._devc @property def fed_times(self) -> np.ndarray: - """ - """ + """ """ if not hasattr(self, "_fed_times"): self._load_xyz_data() return self._fed_times @@ -191,19 +190,21 @@ def _load_fed_data(self, n_corrs: int): self._fed_times = np.array([]) return - with open(file_path, 'rb') as infile: - dtype_meta = fdtype.new((('i', 6),)) - dtype_time = fdtype.new((('f', 2),)) - dtype_grid_meta = fdtype.new((('i', 4),)) - dtype_corr = fdtype.new((('f', 8),)) - dtype_devs_meta = fdtype.new((('i', 2),)) - dtype_devs_data = fdtype.new((('i', 1), ('f', 1), ('i', 2), ('f', 1))) + with open(file_path, "rb") as infile: + dtype_meta = fdtype.new((("i", 6),)) + dtype_time = fdtype.new((("f", 2),)) + dtype_grid_meta = fdtype.new((("i", 4),)) + dtype_corr = fdtype.new((("f", 8),)) + dtype_devs_meta = fdtype.new((("i", 2),)) + dtype_devs_data = fdtype.new((("i", 1), ("f", 1), ("i", 2), ("f", 1))) # File header file_format = fdtype.read(infile, fdtype.INT, 1)[0][0][0] if file_format != -4: - logging.warning("The evac fed file (" + file_path + ") was written in an unsupported file format. " - "Please submit an issue on Github!") + logging.warning( + "The evac fed file (" + file_path + ") was written in an unsupported file format. " + "Please submit an issue on Github!" + ) n_grids = fdtype.read(infile, dtype_meta, 1)[0][0][0] n_devc = fdtype.read(infile, fdtype.INT, 1)[0][0][0] @@ -220,7 +221,7 @@ def _load_fed_data(self, n_corrs: int): n_i.append(n_i_g) n_j.append(n_j_g) n.append(n_g) - dtype_grid_data.append(fdtype.new((('f', n_g),))) + dtype_grid_data.append(fdtype.new((("f", n_g),))) for i in range(n_i_g): for j in range(n_j_g): _ = fdtype.read(infile, dtype_grid_data[-1], 1) @@ -229,24 +230,36 @@ def _load_fed_data(self, n_corrs: int): infile.seek(dtype_meta.itemsize + fdtype.INT.itemsize * 2) n_t = (os.stat(file_path).st_size - (fdtype.INT.itemsize * 2 + dtype_meta.itemsize)) // ( - dtype_time.itemsize + sum( - (dtype_grid_meta.itemsize + n_i[g] * n_j[g] * dtype_grid_data[g].itemsize) for g in - range(n_grids)) + n_corrs * dtype_corr.itemsize + fdtype.FLOAT.itemsize + n_devc * ( - dtype_devs_meta.itemsize + dtype_devs_data.itemsize)) + dtype_time.itemsize + + sum( + (dtype_grid_meta.itemsize + n_i[g] * n_j[g] * dtype_grid_data[g].itemsize) for g in range(n_grids) + ) + + n_corrs * dtype_corr.itemsize + + fdtype.FLOAT.itemsize + + n_devc * (dtype_devs_meta.itemsize + dtype_devs_data.itemsize) + ) times = list() - self._fed_grid = dict(co_co2_o2=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], - soot_dens=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], - tmp_g=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], - radflux=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)]) - self._fed_corr = dict(co_co2_o2=[np.empty((n_t, 2)) for _ in range(n_corrs)], - soot_dens=[np.empty((n_t, 2)) for _ in range(n_corrs)], - tmp_g=[np.empty((n_t, 2)) for _ in range(n_corrs)], - radflux=[np.empty((n_t, 2)) for _ in range(n_corrs)]) - self._devc = dict(i_type=[0 for _ in range(n_devc)], devc_id=[0 for _ in range(n_devc)], - current=[np.empty((n_t,), dtype=int) for _ in range(n_devc)], - prior=[np.empty((n_t,), dtype=int) for _ in range(n_devc)], - t_change=[np.empty((n_t,)) for _ in range(n_devc)], times=np.empty((n_t,))) + self._fed_grid = dict( + co_co2_o2=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], + soot_dens=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], + tmp_g=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], + radflux=[np.empty((n_t, n_i[g], n_j[g])) for g in range(n_grids)], + ) + self._fed_corr = dict( + co_co2_o2=[np.empty((n_t, 2)) for _ in range(n_corrs)], + soot_dens=[np.empty((n_t, 2)) for _ in range(n_corrs)], + tmp_g=[np.empty((n_t, 2)) for _ in range(n_corrs)], + radflux=[np.empty((n_t, 2)) for _ in range(n_corrs)], + ) + self._devc = dict( + i_type=[0 for _ in range(n_devc)], + devc_id=[0 for _ in range(n_devc)], + current=[np.empty((n_t,), dtype=int) for _ in range(n_devc)], + prior=[np.empty((n_t,), dtype=int) for _ in range(n_devc)], + t_change=[np.empty((n_t,)) for _ in range(n_devc)], + times=np.empty((n_t,)), + ) for t in range(n_t): times.append(fdtype.read(infile, dtype_time, 1)[0][0]) # t, dt_save @@ -263,8 +276,9 @@ def _load_fed_data(self, n_corrs: int): # Corr for c in range(n_corrs): - co_co2_o2_1, soot_dens_1, tmp_g_1, radflux_1, co_co2_o2_2, soot_dens_2, tmp_g_2, radflux_2 = \ + co_co2_o2_1, soot_dens_1, tmp_g_1, radflux_1, co_co2_o2_2, soot_dens_2, tmp_g_2, radflux_2 = ( fdtype.read(infile, dtype_corr, 1)[0][0][:8] + ) self._fed_corr["co_co2_o2"][c][t] = (co_co2_o2_1, co_co2_o2_2) self._fed_corr["soot_dens"][c][t] = (soot_dens_1, soot_dens_2) self._fed_corr["tmp_g"][c][t] = (tmp_g_1, tmp_g_2) @@ -294,14 +308,15 @@ def _load_eff_data(self): self._eff = None return - with open(file_path, 'rb') as infile: - dtype_grid_meta = fdtype.new((('i', 3),)) - dtype_grid_data = fdtype.new((('f', 2),)) # u, v + with open(file_path, "rb") as infile: + dtype_grid_meta = fdtype.new((("i", 3),)) + dtype_grid_data = fdtype.new((("f", 2),)) # u, v - n_grids = fdtype.read(infile, fdtype.INT, 1)[0][0][0] + fdtype.read(infile, fdtype.INT, 1)[0][0][0] # n_fields = (os.stat(file_path).st_size - fdtype.INT.itemsize) // ( - # sum(dtype_grid_meta.itemsize + n_i[g] * n_j[g] * dtype_grid_data.itemsize for g in range(n_grids))) + # sum(dtype_grid_meta.itemsize + n_i[g] * n_j[g] * dtype_grid_data.itemsize + # for g in range(n_grids))) # # self._eff = np.empty((n_grids, n_fields, n_i, n_j, 2)) # for g in range(n_grids): @@ -340,7 +355,7 @@ def get_unfiltered_positions(self, quantity: Union[Quantity, str] = None): size = 0 for evac in filtered_evacs: pos = evac.positions[t] - combined_positions[t][size:size + pos.shape[0]] = pos + combined_positions[t][size : size + pos.shape[0]] = pos size += pos.shape[0] return combined_positions @@ -366,14 +381,13 @@ def get_unfiltered_data(self, quantity: Union[Quantity, str]): if size != 0: size = 0 for d in data: - combined_data[t][size:size + d[t].shape[0]] = d[t] + combined_data[t][size : size + d[t].shape[0]] = d[t] size += d[t].shape[0] return combined_data def _load_prt_data(self): - """Function to read in all evac data for a simulation. - """ + """Function to read in all evac data for a simulation.""" evacs = self pointer_location = {evac: [0] * len(self.times) for evac in evacs} @@ -393,14 +407,13 @@ def _load_prt_data(self): evac._tags.append(np.empty((size,), dtype=int)) for mesh, file_path in self._file_paths.items(): - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: # Initial offset (ONE, fds version and number of evac classes) offset = 3 * fdtype.INT.itemsize # Number of quantities for each evac class (plus an INTEGER_ZERO) - offset += fdtype.new((('i', 2),)).itemsize * len(evacs) + offset += fdtype.new((("i", 2),)).itemsize * len(evacs) # 30-char long name and unit information for each quantity - offset += fdtype.new((('c', 30),)).itemsize * 2 * sum( - [len(evac.quantities) for evac in evacs]) + offset += fdtype.new((("c", 30),)).itemsize * 2 * sum([len(evac.quantities) for evac in evacs]) infile.seek(offset) for t in range(len(self.times)): @@ -413,34 +426,35 @@ def _load_prt_data(self): n_humans = fdtype.read(infile, fdtype.INT, 1)[0][0][0] offset = pointer_location[evac][t] # Read positions - dtype_positions = fdtype.new((('f', 7 * n_humans),)) - pos = fdtype.read(infile, dtype_positions, 1)[0][0].reshape((n_humans, 7), - order='F').astype(float) - evac._positions[t][offset: offset + n_humans] = pos[:, :3] - evac._body_angles[t][offset: offset + n_humans] = pos[:, 3] - evac._semi_major_axis[t][offset: offset + n_humans] = pos[:, 4] - evac._semi_minor_axis[t][offset: offset + n_humans] = pos[:, 5] - evac._agent_heights[t][offset: offset + n_humans] = pos[:, 6] + dtype_positions = fdtype.new((("f", 7 * n_humans),)) + pos = ( + fdtype.read(infile, dtype_positions, 1)[0][0] + .reshape((n_humans, 7), order="F") + .astype(float) + ) + evac._positions[t][offset : offset + n_humans] = pos[:, :3] + evac._body_angles[t][offset : offset + n_humans] = pos[:, 3] + evac._semi_major_axis[t][offset : offset + n_humans] = pos[:, 4] + evac._semi_minor_axis[t][offset : offset + n_humans] = pos[:, 5] + evac._agent_heights[t][offset : offset + n_humans] = pos[:, 6] # Read tags - dtype_tags = fdtype.new((('i', n_humans),)) - evac._tags[t][offset: offset + n_humans] = fdtype.read(infile, dtype_tags, 1)[0][0] + dtype_tags = fdtype.new((("i", n_humans),)) + evac._tags[t][offset : offset + n_humans] = fdtype.read(infile, dtype_tags, 1)[0][0] # Read actual quantity values if len(evac.quantities) > 0: - dtype_data = fdtype.new( - (('f', str((n_humans, len(evac.quantities)))),)) + dtype_data = fdtype.new((("f", str((n_humans, len(evac.quantities)))),)) data_raw = fdtype.read(infile, dtype_data, 1)[0][0].reshape( - (n_humans, len(evac.quantities)), order='F') + (n_humans, len(evac.quantities)), order="F" + ) for q, quantity in enumerate(evac.quantities): - evac._data[quantity.name][t][ - offset:offset + n_humans] = data_raw[:, q].astype(float) + evac._data[quantity.name][t][offset : offset + n_humans] = data_raw[:, q].astype(float) pointer_location[evac][t] += evac.n_humans[mesh][t] def __getitem__(self, key): - """Get the evac data for a specific agent/human class. - """ - if type(key) == int: + """Get the evac data for a specific agent/human class.""" + if isinstance(key, int): return self._elements[key] for evac in self: if evac.class_name == key: @@ -455,4 +469,4 @@ def __contains__(self, value): return False def __repr__(self): - return "EvacCollection(" + super(EvacCollection, self).__repr__() + ")" + return "EvacCollection(" + super().__repr__() + ")" diff --git a/fdsreader/evac/evacuation.py b/fdsreader/evac/evacuation.py index d8379d1f..c583c00b 100644 --- a/fdsreader/evac/evacuation.py +++ b/fdsreader/evac/evacuation.py @@ -1,11 +1,9 @@ -from typing import List, Tuple, Dict, Sequence, Union +from typing import Dict, List, Sequence, Tuple, Union import numpy as np -from fdsreader.fds_classes import Mesh from fdsreader.utils import Quantity - # class Entrance: # def __init__(self): # self.id = "" @@ -74,14 +72,14 @@ def id(self): return self.class_name def has_quantity(self, quantity: Union[Quantity, str]): - if type(quantity) == Quantity: + if isinstance(quantity, Quantity): quantity = quantity.name return any( - q.name.lower() == quantity.lower() or q.short_name.lower() == quantity.lower() for q in self.quantities) + q.name.lower() == quantity.lower() or q.short_name.lower() == quantity.lower() for q in self.quantities + ) def filter_by_tag(self, tag: int): - """Filter all evacs by a single one with the specified tag. - """ + """Filter all evacs by a single one with the specified tag.""" data = self.data tags = self.tags positions = self.positions @@ -116,7 +114,7 @@ def filter_by_tag(self, tag: int): @property def data(self) -> Dict[str, List[np.ndarray]]: """Dictionary with quantities as keys and a list with a numpy array for each timestep which - contains data for each person in that timestep. + contains data for each person in that timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() @@ -124,10 +122,10 @@ def data(self) -> Dict[str, List[np.ndarray]]: def get_data(self, quantity: Union[Quantity, str]) -> List[np.ndarray]: """Returns a list with a numpy array for each timestep which contains data about the specified quantity for - each person in that timestep. + each person in that timestep. """ if self.has_quantity(quantity): - if type(quantity) == Quantity: + if isinstance(quantity, Quantity): quantity = quantity.name return self.data[quantity] return [] @@ -135,7 +133,7 @@ def get_data(self, quantity: Union[Quantity, str]) -> List[np.ndarray]: @property def tags(self) -> List[np.ndarray]: """List with a numpy array for each timestep which contains a tag for each evac in that - timestep. + timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() @@ -144,7 +142,7 @@ def tags(self) -> List[np.ndarray]: @property def positions(self) -> List[np.ndarray]: """List with a numpy array for each timestep which contains the position of each evac in - that timestep. + that timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() @@ -152,39 +150,34 @@ def positions(self) -> List[np.ndarray]: @property def body_angles(self) -> List[np.ndarray]: - """ - """ + """ """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() return self._body_angles @property def semi_major_axis(self) -> List[np.ndarray]: - """ - """ + """ """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() return self._semi_major_axis @property def semi_minor_axis(self) -> List[np.ndarray]: - """ - """ + """ """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() return self._semi_minor_axis @property def agent_heights(self) -> List[np.ndarray]: - """ - """ + """ """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() return self._agent_heights def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if len(self._positions) != 0: del self._positions self._positions = list() diff --git a/fdsreader/export/__init__.py b/fdsreader/export/__init__.py index 8f37608e..dd2261a6 100644 --- a/fdsreader/export/__init__.py +++ b/fdsreader/export/__init__.py @@ -1,4 +1,4 @@ -from .smoke3d_exporter import export_smoke_raw -from .slcf_exporter import export_slcf_raw -from .obst_exporter import export_obst_raw -from .sim_exporter import export_sim +from .obst_exporter import export_obst_raw as export_obst_raw +from .sim_exporter import export_sim as export_sim +from .slcf_exporter import export_slcf_raw as export_slcf_raw +from .smoke3d_exporter import export_smoke_raw as export_smoke_raw diff --git a/fdsreader/export/obst_exporter.py b/fdsreader/export/obst_exporter.py index 04206280..0cb978f1 100644 --- a/fdsreader/export/obst_exporter.py +++ b/fdsreader/export/obst_exporter.py @@ -1,13 +1,14 @@ import os from pathlib import Path -from typing import Dict, Union, Tuple +from typing import Dict, Tuple, Union import numpy as np from typing_extensions import Literal + from ..bndf import Obstruction -def export_obst_raw(obst: Obstruction, output_dir: str, ordering: Literal['C', 'F'] = 'C'): +def export_obst_raw(obst: Obstruction, output_dir: str, ordering: Literal["C", "F"] = "C"): """Exports the 3d arrays to raw binary files with corresponding .yaml meta files. :param obst: The :class:`Obstruction` object to export including its :class:`Boundary` data. @@ -17,13 +18,17 @@ def export_obst_raw(obst: Obstruction, output_dir: str, ordering: Literal['C', ' if len(obst.quantities) == 0: return "" + from multiprocess import Manager from pathos.pools import ProcessPool as Pool - from multiprocess import Lock, Manager + obst_filename_base = "obst-" + str(obst.id) bounding_box = obst.bounding_box.as_list() - meta = {"BoundingBox": " ".join(f"{b:.6f}" for b in bounding_box), "NumQuantities": len(obst.quantities), - "Orientations": list()} + meta = { + "BoundingBox": " ".join(f"{b:.6f}" for b in bounding_box), + "NumQuantities": len(obst.quantities), + "Orientations": list(), + } m = Manager() lock = m.Lock() meta["Quantities"] = m.list() @@ -42,15 +47,18 @@ def export_obst_raw(obst: Obstruction, output_dir: str, ordering: Literal['C', ' spacing1 = (bounding_box[1] - bounding_box[0]) / face.shape[0] spacing2 = (bounding_box[3] - bounding_box[2]) / face.shape[1] - meta["Orientations"].append({ - "BoundaryOrientation": orientation, - # "MeshPos": f"{meta['BoundingBox'][0]:.6f} {meta['BoundingBox'][2]:.6f} {meta['BoundingBox'][4]:.6f}", - "Spacing": f"{random_bndf.times[1] - random_bndf.times[0]:.6f} {spacing1:.6f} {spacing2:.6f}", - "DimSize": f"{face.shape[0]} {face.shape[1]}" - }) - - def worker(quantity: str, faces: Dict[int, Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]], vmin: float, - vmax: float): + meta["Orientations"].append( + { + "BoundaryOrientation": orientation, + # "MeshPos": f"{meta['BoundingBox'][0]:.6f} {meta['BoundingBox'][2]:.6f} {meta['BoundingBox'][4]:.6f}", + "Spacing": f"{random_bndf.times[1] - random_bndf.times[0]:.6f} {spacing1:.6f} {spacing2:.6f}", + "DimSize": f"{face.shape[0]} {face.shape[1]}", + } + ) + + def worker( + quantity: str, faces: Dict[int, Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]], vmin: float, vmax: float + ): quantity_name = quantity.replace(" ", "_").replace(".", "-") filename = obst_filename_base + "_quantity-" + quantity_name + ".dat" @@ -59,17 +67,17 @@ def worker(quantity: str, faces: Dict[int, Union[np.ndarray, Tuple[np.ndarray, n "DataFile": os.path.join(quantity_name, filename), "DataValMax": vmax, "DataValMin": vmin, - "ScaleFactor": 255.0 / vmax + "ScaleFactor": 255.0 / vmax, } # Abort if no useful data is available if out["DataValMax"] <= 0: return - with open(os.path.join(output_dir, quantity_name, filename), 'wb') as rawfile: + with open(os.path.join(output_dir, quantity_name, filename), "wb") as rawfile: for face in faces.values(): # face for each orientation for d in (face * out["ScaleFactor"]).astype(np.uint8): - if ordering == 'F': + if ordering == "F": d = d.T d.tofile(rawfile) @@ -78,11 +86,18 @@ def worker(quantity: str, faces: Dict[int, Union[np.ndarray, Tuple[np.ndarray, n worker_args = list() for i, bndf_quantity in enumerate(obst.quantities): - worker_args.append((bndf_quantity.name, obst.get_global_boundary_data_arrays(bndf_quantity), - obst.vmin(bndf_quantity, 0), obst.vmax(bndf_quantity, 0))) + worker_args.append( + ( + bndf_quantity.name, + obst.get_global_boundary_data_arrays(bndf_quantity), + obst.vmin(bndf_quantity, 0), + obst.vmax(bndf_quantity, 0), + ) + ) # Create all requested directories if they don't exist yet - Path(os.path.join(output_dir, bndf_quantity.name.replace(" ", "_").replace(".", "-"))).mkdir(parents=True, - exist_ok=True) + Path(os.path.join(output_dir, bndf_quantity.name.replace(" ", "_").replace(".", "-"))).mkdir( + parents=True, exist_ok=True + ) with Pool(len(obst.quantities)) as pool: pool.map(lambda args: worker(*args), worker_args) @@ -90,8 +105,9 @@ def worker(quantity: str, faces: Dict[int, Union[np.ndarray, Tuple[np.ndarray, n meta["Quantities"] = list(meta["Quantities"]) meta_file_path = os.path.join(output_dir, obst_filename_base + ".yaml") - with open(meta_file_path, 'w') as meta_file: + with open(meta_file_path, "w") as meta_file: import yaml + yaml.dump(meta, meta_file) return meta_file_path diff --git a/fdsreader/export/sim_exporter.py b/fdsreader/export/sim_exporter.py index 05388c4a..7f4d8367 100644 --- a/fdsreader/export/sim_exporter.py +++ b/fdsreader/export/sim_exporter.py @@ -1,10 +1,12 @@ import os + from typing_extensions import Literal -from . import export_slcf_raw, export_obst_raw, export_smoke_raw + from .. import Simulation +from . import export_obst_raw, export_slcf_raw, export_smoke_raw -def export_sim(sim: Simulation, output_dir: str, ordering: Literal['C', 'F'] = 'C'): +def export_sim(sim: Simulation, output_dir: str, ordering: Literal["C", "F"] = "C"): """Exports the 3d arrays to raw binary files with corresponding .yaml meta files. Warning: This method does not work for large simulations as some internal multiprocess buffers overflow after a few GB of data. Please export the simulation manually using the functions used in this method in separate @@ -22,12 +24,16 @@ def export_sim(sim: Simulation, output_dir: str, ordering: Literal['C', 'F'] = ' meta["Obstructions"].append(os.path.relpath(obst_path, output_dir).replace("\\", "/")) for slc in sim.slices: - slice_path = export_slcf_raw(slc, os.path.join(output_dir, "slices", slc.quantity.name.replace(' ', '_').lower()), ordering) + slice_path = export_slcf_raw( + slc, os.path.join(output_dir, "slices", slc.quantity.name.replace(" ", "_").lower()), ordering + ) if slice_path: meta["Slices"].append(os.path.relpath(slice_path, output_dir).replace("\\", "/")) for smoke in sim.smoke_3d: - volume_path = export_smoke_raw(smoke, os.path.join(output_dir, "smoke", smoke.quantity.name.replace(' ', '_').lower()), ordering) + volume_path = export_smoke_raw( + smoke, os.path.join(output_dir, "smoke", smoke.quantity.name.replace(" ", "_").lower()), ordering + ) if volume_path: meta["Volumes"].append(os.path.relpath(volume_path, output_dir).replace("\\", "/")) @@ -36,8 +42,9 @@ def export_sim(sim: Simulation, output_dir: str, ordering: Literal['C', 'F'] = ' meta["NumVolumes"] = len(meta["Volumes"]) import yaml + meta_file_path = os.path.join(output_dir, sim.chid + "-smv.yaml") - with open(meta_file_path, 'w') as metafile: + with open(meta_file_path, "w") as metafile: yaml.dump(meta, metafile) return meta_file_path diff --git a/fdsreader/export/slcf_exporter.py b/fdsreader/export/slcf_exporter.py index 1751d3f5..ef1ab2ff 100644 --- a/fdsreader/export/slcf_exporter.py +++ b/fdsreader/export/slcf_exporter.py @@ -1,22 +1,31 @@ import os from pathlib import Path + import numpy as np from typing_extensions import Literal + from ..slcf import Slice -def export_slcf_raw(slc: Slice, output_dir: str, ordering: Literal['C', 'F'] = 'C'): +def export_slcf_raw(slc: Slice, output_dir: str, ordering: Literal["C", "F"] = "C"): """Exports the 3d arrays to raw binary files with corresponding .yaml meta files. :param slc: The :class:`Slice` object to export. :param output_dir: The directory in which to save all files. :param ordering: Whether to write the data in C or Fortran ordering. """ + from multiprocess import Manager from pathos.pools import ProcessPool as Pool - from multiprocess import Lock, Manager - slc2d = slc.type == '2D' - meta = {"CellCentered": 1 if slc.cell_centered else 0, "DataValMax": float(slc.vmax), "DataValMin": float(slc.vmin), "ScaleFactor": 255. / (float(slc.vmax) - float(slc.vmin)), - "MeshNum": len(slc.subslices), "Quantity": slc.quantity.name} + + slc2d = slc.type == "2D" + meta = { + "CellCentered": 1 if slc.cell_centered else 0, + "DataValMax": float(slc.vmax), + "DataValMin": float(slc.vmin), + "ScaleFactor": 255.0 / (float(slc.vmax) - float(slc.vmin)), + "MeshNum": len(slc.subslices), + "Quantity": slc.quantity.name, + } filename_base = ("slice" + ("2D-" if slc2d else "3D-") + slc.id.lower()).replace(" ", "_").replace(".", "-") # Create all requested directories if they don't exist yet @@ -33,26 +42,32 @@ def worker(mesh, subslice): data = ((subslice.data - meta["DataValMin"]) * meta["ScaleFactor"]).astype(np.uint8) shape = data.shape if slc2d: - shape = shape[:subslice.orientation] + (1,) + shape[subslice.orientation:] + shape = shape[: subslice.orientation] + (1,) + shape[subslice.orientation :] - with open(os.path.join(output_dir, filename_base + "-data", filename), 'wb') as rawfile: + with open(os.path.join(output_dir, filename_base + "-data", filename), "wb") as rawfile: for d in data: - if ordering == 'F': + if ordering == "F": d = d.T d.tofile(rawfile) - spacing = [slc.times[1] - slc.times[0], - mesh.coordinates['x'][1] - mesh.coordinates['x'][0], - mesh.coordinates['y'][1] - mesh.coordinates['y'][0], - mesh.coordinates['z'][1] - mesh.coordinates['z'][0]] + spacing = [ + slc.times[1] - slc.times[0], + mesh.coordinates["x"][1] - mesh.coordinates["x"][0], + mesh.coordinates["y"][1] - mesh.coordinates["y"][0], + mesh.coordinates["z"][1] - mesh.coordinates["z"][0], + ] with lock: - meta["Meshes"].append({ - "Mesh": mesh_id, - "DataFile": os.path.join(filename_base + "-data", filename), - "MeshPos": f"{subslice.extent['x'][0]:.6} {subslice.extent['y'][0]:.6} {subslice.extent['z'][0]:.6}", - "Spacing": f"{spacing[0]:.6} {spacing[1]:.6} {spacing[2]:.6} {spacing[3]:.6}", - "DimSize": f"{shape[0]} {shape[1]} {shape[2]} {shape[3]}" - }) + meta["Meshes"].append( + { + "Mesh": mesh_id, + "DataFile": os.path.join(filename_base + "-data", filename), + "MeshPos": ( + f"{subslice.extent['x'][0]:.6} {subslice.extent['y'][0]:.6} {subslice.extent['z'][0]:.6}" + ), + "Spacing": f"{spacing[0]:.6} {spacing[1]:.6} {spacing[2]:.6} {spacing[3]:.6}", + "DimSize": f"{shape[0]} {shape[1]} {shape[2]} {shape[3]}", + } + ) with Pool() as pool: pool.map(lambda args: worker(*args), list(slc._subslices.items())) @@ -60,8 +75,9 @@ def worker(mesh, subslice): meta["Meshes"] = list(meta["Meshes"]) meta_file_path = os.path.join(output_dir, filename_base + ".yaml") - with open(meta_file_path, 'w') as meta_file: + with open(meta_file_path, "w") as meta_file: import yaml + yaml.dump(meta, meta_file) return meta_file_path diff --git a/fdsreader/export/smoke3d_exporter.py b/fdsreader/export/smoke3d_exporter.py index 7b46b351..f4128726 100644 --- a/fdsreader/export/smoke3d_exporter.py +++ b/fdsreader/export/smoke3d_exporter.py @@ -1,25 +1,33 @@ import os from pathlib import Path + import numpy as np from typing_extensions import Literal + from ..smoke3d import Smoke3D -def export_smoke_raw(smoke3d: Smoke3D, output_dir: str, ordering: Literal['C', 'F'] = 'C'): +def export_smoke_raw(smoke3d: Smoke3D, output_dir: str, ordering: Literal["C", "F"] = "C"): """Exports the 3d arrays to raw binary files with corresponding .yaml meta files. :param smoke3d: The :class:`Smoke3D` object to export. :param output_dir: The directory in which to save all files. :param ordering: Whether to write the data in C or Fortran ordering. """ + from multiprocess import Manager from pathos.pools import ProcessPool as Pool - from multiprocess import Lock, Manager + filename_base = ("smoke-" + smoke3d.quantity.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) - meta = {"DataValMax": -100000., "DataValMin": 100000., "ScaleFactor": 1, "MeshNum": len(smoke3d.subsmokes), - "Quantity": smoke3d.quantity.name} + meta = { + "DataValMax": -100000.0, + "DataValMin": 100000.0, + "ScaleFactor": 1, + "MeshNum": len(smoke3d.subsmokes), + "Quantity": smoke3d.quantity.name, + } for subsmoke in smoke3d._subsmokes.values(): meta["DataValMax"] = max(meta["DataValMax"], np.max(subsmoke.data)) @@ -43,24 +51,30 @@ def worker(mesh, subsmoke): data = (subsmoke.data * meta["ScaleFactor"]).astype(np.uint8) - with open(os.path.join(output_dir, filename_base + "-data", filename), 'wb') as rawfile: + with open(os.path.join(output_dir, filename_base + "-data", filename), "wb") as rawfile: for d in data: - if ordering == 'F': + if ordering == "F": d = d.T d.tofile(rawfile) - spacing = [smoke3d.times[1] - smoke3d.times[0], - mesh.coordinates['x'][1] - mesh.coordinates['x'][0], - mesh.coordinates['y'][1] - mesh.coordinates['y'][0], - mesh.coordinates['z'][1] - mesh.coordinates['z'][0]] + spacing = [ + smoke3d.times[1] - smoke3d.times[0], + mesh.coordinates["x"][1] - mesh.coordinates["x"][0], + mesh.coordinates["y"][1] - mesh.coordinates["y"][0], + mesh.coordinates["z"][1] - mesh.coordinates["z"][0], + ] with lock: - meta["Meshes"].append({ - "Mesh": mesh_id, - "DataFile": os.path.join(filename_base + "-data", filename), - "MeshPos": f"{mesh.coordinates['x'][0]:.6} {mesh.coordinates['y'][0]:.6} {mesh.coordinates['z'][0]:.6}", - "Spacing": f"{spacing[0]:.6} {spacing[1]:.6} {spacing[2]:.6} {spacing[3]:.6}", - "DimSize": f"{data.shape[0]} {data.shape[1]} {data.shape[2]} {data.shape[3]}" - }) + meta["Meshes"].append( + { + "Mesh": mesh_id, + "DataFile": os.path.join(filename_base + "-data", filename), + "MeshPos": ( + f"{mesh.coordinates['x'][0]:.6} {mesh.coordinates['y'][0]:.6} {mesh.coordinates['z'][0]:.6}" + ), + "Spacing": f"{spacing[0]:.6} {spacing[1]:.6} {spacing[2]:.6} {spacing[3]:.6}", + "DimSize": f"{data.shape[0]} {data.shape[1]} {data.shape[2]} {data.shape[3]}", + } + ) with Pool() as pool: pool.map(lambda args: worker(*args), list(smoke3d._subsmokes.items())) @@ -68,8 +82,9 @@ def worker(mesh, subsmoke): meta["Meshes"] = list(meta["Meshes"]) meta_file_path = os.path.join(output_dir, filename_base + ".yaml") - with open(meta_file_path, 'w') as meta_file: + with open(meta_file_path, "w") as meta_file: import yaml + yaml.dump(meta, meta_file) return meta_file_path diff --git a/fdsreader/fds_classes/__init__.py b/fdsreader/fds_classes/__init__.py index d254bd59..442b6a1f 100644 --- a/fdsreader/fds_classes/__init__.py +++ b/fdsreader/fds_classes/__init__.py @@ -1,7 +1,4 @@ -from .mesh import Mesh - -from .mesh_collection import MeshCollection - -from .surface import Surface - -from .ventilation import Ventilation +from .mesh import Mesh as Mesh +from .mesh_collection import MeshCollection as MeshCollection +from .surface import Surface as Surface +from .ventilation import Ventilation as Ventilation diff --git a/fdsreader/fds_classes/mesh.py b/fdsreader/fds_classes/mesh.py index fffda51a..41e897a9 100644 --- a/fdsreader/fds_classes/mesh.py +++ b/fdsreader/fds_classes/mesh.py @@ -1,11 +1,12 @@ -from typing import Dict, Tuple, Sequence, List, Union -from typing_extensions import Literal -import numpy as np import math +from typing import Dict, List, Sequence, Tuple, Union + +import numpy as np +from typing_extensions import Literal from fdsreader import settings -from fdsreader.utils import Dimension, Extent, Quantity from fdsreader.bndf import Boundary, Patch +from fdsreader.utils import Dimension, Extent, Quantity class Mesh: @@ -19,8 +20,12 @@ class Mesh: :var id: Mesh id/short_name assigned to this mesh. """ - def __init__(self, coordinates: Dict[Literal['x', 'y', 'z'], np.ndarray], - extents: Dict[Literal['x', 'y', 'z'], Tuple[float, float]], mesh_id: str): + def __init__( + self, + coordinates: Dict[Literal["x", "y", "z"], np.ndarray], + extents: Dict[Literal["x", "y", "z"], Tuple[float, float]], + mesh_id: str, + ): """ :param coordinates: Coordinate values of the three axes. :param extents: Extent of the mesh in each dimension. @@ -29,13 +34,15 @@ def __init__(self, coordinates: Dict[Literal['x', 'y', 'z'], np.ndarray], self.id = mesh_id self.coordinates = coordinates self.dimension = Dimension( - coordinates['x'].size if coordinates['x'].size > 0 else 1, - coordinates['y'].size if coordinates['y'].size > 0 else 1, - coordinates['z'].size if coordinates['z'].size > 0 else 1) + coordinates["x"].size if coordinates["x"].size > 0 else 1, + coordinates["y"].size if coordinates["y"].size > 0 else 1, + coordinates["z"].size if coordinates["z"].size > 0 else 1, + ) self.n_size = self.dimension.size() - self.extent = Extent(extents['x'][0], extents['x'][1], extents['y'][0], extents['y'][1], - extents['z'][0], extents['z'][1]) + self.extent = Extent( + extents["x"][0], extents["x"][1], extents["y"][0], extents["y"][1], extents["z"][0], extents["z"][1] + ) self.obstructions = list() @@ -54,14 +61,14 @@ def get_obstruction_mask(self, times: Sequence[float], cell_centered=False) -> n for obst in self.obstructions: subobst = obst[self] - x1, x2 = subobst.bound_indices['x'] - y1, y2 = subobst.bound_indices['y'] - z1, z2 = subobst.bound_indices['z'] + x1, x2 = subobst.bound_indices["x"] + y1, y2 = subobst.bound_indices["y"] + z1, z2 = subobst.bound_indices["z"] t_idx = 0 for t in subobst.get_visible_times(times): while not np.isclose(t, times[t_idx]): t_idx += 1 - mask[t_idx, x1:max(x2 + c, x1 + 1), y1:max(y2 + c, y1 + 1), z1:max(z2 + c, z1 + 1)] = False + mask[t_idx, x1 : max(x2 + c, x1 + 1), y1 : max(y2 + c, y1 + 1), z1 : max(z2 + c, z1 + 1)] = False return mask def get_obstruction_mask_slice(self, subslice): @@ -82,9 +89,12 @@ def get_obstruction_mask_slice(self, subslice): return self.get_obstruction_mask(subslice.times, cell_centered=cell_centered)[mask_indices] - def coordinate_to_index(self, coordinate: Tuple[float, ...], - dimension: Tuple[Literal[1, 2, 3, 'x', 'y', 'z'], ...] = ('x', 'y', 'z'), - cell_centered=False) -> Tuple[int, ...]: + def coordinate_to_index( + self, + coordinate: Tuple[float, ...], + dimension: Tuple[Literal[1, 2, 3, "x", "y", "z"], ...] = ("x", "y", "z"), + cell_centered=False, + ) -> Tuple[int, ...]: """Finds the nearest point in the mesh's grid and returns its indices. :param coordinate: Tuple of 3 floats. If the dimension parameter is supplied, up to 2 @@ -93,7 +103,7 @@ def coordinate_to_index(self, coordinate: Tuple[float, ...], :param cell_centered: Instead of finding the nearest point on the mesh, find the center of the nearest cell. """ # Convert possible integer input to chars - dimension = tuple(('x', 'y', 'z')[dim - 1] if type(dim) == int else dim for dim in dimension) + dimension = tuple(("x", "y", "z")[dim - 1] if isinstance(dim, int) else dim for dim in dimension) ret = list() for i, dim in enumerate(dimension): @@ -102,16 +112,18 @@ def coordinate_to_index(self, coordinate: Tuple[float, ...], if cell_centered: coords = coords[:-1] + (coords[1] - coords[0]) / 2 idx = np.searchsorted(coords, co, side="left") - if co > 0 and (idx == len(coords) or math.fabs(co - coords[idx - 1]) < math.fabs( - co - coords[idx])): + if co > 0 and (idx == len(coords) or math.fabs(co - coords[idx - 1]) < math.fabs(co - coords[idx])): ret.append(idx - 1) else: ret.append(idx) return tuple(ret) - def get_nearest_coordinate(self, coordinate: Tuple[float, ...], - dimension: Tuple[Literal[1, 2, 3, 'x', 'y', 'z'], ...] = ('x', 'y', 'z'), - cell_centered=False) -> Tuple[float, ...]: + def get_nearest_coordinate( + self, + coordinate: Tuple[float, ...], + dimension: Tuple[Literal[1, 2, 3, "x", "y", "z"], ...] = ("x", "y", "z"), + cell_centered=False, + ) -> Tuple[float, ...]: """Finds the nearest point in the mesh's grid. :param coordinate: Tuple of 3 floats. If the dimension parameter is supplied, up to 2 @@ -128,12 +140,22 @@ def get_nearest_coordinate(self, coordinate: Tuple[float, ...], ret.append(coords[indices[i]]) return tuple(ret) - def _add_patches(self, bid: int, cell_centered: bool, quantity: str, short_name: str, unit: str, - patches: List[Patch], times: np.ndarray, lower_bounds: np.ndarray, - upper_bounds: np.ndarray): + def _add_patches( + self, + bid: int, + cell_centered: bool, + quantity: str, + short_name: str, + unit: str, + patches: List[Patch], + times: np.ndarray, + lower_bounds: np.ndarray, + upper_bounds: np.ndarray, + ): if bid not in self._boundary_data: - self._boundary_data[bid] = Boundary(Quantity(quantity, short_name, unit), cell_centered, times, - patches, lower_bounds, upper_bounds) + self._boundary_data[bid] = Boundary( + Quantity(quantity, short_name, unit), cell_centered, times, patches, lower_bounds, upper_bounds + ) # Add reference to parent boundary class in patches for patch in patches: patch._boundary_parent = self._boundary_data[bid] @@ -142,19 +164,22 @@ def _add_patches(self, bid: int, cell_centered: bool, quantity: str, short_name: _ = self._boundary_data[bid].data def get_boundary_data(self, quantity: Union[str, Quantity]): - if type(quantity) == Quantity: + if isinstance(quantity, Quantity): quantity = quantity.name - return next(b for b in self._boundary_data.values() if - b.quantity.name.lower() == quantity.lower() or b.quantity.short_name.lower() == quantity.lower()) + return next( + b + for b in self._boundary_data.values() + if b.quantity.name.lower() == quantity.lower() or b.quantity.short_name.lower() == quantity.lower() + ) - def __getitem__(self, dimension: Literal[0, 1, 2, 'x', 'y', 'z']) -> np.ndarray: + def __getitem__(self, dimension: Literal[0, 1, 2, "x", "y", "z"]) -> np.ndarray: """Get all values in given dimension. :param dimension: The dimension in which to return all grid values (0=x, 1=y, 2=z). """ # Convert possible integer input to chars - if type(dimension) == int: - dimension = ('x', 'y', 'z')[dimension] + if isinstance(dimension, int): + dimension = ("x", "y", "z")[dimension] return self.coordinates[dimension] def __eq__(self, other): diff --git a/fdsreader/fds_classes/mesh_collection.py b/fdsreader/fds_classes/mesh_collection.py index a58d684a..cc3c40f6 100644 --- a/fdsreader/fds_classes/mesh_collection.py +++ b/fdsreader/fds_classes/mesh_collection.py @@ -6,16 +6,15 @@ class MeshCollection(FDSDataCollection): """Collection of :class:`Obstruction` objects. Offers extensive functionality for filtering and - using obstructions as well as dependent such as :class:`Boundary`. + using obstructions as well as dependent such as :class:`Boundary`. """ def __init__(self, *meshes: Iterable[Mesh]): super().__init__(*meshes) def get_by_id(self, mesh_id: str): - """Get the mesh with corresponding id if it exists. - """ + """Get the mesh with corresponding id if it exists.""" return next((mesh for mesh in self if mesh.id == mesh_id), None) def __repr__(self): - return "MeshCollection(" + super(MeshCollection, self).__repr__() + ")" + return "MeshCollection(" + super().__repr__() + ")" diff --git a/fdsreader/fds_classes/surface.py b/fdsreader/fds_classes/surface.py index 154a2022..80131151 100644 --- a/fdsreader/fds_classes/surface.py +++ b/fdsreader/fds_classes/surface.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Tuple class Surface: @@ -15,9 +15,18 @@ class Surface: :ivar transparency: Transparency of the color (alpha channel). """ - def __init__(self, name: str, tmpm: float, material_emissivity: float, surface_type: int, - texture_width: float, texture_height: float, texture_map: Optional[str], - rgb: Tuple[float, float, float], transparency: float): + def __init__( + self, + name: str, + tmpm: float, + material_emissivity: float, + surface_type: int, + texture_width: float, + texture_height: float, + texture_map: str | None, + rgb: Tuple[float, float, float], + transparency: float, + ): self.name = name self.tmpm = tmpm self.material_emissivity = material_emissivity diff --git a/fdsreader/fds_classes/ventilation.py b/fdsreader/fds_classes/ventilation.py index 586b8bba..324fe12e 100644 --- a/fdsreader/fds_classes/ventilation.py +++ b/fdsreader/fds_classes/ventilation.py @@ -1,6 +1,6 @@ -from typing import Union, Tuple, Dict +from typing import Dict, Tuple, Union -from fdsreader.fds_classes import Surface, Mesh +from fdsreader.fds_classes import Mesh, Surface from fdsreader.utils import Extent @@ -44,12 +44,17 @@ class Ventilation: :ivar radius: Radius of the ventilation circle. """ - def __init__(self, surface: Surface, bound_indices: Tuple[int, int, int, int, int, int], - color_index: int, draw_type: int, - rgba: Union[Tuple[()], Tuple[float, float, float, float]] = (), - texture_origin: Union[Tuple[()], Tuple[float, float, float]] = (), - circular_vent_origin: Union[Tuple[()], Tuple[float, float, float]] = (), - radius: float = -1): + def __init__( + self, + surface: Surface, + bound_indices: Tuple[int, int, int, int, int, int], + color_index: int, + draw_type: int, + rgba: Union[Tuple[()], Tuple[float, float, float, float]] = (), + texture_origin: Union[Tuple[()], Tuple[float, float, float]] = (), + circular_vent_origin: Union[Tuple[()], Tuple[float, float, float]] = (), + radius: float = -1, + ): self.surface = surface self.bound_indices = bound_indices self.color_index = color_index @@ -72,4 +77,4 @@ def _add_subventilation(self, mesh: Mesh, extent: Extent): self._subventilations[mesh.id] = SubVentilation(mesh, extent) def __repr__(self, *args, **kwargs): - return f"Ventilation()" + return "Ventilation()" diff --git a/fdsreader/geom/__init__.py b/fdsreader/geom/__init__.py index e4842744..9714fe51 100644 --- a/fdsreader/geom/__init__.py +++ b/fdsreader/geom/__init__.py @@ -1,3 +1,3 @@ -from .geometry import Geometry, GeomBoundary - -from .geometry_collection import GeometryCollection +from .geometry import GeomBoundary as GeomBoundary +from .geometry import Geometry as Geometry +from .geometry_collection import GeometryCollection as GeometryCollection diff --git a/fdsreader/geom/geometry.py b/fdsreader/geom/geometry.py index 3db4924a..3d789495 100644 --- a/fdsreader/geom/geometry.py +++ b/fdsreader/geom/geometry.py @@ -1,11 +1,10 @@ -import threading -from typing import Tuple, Dict, Iterable +from typing import Dict, Iterable, Tuple import numpy as np +import fdsreader.utils.fortran_data as fdtype from fdsreader.fds_classes import Surface from fdsreader.utils import Quantity -import fdsreader.utils.fortran_data as fdtype class GeomBoundary: @@ -29,8 +28,9 @@ def __init__(self, quantity: Quantity, times: np.ndarray, n_t: int): self.file_paths_be: Dict[int, str] = dict() self.file_paths_gbf: Dict[int, str] = dict() - def _add_data(self, mesh: int, file_path_be: str, file_path_gbf: str, lower_bounds: np.ndarray, - upper_bounds: np.ndarray): + def _add_data( + self, mesh: int, file_path_be: str, file_path_gbf: str, lower_bounds: np.ndarray, upper_bounds: np.ndarray + ): self.file_paths_be[mesh] = file_path_be self.file_paths_gbf[mesh] = file_path_gbf @@ -46,35 +46,32 @@ def _load_data(self): file_path_be = self.file_paths_be[mesh] file_path_gbf = self.file_paths_gbf[mesh] # Load .gbf - with open(file_path_gbf, 'rb') as infile: - offset = fdtype.INT.itemsize * 2 + fdtype.new( - (('i', 3),)).itemsize + fdtype.FLOAT.itemsize + with open(file_path_gbf, "rb") as infile: + offset = fdtype.INT.itemsize * 2 + fdtype.new((("i", 3),)).itemsize + fdtype.FLOAT.itemsize infile.seek(offset) - dtype_meta = fdtype.new((('i', 3),)) + dtype_meta = fdtype.new((("i", 3),)) n_vertices, n_faces, _ = np.fromfile(infile, dtype_meta, 1)[0][1] - dtype_vertices = fdtype.new((('f', 3 * n_vertices),)) - vertices = np.fromfile(infile, dtype_vertices, 1)[0][1].reshape( - (n_vertices, 3)).astype(float) + dtype_vertices = fdtype.new((("f", 3 * n_vertices),)) + vertices = np.fromfile(infile, dtype_vertices, 1)[0][1].reshape((n_vertices, 3)).astype(float) - dtype_faces = fdtype.new((('i', 3 * n_faces),)) - faces = fdtype.read(infile, dtype_faces, 1)[0][0].reshape((n_faces, 3)).astype( - int) - 1 + dtype_faces = fdtype.new((("i", 3 * n_faces),)) + faces = fdtype.read(infile, dtype_faces, 1)[0][0].reshape((n_faces, 3)).astype(int) - 1 # Load .be - dtype_faces = fdtype.new((('f', n_faces),)) - dtype_meta = fdtype.new((('i', 4),)) + dtype_faces = fdtype.new((("f", n_faces),)) + dtype_meta = fdtype.new((("i", 4),)) data = np.empty((self.n_t, n_faces), dtype=np.float32) - with open(file_path_be, 'rb') as infile: + with open(file_path_be, "rb") as infile: offset = fdtype.INT.itemsize * 2 infile.seek(offset) for t in range(self.n_t): # Skip time value infile.seek(fdtype.FLOAT.itemsize, 1) - assert fdtype.read(infile, dtype_meta, 1)[0][0][3] == n_faces, "Something went wrong, please" \ - " submit an issue on Github" \ - " attaching your fds case!" + assert fdtype.read(infile, dtype_meta, 1)[0][0][3] == n_faces, ( + "Something went wrong, please submit an issue on Github attaching your fds case!" + ) if n_faces > 0: data[t, :] = np.fromfile(infile, dtype_faces, 1)[0][1] @@ -140,7 +137,8 @@ def _load_data(self): # times.append(self.n_t) # threads = list() # for i in range(len(times)-1): - # threads.append(BEReader(file_path_be, offset+stride*times[i], times[i+1] - times[i], data, times[i], n_faces)) + # threads.append(BEReader(file_path_be, offset+stride*times[i], times[i+1] - times[i], + # data, times[i], n_faces)) # # for thread in threads: # thread.start() @@ -154,8 +152,7 @@ def _load_data(self): @property def vertices(self) -> Iterable: - """Returns a global array of the vertices from all meshes. - """ + """Returns a global array of the vertices from all meshes.""" if not hasattr(self, "_vertices"): self._load_data() @@ -165,15 +162,14 @@ def vertices(self) -> Iterable: for v in self._vertices.values(): size = v.shape[0] - ret[counter:counter + size, :] = v + ret[counter : counter + size, :] = v counter += size return ret @property def faces(self) -> np.ndarray: - """Returns a global array of the faces from all meshes. - """ + """Returns a global array of the faces from all meshes.""" if not hasattr(self, "_faces"): self._load_data() @@ -184,7 +180,7 @@ def faces(self) -> np.ndarray: for m, f in self._faces.items(): size = f.shape[0] - ret[counter:counter + size, :] = f + verts_counter + ret[counter : counter + size, :] = f + verts_counter counter += size verts_counter += self._vertices[m].shape[0] @@ -192,8 +188,7 @@ def faces(self) -> np.ndarray: @property def data(self) -> np.ndarray: - """Returns a global array of the loaded data for the quantity with data from all meshes. - """ + """Returns a global array of the loaded data for the quantity with data from all meshes.""" if not hasattr(self, "_data"): self._load_data() @@ -203,7 +198,7 @@ def data(self) -> np.ndarray: counter = 0 for d in self._data.values(): size = d[t].shape[0] - ret[t, counter:counter + size] = d[t] + ret[t, counter : counter + size] = d[t] counter += size return ret @@ -229,8 +224,7 @@ def data(self) -> np.ndarray: @property def vmin(self) -> float: - """Minimum value of all faces at any time. - """ + """Minimum value of all faces at any time.""" curr_min = min(np.min(b) for b in self.lower_bounds.values()) if curr_min == 0.0: return min(min(np.min(p.data) for p in ps) for ps in self._faces.values()) @@ -238,8 +232,7 @@ def vmin(self) -> float: @property def vmax(self) -> float: - """Maximum value of all faces at any time. - """ + """Maximum value of all faces at any time.""" curr_max = max(np.max(b) for b in self.upper_bounds.values()) if curr_max == np.float32(-1e33): return max(max(np.max(p.data) for p in ps) for ps in self._faces.values()) @@ -260,9 +253,15 @@ class Geometry: :ivar surface: Surface object used for the geometry. """ - def __init__(self, file_path: str, texture_mapping: str, - texture_origin: Tuple[float, float, float], - is_terrain: bool, rgb: Tuple[int, int, int], surface: Surface = None): + def __init__( + self, + file_path: str, + texture_mapping: str, + texture_origin: Tuple[float, float, float], + is_terrain: bool, + rgb: Tuple[int, int, int], + surface: Surface = None, + ): self.file_path = file_path self.texture_map = texture_mapping self.texture_origin = texture_origin diff --git a/fdsreader/geom/geometry_collection.py b/fdsreader/geom/geometry_collection.py index 6f71de4b..f25d652f 100644 --- a/fdsreader/geom/geometry_collection.py +++ b/fdsreader/geom/geometry_collection.py @@ -1,4 +1,4 @@ -from typing import Iterable, Union, List +from typing import Iterable, List, Union from fdsreader import settings from fdsreader.geom import GeomBoundary @@ -7,7 +7,7 @@ class GeometryCollection(FDSDataCollection): """Collection of :class:`GeomBoundary` objects. Offers extensive functionality for filtering and - using geometry data. + using geometry data. """ def __init__(self, *geom_boundaries: Iterable[GeomBoundary]): @@ -22,12 +22,14 @@ def quantities(self) -> List[Quantity]: return list({geom.name for geom in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): - """Filters all GeomBoundaries by a specific quantity. - """ - if type(quantity) == Quantity: + """Filters all GeomBoundaries by a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return GeometryCollection(x for x in self if - x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower()) + return GeometryCollection( + x + for x in self + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def __repr__(self): - return "GeometryCollection(" + super(GeometryCollection, self).__repr__() + ")" + return "GeometryCollection(" + super().__repr__() + ")" diff --git a/fdsreader/isof/__init__.py b/fdsreader/isof/__init__.py index 219a9c1a..2b876109 100644 --- a/fdsreader/isof/__init__.py +++ b/fdsreader/isof/__init__.py @@ -1,3 +1,2 @@ -from .isosurface import Isosurface - -from .isosurface_collection import IsosurfaceCollection +from .isosurface import Isosurface as Isosurface +from .isosurface_collection import IsosurfaceCollection as IsosurfaceCollection diff --git a/fdsreader/isof/isosurface.py b/fdsreader/isof/isosurface.py index 811b6006..10118f27 100644 --- a/fdsreader/isof/isosurface.py +++ b/fdsreader/isof/isosurface.py @@ -1,14 +1,14 @@ +import math import operator from functools import reduce -from typing import BinaryIO, Dict, Union, List, Tuple, Optional +from typing import BinaryIO, Dict, List, Tuple, Union import numpy as np -import math +import fdsreader.utils.fortran_data as fdtype +from fdsreader import settings from fdsreader.fds_classes import Mesh from fdsreader.utils import Quantity -from fdsreader import settings -import fdsreader.utils.fortran_data as fdtype class SubSurface: @@ -29,12 +29,11 @@ def __init__(self, mesh: Mesh, iso_filepath: str, times: List, viso_filepath: st if viso_filepath != "": self.v_file_path = viso_filepath - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: nlevels = fdtype.read(infile, fdtype.INT, 3)[2][0][0] - dtype_header_levels = fdtype.new((('f', nlevels),)) - dtype_header_zeros = fdtype.combine(fdtype.INT, fdtype.new((('i', 2),))) - self._offset = fdtype.INT.itemsize * 3 + dtype_header_levels.itemsize + \ - dtype_header_zeros.itemsize + dtype_header_levels = fdtype.new((("f", nlevels),)) + dtype_header_zeros = fdtype.combine(fdtype.INT, fdtype.new((("i", 2),))) + self._offset = fdtype.INT.itemsize * 3 + dtype_header_levels.itemsize + dtype_header_zeros.itemsize self.times = times self.n_vertices = list() @@ -45,24 +44,22 @@ def __init__(self, mesh: Mesh, iso_filepath: str, times: List, viso_filepath: st if self.has_color_data: if not settings.LAZY_LOAD: - with open(self.v_file_path, 'rb') as infile: + with open(self.v_file_path, "rb") as infile: self._load_vdata(infile) @property def vertices(self): - """Property to lazy load all vertices for all triangles of any level. - """ + """Property to lazy load all vertices for all triangles of any level.""" if not hasattr(self, "_vertices"): - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: self._load_data(infile) return self._vertices @property def triangles(self): - """Property to lazy load all triangles of any level. - """ + """Property to lazy load all triangles of any level.""" if not hasattr(self, "_triangles"): - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: self._load_data(infile) return self._triangles @@ -72,35 +69,34 @@ def surfaces(self): The list has the size n_triangles, while the indices correspond to indices of the triangles. """ if not hasattr(self, "_surfaces"): - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: self._load_data(infile) return self._surfaces @property def has_color_data(self): - """Defines whether there is color data for this subsurface or not. - """ + """Defines whether there is color data for this subsurface or not.""" return hasattr(self, "v_file_path") @property def colors(self): - """Property to lazy load the color data that might be associated with the isosurfaces. - """ + """Property to lazy load the color data that might be associated with the isosurfaces.""" if self.has_color_data: if not hasattr(self, "_colors"): - with open(self.v_file_path, 'rb') as infile: + with open(self.v_file_path, "rb") as infile: self._load_vdata(infile) return self._colors else: - raise UserWarning("The isosurface does not have any associated color-data. Use the" - " attribute 'has_color_data' to check if an isosurface has associated" - " color-data.") + raise UserWarning( + "The isosurface does not have any associated color-data. Use the" + " attribute 'has_color_data' to check if an isosurface has associated" + " color-data." + ) def _load_data(self, infile: BinaryIO): - """Loads data for the subsurface which is given in an iso file. - """ - dtype_time = fdtype.new((('f', 1), ('i', 1))) - dtype_dims = fdtype.new((('i', 2),)) + """Loads data for the subsurface which is given in an iso file.""" + dtype_time = fdtype.new((("f", 1), ("i", 1))) + dtype_dims = fdtype.new((("i", 2),)) self._vertices = list() self._triangles = list() @@ -115,16 +111,16 @@ def _load_data(self, infile: BinaryIO): n_vertices = dims_data[0][0][0] n_triangles = dims_data[0][0][1] if n_vertices > 0: - dtype_vertices = fdtype.new((('f', 3 * n_vertices),)) - dtype_triangles = fdtype.new((('i', 3 * n_triangles),)) - dtype_surfaces = fdtype.new((('i', n_triangles),)) + dtype_vertices = fdtype.new((("f", 3 * n_vertices),)) + dtype_triangles = fdtype.new((("i", 3 * n_triangles),)) + dtype_surfaces = fdtype.new((("i", n_triangles),)) self._vertices.append( - fdtype.read(infile, dtype_vertices, 1)[0][0].reshape((n_vertices, 3)).astype( - float)) + fdtype.read(infile, dtype_vertices, 1)[0][0].reshape((n_vertices, 3)).astype(float) + ) self._triangles.append( - fdtype.read(infile, dtype_triangles, 1)[0][0].reshape((n_triangles, 3)).astype( - int) - 1) + fdtype.read(infile, dtype_triangles, 1)[0][0].reshape((n_triangles, 3)).astype(int) - 1 + ) self._surfaces.append(fdtype.read(infile, dtype_surfaces, 1)[0][0].astype(int) - 1) self.n_vertices.append(n_vertices) self.n_triangles.append(n_triangles) @@ -140,10 +136,9 @@ def _load_data(self, infile: BinaryIO): self.n_t = len(self.times) def _load_vdata(self, infile: BinaryIO): - """Loads all color data for all isosurfaces in a given viso file. - """ + """Loads all color data for all isosurfaces in a given viso file.""" self._colors = np.empty((self.n_t,), dtype=object) - dtype_nverts = fdtype.new((('i', 4),)) + dtype_nverts = fdtype.new((("i", 4),)) infile.seek(fdtype.INT.itemsize * 2) t = fdtype.read(infile, fdtype.FLOAT, 1) @@ -151,12 +146,11 @@ def _load_vdata(self, infile: BinaryIO): time_index = self.times.index(t[0][0][0]) n_vertices = fdtype.read(infile, dtype_nverts, 1)[0][0][2] if n_vertices > 0: - self._colors[time_index] = fdtype.read(infile, fdtype.new((('f', n_vertices),)), 1) + self._colors[time_index] = fdtype.read(infile, fdtype.new((("f", n_vertices),)), 1) t = fdtype.read(infile, fdtype.FLOAT, 1) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_vertices"): del self._vertices if hasattr(self, "_triangles"): @@ -181,9 +175,18 @@ class Isosurface: :ivar levels: All isosurface levels. """ - def __init__(self, isosurface_id: int, double_quantity: bool, quantity: str, - short_name: str, unit: str, levels: List[float], v_quantity: str = "", - v_short_name: str = "", v_unit: str = ""): + def __init__( + self, + isosurface_id: int, + double_quantity: bool, + quantity: str, + short_name: str, + unit: str, + levels: List[float], + v_quantity: str = "", + v_short_name: str = "", + v_unit: str = "", + ): self.id = isosurface_id self.quantity = Quantity(quantity, short_name, unit) self._double_quantity = double_quantity @@ -196,8 +199,7 @@ def __init__(self, isosurface_id: int, double_quantity: bool, quantity: str, if self._double_quantity: self.v_quantity = Quantity(v_quantity, v_short_name, v_unit) - def _add_subsurface(self, mesh: Mesh, iso_file_path: str, - viso_file_path: str = "") -> SubSurface: + def _add_subsurface(self, mesh: Mesh, iso_file_path: str, viso_file_path: str = "") -> SubSurface: if viso_file_path != "": subsurface = SubSurface(mesh, iso_file_path, self._times, viso_file_path) else: @@ -207,37 +209,32 @@ def _add_subsurface(self, mesh: Mesh, iso_file_path: str, return subsurface def __getitem__(self, mesh: str) -> SubSurface: - """Returns the :class:`SubSurface` that contains data for the given mesh id. - """ + """Returns the :class:`SubSurface` that contains data for the given mesh id.""" return self._subsurfaces[mesh] @property def vertices(self) -> Dict[str, List[np.ndarray]]: - """Gets all vertices per mesh. - """ + """Gets all vertices per mesh.""" return {mesh: subsurface.vertices for mesh, subsurface in self._subsurfaces.items()} @property def triangles(self) -> Dict[str, List[np.ndarray]]: - """Gets all triangles per mesh. - """ + """Gets all triangles per mesh.""" return {mesh: subsurface.triangles for mesh, subsurface in self._subsurfaces.items()} @property def surfaces(self) -> Dict[str, List[np.ndarray]]: - """Gets all surfaces per mesh. - """ + """Gets all surfaces per mesh.""" return {mesh: subsurface.surfaces for mesh, subsurface in self._subsurfaces.items()} - def to_global(self, time: Union[int, float]) -> Tuple[ - np.ndarray, List[np.ndarray], Optional[np.ndarray]]: + def to_global(self, time: Union[int, float]) -> Tuple[np.ndarray, List[np.ndarray], np.ndarray | None]: """Creates an array containing all global vertices and a list containing numpy arrays with triangles for each surface level. :param time: Either the index of the timestep or an actual time value. In the latter case data for the nearest matching timestep will be used. """ - if type(time) == float: + if isinstance(time, float): time = self.get_nearest_timestep(time) if time > len(self.times): @@ -245,8 +242,7 @@ def to_global(self, time: Union[int, float]) -> Tuple[ if time < 0: time = 0 - n_vertices = sum( - x[time].shape[0] if len(x[time].shape) > 0 else 0 for x in self.vertices.values()) + n_vertices = sum(x[time].shape[0] if len(x[time].shape) > 0 else 0 for x in self.vertices.values()) vertices = np.empty((n_vertices, 3)) colors = np.empty((n_vertices,)) if self.has_color_data else None @@ -257,11 +253,10 @@ def to_global(self, time: Union[int, float]) -> Tuple[ verts_counter += verts.shape[0] vertices[tmp:verts_counter] = verts if self.has_color_data: - colors[tmp: verts_counter] = self[mesh].colors[time] + colors[tmp:verts_counter] = self[mesh].colors[time] triangles = list() - num_levels = max( - max(np.max(t) if t.shape[0] != 0 else 0 for t in s) for s in self.surfaces.values()) + 1 + num_levels = max(max(np.max(t) if t.shape[0] != 0 else 0 for t in s) for s in self.surfaces.values()) + 1 for surf in range(num_levels): n_triangles = sum(np.count_nonzero(s[time] == surf) for s in self.surfaces.values()) triangles.append(np.empty((n_triangles, 3), dtype=int)) @@ -278,16 +273,17 @@ def to_global(self, time: Union[int, float]) -> Tuple[ return vertices, triangles, colors def get_pyvista_mesh(self, vertices: np.ndarray, triangles: np.ndarray): - """Creates a PyVista mesh from the data. - """ + """Creates a PyVista mesh from the data.""" try: from pyvista import PolyData triangles = np.hstack(np.append(np.full((triangles.shape[0], 1), 3), triangles, axis=1)) return PolyData(vertices, triangles) except ImportError: - raise ImportError("The 'get_pyvista_mesh' method requires the PyVista python-package to" - " be installed. Consider installing it via 'pip install pyvista'.") + raise ImportError( + "The 'get_pyvista_mesh' method requires the PyVista python-package to" + " be installed. Consider installing it via 'pip install pyvista'." + ) def join_pyvista_meshes(self, meshes: List): """Combines multiple PyVista meshes. @@ -297,13 +293,16 @@ def join_pyvista_meshes(self, meshes: List): try: from pyvista import PolyData - assert all(type(mesh) == PolyData for mesh in - meshes), "Argument 'meshes' has to be a sequence of type pyvista.Polydata" + assert all(isinstance(mesh, PolyData) for mesh in meshes), ( + "Argument 'meshes' has to be a sequence of type pyvista.Polydata" + ) return reduce(operator.add, meshes) except ImportError: - raise ImportError("The 'join_pyvista_meshes' method requires the PyVista python-package" - " to be installed. Consider installing it via 'pip install pyvista'.") + raise ImportError( + "The 'join_pyvista_meshes' method requires the PyVista python-package" + " to be installed. Consider installing it via 'pip install pyvista'." + ) def export(self, file_path: str, mesh): """Export the isosurface for a single timestep into one of many formats. @@ -316,52 +315,53 @@ def export(self, file_path: str, mesh): try: from pyvista import PolyData - assert type(mesh) == PolyData, "Argument 'mesh' has to be of type pyvista.Polydata" + assert isinstance(mesh, PolyData), "Argument 'mesh' has to be of type pyvista.Polydata" if "vtk" in file_path or "vtp" in file_path: return mesh.save(file_path) else: try: - import meshio + import meshio # noqa: F401 return mesh.save_meshio(file_path) except ImportError: - raise ImportError("The 'export' method requires the Meshio python-package to be" - " installed. " - "Consider installing it via 'pip install meshio'.") + raise ImportError( + "The 'export' method requires the Meshio python-package to be" + " installed. " + "Consider installing it via 'pip install meshio'." + ) except ImportError: - raise ImportError("The 'export' method requires the PyVista python-package to be" - " installed. Consider installing it via 'pip install pyvista'.") + raise ImportError( + "The 'export' method requires the PyVista python-package to be" + " installed. Consider installing it via 'pip install pyvista'." + ) def get_nearest_timestep(self, time: float) -> int: - """Calculates the nearest timestep for which data has been output for this isosurface. - """ + """Calculates the nearest timestep for which data has been output for this isosurface.""" idx = np.searchsorted(self.times, time, side="left") - if time > 0 and (idx == len(self.times) or math.fabs( - time - self.times[idx - 1]) < math.fabs(time - self.times[idx])): + if time > 0 and ( + idx == len(self.times) or math.fabs(time - self.times[idx - 1]) < math.fabs(time - self.times[idx]) + ): return idx - 1 else: return idx @property def has_color_data(self) -> bool: - """Defines whether there is color data for this isosurface or not. - """ + """Defines whether there is color data for this isosurface or not.""" return self._double_quantity @property def times(self) -> List[float]: - """List containing all times for which data has been recorded. - """ + """List containing all times for which data has been recorded.""" if len(self._times) == 0: # Implicitly load the data for one subsurface and read times _ = next(iter(self._subsurfaces.values())).vertices return self._times def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for subsurface in self._subsurfaces.values(): subsurface.clear_cache() diff --git a/fdsreader/isof/isosurface_collection.py b/fdsreader/isof/isosurface_collection.py index 98238722..fb5f8473 100644 --- a/fdsreader/isof/isosurface_collection.py +++ b/fdsreader/isof/isosurface_collection.py @@ -1,4 +1,4 @@ -from typing import Iterable, Union, List +from typing import Iterable, List, Union from fdsreader.isof import Isosurface from fdsreader.utils.data import FDSDataCollection, Quantity @@ -6,7 +6,7 @@ class IsosurfaceCollection(FDSDataCollection): """Collection of :class:`Isosurface` objects. Offers extensive functionality for filtering and - using isosurfaces as well as its subclasses such as :class:`SubSurface`. + using isosurfaces as well as its subclasses such as :class:`SubSurface`. """ def __init__(self, *isosurfaces: Iterable[Isosurface]): @@ -17,12 +17,14 @@ def quantities(self) -> List[Quantity]: return list({iso.name for iso in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): - """Filters all isosurfaces by a specific quantity. - """ - if type(quantity) == Quantity: + """Filters all isosurfaces by a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return IsosurfaceCollection(x for x in self if - x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower()) + return IsosurfaceCollection( + x + for x in self + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def __repr__(self): - return "IsosurfaceCollection(" + super(IsosurfaceCollection, self).__repr__() + ")" + return "IsosurfaceCollection(" + super().__repr__() + ")" diff --git a/fdsreader/part/__init__.py b/fdsreader/part/__init__.py index 63843560..5ffb17a2 100644 --- a/fdsreader/part/__init__.py +++ b/fdsreader/part/__init__.py @@ -1,3 +1,2 @@ -from .particle import Particle - -from .particle_collection import ParticleCollection +from .particle import Particle as Particle +from .particle_collection import ParticleCollection as ParticleCollection diff --git a/fdsreader/part/particle.py b/fdsreader/part/particle.py index fb5f4d04..e04793b3 100644 --- a/fdsreader/part/particle.py +++ b/fdsreader/part/particle.py @@ -1,8 +1,7 @@ -from typing import List, Tuple, Dict, Sequence +from typing import Dict, List, Sequence, Tuple import numpy as np -from fdsreader.fds_classes import Mesh from fdsreader.utils import Quantity @@ -38,8 +37,7 @@ def id(self): return self.class_name def filter_by_tag(self, tag: int): - """Filter all particles by a single one with the specified tag. - """ + """Filter all particles by a single one with the specified tag.""" data = self.data tags = self.tags positions = self.positions @@ -74,7 +72,7 @@ def filter_by_tag(self, tag: int): @property def data(self) -> Dict[str, List[np.ndarray]]: """Dictionary with quantities as keys and a list with a numpy array for each timestep which - contains data for each particle in that timestep. + contains data for each particle in that timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() @@ -83,7 +81,7 @@ def data(self) -> Dict[str, List[np.ndarray]]: @property def tags(self) -> List[np.ndarray]: """List with a numpy array for each timestep which contains a tag for each particle in that - timestep. + timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() @@ -92,15 +90,14 @@ def tags(self) -> List[np.ndarray]: @property def positions(self) -> List[np.ndarray]: """List with a numpy array for each timestep which contains the position of each particle in - that timestep. + that timestep. """ if len(self._positions) == 0 and len(self._tags) == 0: self._init_callback() return self._positions def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if len(self._positions) != 0: del self._positions self._positions = list() diff --git a/fdsreader/part/particle_collection.py b/fdsreader/part/particle_collection.py index 400d0751..6404718c 100644 --- a/fdsreader/part/particle_collection.py +++ b/fdsreader/part/particle_collection.py @@ -1,16 +1,15 @@ -from typing import Iterable, Dict, List +from typing import Dict, Iterable, List + import numpy as np -from fdsreader.part import Particle -from fdsreader.fds_classes import Mesh -from fdsreader.utils.data import FDSDataCollection, Quantity import fdsreader.utils.fortran_data as fdtype from fdsreader import settings +from fdsreader.part import Particle +from fdsreader.utils.data import FDSDataCollection, Quantity class ParticleCollection(FDSDataCollection): - """Collection of :class:`Particle` objects. - """ + """Collection of :class:`Particle` objects.""" def __init__(self, times: Iterable[float], particles: Iterable[Particle]): super().__init__(particles) @@ -38,8 +37,7 @@ def _post_init(self): self._load_data() def _load_data(self): - """Function to read in all particle data for a simulation. - """ + """Function to read in all particle data for a simulation.""" particles = self pointer_location = {particle: [0] * len(self.times) for particle in particles} @@ -55,14 +53,15 @@ def _load_data(self): particle._tags.append(np.empty((size,), dtype=int)) for mesh, file_path in self._file_paths.items(): - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: # Initial offset (ONE, fds version and number of particle classes) offset = 3 * fdtype.INT.itemsize # Number of quantities for each particle class (plus an INTEGER_ZERO) - offset += fdtype.new((('i', 2),)).itemsize * len(particles) + offset += fdtype.new((("i", 2),)).itemsize * len(particles) # 30-char long name and unit information for each quantity - offset += fdtype.new((('c', 30),)).itemsize * 2 * sum( - [len(particle.quantities) for particle in particles]) + offset += ( + fdtype.new((("c", 30),)).itemsize * 2 * sum([len(particle.quantities) for particle in particles]) + ) infile.seek(offset) for t in range(len(self.times)): @@ -75,30 +74,32 @@ def _load_data(self): n_particles = fdtype.read(infile, fdtype.INT, 1)[0][0][0] offset = pointer_location[particle][t] # Read positions - dtype_positions = fdtype.new((('f', 3 * n_particles),)) + dtype_positions = fdtype.new((("f", 3 * n_particles),)) pos = fdtype.read(infile, dtype_positions, 1)[0][0] - particle._positions[t][offset: offset + n_particles] = pos.reshape((n_particles, 3), - order='F').astype(float) + particle._positions[t][offset : offset + n_particles] = pos.reshape( + (n_particles, 3), order="F" + ).astype(float) # Read tags - dtype_tags = fdtype.new((('i', n_particles),)) - particle._tags[t][offset: offset + n_particles] = fdtype.read(infile, dtype_tags, 1)[0][0] + dtype_tags = fdtype.new((("i", n_particles),)) + particle._tags[t][offset : offset + n_particles] = fdtype.read(infile, dtype_tags, 1)[0][0] # Read actual quantity values if len(particle.quantities) > 0: - dtype_data = fdtype.new( - (('f', str((n_particles, len(particle.quantities)))),)) + dtype_data = fdtype.new((("f", str((n_particles, len(particle.quantities)))),)) data_raw = fdtype.read(infile, dtype_data, 1)[0][0].reshape( - (n_particles, len(particle.quantities)), order='F') + (n_particles, len(particle.quantities)), order="F" + ) for q, quantity in enumerate(particle.quantities): - particle._data[quantity.name][t][ - offset:offset + n_particles] = data_raw[:, q].astype(float) + particle._data[quantity.name][t][offset : offset + n_particles] = data_raw[:, q].astype( + float + ) pointer_location[particle][t] += particle.n_particles[mesh][t] def __getitem__(self, key): - if type(key) == int: + if isinstance(key, int): return self._elements[key] for particle in self: if particle.class_name == key: @@ -113,4 +114,4 @@ def __contains__(self, value): return False def __repr__(self): - return "ParticleCollection(" + super(ParticleCollection, self).__repr__() + ")" + return "ParticleCollection(" + super().__repr__() + ")" diff --git a/fdsreader/pl3d/__init__.py b/fdsreader/pl3d/__init__.py index ad9f4a52..16792c01 100644 --- a/fdsreader/pl3d/__init__.py +++ b/fdsreader/pl3d/__init__.py @@ -1,3 +1,2 @@ -from .pl3d import Plot3D - -from .plot3D_collection import Plot3DCollection +from .pl3d import Plot3D as Plot3D +from .plot3D_collection import Plot3DCollection as Plot3DCollection diff --git a/fdsreader/pl3d/pl3d.py b/fdsreader/pl3d/pl3d.py index 721e97bc..3fd508bc 100644 --- a/fdsreader/pl3d/pl3d.py +++ b/fdsreader/pl3d/pl3d.py @@ -1,22 +1,22 @@ +import bisect import logging +import math import os from copy import deepcopy -from typing import Dict, Sequence, Tuple, Literal, Union, List +from typing import Dict, List, Literal, Tuple, Union + import numpy as np -import math -import bisect +import fdsreader.utils.fortran_data as fdtype +from fdsreader import settings from fdsreader.fds_classes import Mesh from fdsreader.utils import Quantity -from fdsreader import settings -import fdsreader.utils.fortran_data as fdtype _HANDLED_FUNCTIONS = {np.mean: (lambda pl: pl.mean)} def implements(np_function): - """Decorator to register an __array_function__ implementation for Plot3Ds. - """ + """Decorator to register an __array_function__ implementation for Plot3Ds.""" def decorator(func): _HANDLED_FUNCTIONS[np_function] = func @@ -30,8 +30,9 @@ class SubPlot3D: :ivar mesh: The mesh containing the data. """ + # Offset of the binary file to the end of the file header. - _offset = fdtype.new((('i', 3),)).itemsize + fdtype.new((('i', 4),)).itemsize + _offset = fdtype.new((("i", 3),)).itemsize + fdtype.new((("i", 4),)).itemsize def __init__(self, mesh: Mesh, quantity_idx: int): self.file_paths: List[str] = list() # Path to the binary data file for each time step @@ -49,17 +50,17 @@ def data(self) -> np.ndarray: """ if not hasattr(self, "_data"): self._data = np.empty(shape=(len(self.file_paths),) + self.mesh.dimension.shape()) - dtype_data = fdtype.new((('f', self.mesh.dimension.size() * 5),)) + dtype_data = fdtype.new((("f", self.mesh.dimension.size() * 5),)) for t, file_path in enumerate(self.file_paths): - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: infile.seek(self._offset) self._data[t, :, :, :] = fdtype.read(infile, dtype_data, 1)[0][0].reshape( - self.mesh.dimension.shape() + (5,), order='F')[:, :, :, self._quantity_idx] + self.mesh.dimension.shape() + (5,), order="F" + )[:, :, :, self._quantity_idx] return self._data def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data @@ -96,8 +97,7 @@ def _add_subplot(self, filename: str, time: float, quantity: Quantity, quantity_ _ = self._subplots[mesh.id].data def __getitem__(self, mesh: Mesh): - """Returns the :class:`SubPlot` that contains data for the given mesh. - """ + """Returns the :class:`SubPlot` that contains data for the given mesh.""" return self._subplots[mesh.id] @implements(np.mean) @@ -123,28 +123,28 @@ def std(self) -> float: return np.sqrt(sum / N) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for subplot in self._subplots.values(): subplot.clear_cache() - def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: bool = False) -> \ - Union[np.ndarray, Tuple[np.ndarray, Dict[Literal['x', 'y', 'z'], np.ndarray]]]: + def to_global( + self, masked: bool = False, fill: float = 0, return_coordinates: bool = False + ) -> Union[np.ndarray, Tuple[np.ndarray, Dict[Literal["x", "y", "z"], np.ndarray]]]: """Creates a global numpy ndarray from all subplots. - :param masked: Whether to apply the obstruction mask to the data or not. - :param fill: The fill value to use for masked entries. Only used when masked=True. - :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. + :param masked: Whether to apply the obstruction mask to the data or not. + :param fill: The fill value to use for masked entries. Only used when masked=True. + :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. """ if len(self._subplots) == 0: if return_coordinates: - return np.array([]), {d: np.array([]) for d in ('x', 'y', 'z')} + return np.array([]), {d: np.array([]) for d in ("x", "y", "z")} else: return np.array([]) - coord_min = {'x': math.inf, 'y': math.inf, 'z': math.inf} - coord_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} - for dim in ('x', 'y', 'z'): + coord_min = {"x": math.inf, "y": math.inf, "z": math.inf} + coord_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} + for dim in ("x", "y", "z"): for subplot in self._subplots.values(): co = subplot.mesh.coordinates[dim] coord_min[dim] = min(co[0], coord_min[dim]) @@ -153,29 +153,32 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b # The global grid will use the finest mesh as base and duplicate values of the coarser # meshes. Therefore, we first find the finest mesh and calculate the step size in each # dimension. - step_sizes_min = {'x': coord_max['x'] - coord_min['x'], - 'y': coord_max['y'] - coord_min['y'], - 'z': coord_max['z'] - coord_min['z']} - step_sizes_max = {'x': 0, 'y': 0, 'z': 0} + step_sizes_min = { + "x": coord_max["x"] - coord_min["x"], + "y": coord_max["y"] - coord_min["y"], + "z": coord_max["z"] - coord_min["z"], + } + step_sizes_max = {"x": 0, "y": 0, "z": 0} steps = dict() - global_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} + global_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): for subplot in self._subplots.values(): step_size = subplot.mesh.coordinates[dim][1] - subplot.mesh.coordinates[dim][0] step_sizes_min[dim] = min(step_size, step_sizes_min[dim]) step_sizes_max[dim] = max(step_size, step_sizes_max[dim]) global_max[dim] = max(subplot.mesh.coordinates[dim][-1], global_max[dim]) - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): if step_sizes_min[dim] == 0: step_sizes_min[dim] = math.inf steps[dim] = 1 else: - steps[dim] = max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), - 1) + 1 # + step_sizes_max[dim] / step_sizes_min[dim] + steps[dim] = ( + max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), 1) + 1 + ) # + step_sizes_max[dim] / step_sizes_min[dim] - grid = np.full((self.n_t, steps['x'], steps['y'], steps['z']), np.nan) + grid = np.full((self.n_t, steps["x"], steps["y"], steps["z"]), np.nan) start_idx = dict() end_idx = dict() @@ -184,15 +187,19 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b if masked: mask = subplot.mesh.get_obstruction_mask(self.times) - start_idx = {dim: int(round( - (subplot.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) for dim in ('x', 'y', 'z')} - end_idx = {dim: int(round( - (subplot.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) for dim in ('x', 'y', 'z')} + start_idx = { + dim: int(round((subplot.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) + for dim in ("x", "y", "z") + } + end_idx = { + dim: int(round((subplot.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) + for dim in ("x", "y", "z") + } temp_data = dict() temp_mask = dict() for axis in range(3): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] # Temporarily save border points to add them back to the array again later if np.isclose(subplot.mesh.coordinates[dim][-1], global_max[dim]): temp_data_slices = [slice(s) for s in subplot_data.shape] @@ -205,26 +212,35 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b # We ignore border points unless they are actually on the border of the simulation space as all # other border points actually appear twice, as the subslices overlap. This only # applies for face_centered slices, as cell_centered slices will not overlap. - reduced_shape_slices = (slice(subplot.data.shape[0]),) + tuple(slice(1, None) for s in subplot.data.shape[1:]) + reduced_shape_slices = (slice(subplot.data.shape[0]),) + tuple( + slice(1, None) for s in subplot.data.shape[1:] + ) subplot_data = subplot_data[reduced_shape_slices] if masked: mask = mask[reduced_shape_slices] - n_repeat = max(int(round( - (subplot.mesh.coordinates[dim][1] - subplot.mesh.coordinates[dim][0]) / - step_sizes_min[dim])), 1) + n_repeat = max( + int( + round( + (subplot.mesh.coordinates[dim][1] - subplot.mesh.coordinates[dim][0]) / step_sizes_min[dim] + ) + ), + 1, + ) if n_repeat > 1: subplot_data = np.repeat(subplot_data, n_repeat, axis=axis + 1) if masked: mask = np.repeat(mask, n_repeat, axis=axis + 1) for axis in range(3): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] # Add border points back again if needed if np.isclose(subplot.mesh.coordinates[dim][-1], global_max[dim]): temp_data_slices = [slice(s) for s in subplot_data.shape] temp_data_slices[axis + 1] = slice(None) - subplot_data = np.concatenate((subplot_data, temp_data[dim][tuple(temp_data_slices)]), axis=axis + 1) + subplot_data = np.concatenate( + (subplot_data, temp_data[dim][tuple(temp_data_slices)]), axis=axis + 1 + ) if masked: mask = np.concatenate((mask, temp_mask[dim][tuple(temp_data_slices)]), axis=axis + 1) @@ -233,14 +249,20 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b if masked: subplot_data = np.where(mask, subplot_data, fill) - grid[:, start_idx['x']: end_idx['x'], start_idx['y']: end_idx['y'], - start_idx['z']: end_idx['z']] = subplot_data.reshape( - (self.n_t, end_idx['x'] - start_idx['x'], end_idx['y'] - start_idx['y'], - end_idx['z'] - start_idx['z'])) + grid[:, start_idx["x"] : end_idx["x"], start_idx["y"] : end_idx["y"], start_idx["z"] : end_idx["z"]] = ( + subplot_data.reshape( + ( + self.n_t, + end_idx["x"] - start_idx["x"], + end_idx["y"] - start_idx["y"], + end_idx["z"] - start_idx["z"], + ) + ) + ) if return_coordinates: coordinates = dict() - for dim_index, dim in enumerate(('x', 'y', 'z')): + for dim_index, dim in enumerate(("x", "y", "z")): coordinates[dim] = np.linspace(coord_min[dim], coord_max[dim], grid.shape[dim_index + 1]) if return_coordinates: @@ -250,23 +272,21 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b @property def n_t(self) -> int: - """Get the number of timesteps for which data was output. - """ + """Get the number of timesteps for which data was output.""" return len(self.times) @property def subplots(self): - """Returns a list with one SubPlot3D object per mesh. - """ + """Returns a list with one SubPlot3D object per mesh.""" return list(self._subplots.values()) def __array__(self): - """Method that will be called by numpy when trying to convert the object to a numpy ndarray. - """ + """Method that will be called by numpy when trying to convert the object to a numpy ndarray.""" raise UserWarning( "Plot3Ds can not be converted to numpy arrays, but they support all typical numpy" " operations such as np.multiply. If a 'global' array containing all subplots is" - " required, please use the 'to_global' method and use the returned numpy-array explicitly.") + " required, please use the 'to_global' method and use the returned numpy-array explicitly." + ) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Method that will be called by numpy when using a ufunction with a Plot3D as input. @@ -277,7 +297,9 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): logging.warning( "The %s method has been used which is not explicitly implemented. Correctness of" " results is not guaranteed. If you require this feature to be implemented please" - " submit an issue on Github where you explain your use case.", method) + " submit an issue on Github where you explain your use case.", + method, + ) input_list = list(inputs) for i, inp in enumerate(inputs): if isinstance(inp, self.__class__): @@ -300,5 +322,6 @@ def __array_function__(self, func, types, args, kwargs): return NotImplemented return _HANDLED_FUNCTIONS[func](*args, **kwargs) + # __array_function__ implementations # ... diff --git a/fdsreader/pl3d/plot3D_collection.py b/fdsreader/pl3d/plot3D_collection.py index 95eb384a..3480918d 100644 --- a/fdsreader/pl3d/plot3D_collection.py +++ b/fdsreader/pl3d/plot3D_collection.py @@ -1,4 +1,5 @@ -from typing import Iterable, Union, List +from typing import Iterable, List, Union + import numpy as np from fdsreader.pl3d import Plot3D @@ -7,7 +8,7 @@ class Plot3DCollection(FDSDataCollection): """Collection of :class:`Plot3D` objects. Offers extensive functionality for filtering and - using plot3Ds as well as its subclasses such as :class:`SubPlot3D`. + using plot3Ds as well as its subclasses such as :class:`SubPlot3D`. """ def __init__(self, *plot3ds: Iterable[Plot3D]): @@ -22,12 +23,14 @@ def quantities(self) -> List[Quantity]: return [pl.quantity for pl in self._elements] def get_by_quantity(self, quantity: Union[str, Quantity]): - """Filters all plot3d data by a specific quantity. - """ - if type(quantity) == Quantity: + """Filters all plot3d data by a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return next(x for x in self._elements if - x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower()) + return next( + x + for x in self._elements + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def __repr__(self): - return "Plot3DCollection(" + super(Plot3DCollection, self).__repr__() + ")" + return "Plot3DCollection(" + super().__repr__() + ")" diff --git a/fdsreader/simulation.py b/fdsreader/simulation.py index e0692b24..660c4190 100644 --- a/fdsreader/simulation.py +++ b/fdsreader/simulation.py @@ -1,27 +1,26 @@ import glob import logging import os -import warnings -from typing import List, TextIO, Dict, AnyStr, Sequence, Tuple, Union import pickle +import warnings +from typing import AnyStr, Dict, List, Sequence, TextIO, Tuple, Union import numpy as np +import fdsreader.utils.fortran_data as fdtype +from fdsreader import __version__, settings +from fdsreader.bndf import Obstruction, ObstructionCollection, Patch, SubObstruction +from fdsreader.devc import Device, DeviceCollection +from fdsreader.evac import EvacCollection, Evacuation from fdsreader.fds_classes import Mesh, MeshCollection, Surface, Ventilation -from fdsreader.bndf import Obstruction, Patch, ObstructionCollection, SubObstruction -from fdsreader.geom import Geometry, GeomBoundary, GeometryCollection +from fdsreader.geom import GeomBoundary, Geometry, GeometryCollection from fdsreader.isof import Isosurface, IsosurfaceCollection from fdsreader.part import Particle, ParticleCollection -from fdsreader.evac import Evacuation, EvacCollection from fdsreader.pl3d import Plot3D, Plot3DCollection +from fdsreader.slcf import GeomSlice, GeomSliceCollection, Slice, SliceCollection from fdsreader.smoke3d import Smoke3D, Smoke3DCollection -from fdsreader.slcf import Slice, SliceCollection, GeomSliceCollection, GeomSlice -from fdsreader.devc import Device, DeviceCollection -from fdsreader.utils import Dimension, Quantity, Extent, log_error -from fdsreader.utils.data import create_hash, get_smv_file, Profile -import fdsreader.utils.fortran_data as fdtype -from fdsreader import settings -from fdsreader._version import __version__ +from fdsreader.utils import Dimension, Extent, Quantity, log_error +from fdsreader.utils.data import Profile, create_hash, get_smv_file class Simulation: @@ -60,7 +59,7 @@ def __new__(cls, path: str): smv_file_path = get_smv_file(path) root_path = os.path.dirname(smv_file_path) - with open(smv_file_path, 'r') as infile: + with open(smv_file_path) as infile: for line in infile: if line.strip() == "CHID": chid = infile.readline().strip() @@ -71,7 +70,7 @@ def __new__(cls, path: str): if not Simulation._loading and os.path.isfile(pickle_file_path): Simulation._loading = True try: - with open(pickle_file_path, 'rb') as f: + with open(pickle_file_path, "rb") as f: sim = pickle.load(f) except Exception as e: if settings.DEBUG: @@ -93,25 +92,27 @@ def __new__(cls, path: str): else: if os.path.isfile(pickle_file_path): os.remove(pickle_file_path) - return super(Simulation, cls).__new__(cls) + return super().__new__(cls) def __getnewargs__(self): - return self.smv_file_path, + return (self.smv_file_path,) def __repr__(self): - r = f"Simulation(chid={self.chid},\n" + \ - f" meshes={len(self.meshes)},\n" + \ - (f" obstructions={len(self.obstructions)},\n" if len(self.obstructions) > 0 else "") + \ - (f" geoms={len(self.geoms)},\n" if len(self.geoms) > 0 else "") + \ - (f" slices={len(self.slices)},\n" if len(self.slices) > 0 else "") + \ - (f" geomslices={len(self.geomslices)},\n" if len(self.geomslices) > 0 else "") + \ - (f" data_3d={len(self.data_3d)},\n" if len(self.data_3d) > 0 else "") + \ - (f" smoke_3d={len(self.smoke_3d)},\n" if len(self.smoke_3d) > 0 else "") + \ - (f" isosurfaces={len(self.isosurfaces)},\n" if len(self.isosurfaces) > 0 else "") + \ - (f" particles={len(self.particles)},\n" if len(self.particles) > 0 else "") + \ - (f" evacs={len(self.evacs)},\n" if len(self.evacs) > 0 else "") + \ - (f" devices={len(self.devices)},\n" if len(self.devices) > 0 else "") - return r[:-2] + ')' + r = ( + f"Simulation(chid={self.chid},\n" + + f" meshes={len(self.meshes)},\n" + + (f" obstructions={len(self.obstructions)},\n" if len(self.obstructions) > 0 else "") + + (f" geoms={len(self.geoms)},\n" if len(self.geoms) > 0 else "") + + (f" slices={len(self.slices)},\n" if len(self.slices) > 0 else "") + + (f" geomslices={len(self.geomslices)},\n" if len(self.geomslices) > 0 else "") + + (f" data_3d={len(self.data_3d)},\n" if len(self.data_3d) > 0 else "") + + (f" smoke_3d={len(self.smoke_3d)},\n" if len(self.smoke_3d) > 0 else "") + + (f" isosurfaces={len(self.isosurfaces)},\n" if len(self.isosurfaces) > 0 else "") + + (f" particles={len(self.particles)},\n" if len(self.particles) > 0 else "") + + (f" evacs={len(self.evacs)},\n" if len(self.evacs) > 0 else "") + + (f" devices={len(self.devices)},\n" if len(self.devices) > 0 else "") + ) + return r[:-2] + ")" def __init__(self, path: str): """ @@ -165,7 +166,7 @@ def __init__(self, path: str): self.ventilations: List[Ventilation] = list(self.ventilations.values()) for device_id, device in self._devices.items(): - if type(device) == list: + if isinstance(device, list): for devc in device: devc._data_callback = self._load_DEVC_data else: @@ -174,17 +175,19 @@ def __init__(self, path: str): # Combine the gathered temporary information into data collections self.geom_data = GeometryCollection(self._geom_data) self.slices = SliceCollection( - Slice(self.root_path, slice_data[0]["id"], slice_data[0]["cell_centered"], - slice_data[1:]) for slice_data in self._slices.values()) + Slice(self.root_path, slice_data[0]["id"], slice_data[0]["cell_centered"], slice_data[1:]) + for slice_data in self._slices.values() + ) self.geomslices = GeomSliceCollection( - GeomSlice(self.root_path, slice_data[0]["id"], - slice_data[0]["times"], slice_data[1:]) for slice_data in self._geomslices.values()) + GeomSlice(self.root_path, slice_data[0]["id"], slice_data[0]["times"], slice_data[1:]) + for slice_data in self._geomslices.values() + ) self.smoke_3d = Smoke3DCollection(self._smoke_3d.values()) self.isosurfaces = IsosurfaceCollection(self._isosurfaces.values()) self.devices = DeviceCollection(self._devices.values()) self.obstructions = ObstructionCollection(self._obstructions) # If no particles are simulated, initialize empty data container for consistency - if type(self._particles) == list: + if isinstance(self._particles, list): self.particles = ParticleCollection((), ()) else: self.particles = self._particles @@ -193,16 +196,28 @@ def __init__(self, path: str): if len(self._evacs) == 0: self.evacs = EvacCollection((), "", ()) self.meshes = MeshCollection(self._meshes) - del self._geom_data, self._geomslices, self._slices, self._obstructions, self._smoke_3d, self._isosurfaces, self._devices, self._particles, self._evacs, self._meshes, self._subobstructions + del ( + self._geom_data, + self._geomslices, + self._slices, + self._obstructions, + self._smoke_3d, + self._isosurfaces, + self._devices, + self._particles, + self._evacs, + self._meshes, + self._subobstructions, + ) if settings.ENABLE_CACHING: # Hash will be saved to simulation pickle file and compared to new hash when loading # the pickled simulation again in the next run of the program. self._hash = create_hash(self.smv_file_path) - pickle.dump(self, open(Simulation._get_pickle_filename(self.root_path, self.chid), 'wb'), protocol=4) + pickle.dump(self, open(Simulation._get_pickle_filename(self.root_path, self.chid), "wb"), protocol=4) def parse_smv_file(self): - with open(self.smv_file_path, 'r') as smv_file: + with open(self.smv_file_path) as smv_file: for line in smv_file: keyword = line.strip() if keyword == "VERSION" or keyword == "FDSVERSION": @@ -223,8 +238,9 @@ def parse_smv_file(self): self.steps = self._load_step_data(file_path) elif csv_type == "devc": self.devc_path = file_path - self._devices["Time"] = Device("Time", Quantity("TIME", "TIME", "s"), (.0, .0, .0), - (.0, .0, .0)) + self._devices["Time"] = Device( + "Time", Quantity("TIME", "TIME", "s"), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0) + ) elif keyword == "HRRPUVCUT": self.hrrpuv_cutoff = float(smv_file.readline().strip()) elif keyword == "TOFFSET": @@ -243,7 +259,7 @@ def parse_smv_file(self): elif keyword == "DEVICE": device_id, device = self._register_device(smv_file) if device_id in self._devices: - if type(self._devices[device_id]) == list: + if isinstance(self._devices[device_id], list): self._devices[device_id].append(device) else: self._devices[device_id] = [self._devices[device_id], device] @@ -276,28 +292,27 @@ def parse_smv_file(self): @classmethod def _get_pickle_filename(cls, root_path: str, chid: str) -> AnyStr: - """Get the filename used to save the pickled simulation. - """ + """Get the filename used to save the pickled simulation.""" return os.path.join(root_path, chid + ".pickle") def _load_mesh(self, smv_file: TextIO, line: str) -> Mesh: - """Load information for a single mesh from the smv file at current pointer position. - """ + """Load information for a single mesh from the smv file at current pointer position.""" mesh_id = "".join(line.split()[1:]) grid_numbers = smv_file.readline().strip().split() - grid_dimensions = {'x': int(grid_numbers[0]) + 1, 'y': int(grid_numbers[1]) + 1, - 'z': int(grid_numbers[2]) + 1} + grid_dimensions = {"x": int(grid_numbers[0]) + 1, "y": int(grid_numbers[1]) + 1, "z": int(grid_numbers[2]) + 1} smv_file.readline() # Blank line assert smv_file.readline().strip() == "PDIM" coordinates = dict() extents = smv_file.readline().split() - extents = {'x': (float(extents[0]), float(extents[1])), - 'y': (float(extents[2]), float(extents[3])), - 'z': (float(extents[4]), float(extents[5]))} + extents = { + "x": (float(extents[0]), float(extents[1])), + "y": (float(extents[2]), float(extents[3])), + "z": (float(extents[4]), float(extents[5])), + } - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): smv_file.readline() # Blank line assert smv_file.readline().strip()[:3] == "TRN" noc = int(smv_file.readline().strip()) @@ -325,7 +340,7 @@ def _load_mesh(self, smv_file: TextIO, line: str) -> Mesh: assert smv_file.readline().strip() == "OBST" self._load_obstructions(smv_file, mesh) - a = smv_file.readline() # Blank line + smv_file.readline() # Blank line assert smv_file.readline().strip() == "VENT" self._load_vents(smv_file, mesh) @@ -340,7 +355,7 @@ def _load_obstructions(self, smv_file: TextIO, mesh: Mesh): self._subobstructions[mesh.id] = list() for _ in range(n): - line = smv_file.readline().strip().split('!') + line = smv_file.readline().strip().split("!") line_floats = line[0].strip().split() ext = [float(line_floats[i]) for i in range(6)] # The ordinal is negative if the obstruction was created due to a hole, the negative @@ -361,18 +376,18 @@ def _load_obstructions(self, smv_file: TextIO, mesh: Mesh): if obst_data[0] == obst_id: # The obstruction that was already added to the temp_data has to receive an # updated ID as well, so the user can recognize it as special obstruction - obst_data[0] = obst_data[0] + '_from-hole-1' + obst_data[0] = obst_data[0] + "_from-hole-1" # Now set the next obst_id to '_from-hole-2' - obst_id += '_from-hole-2' + obst_id += "_from-hole-2" # ...For subsequent cases (i.e., when the third or fourth obstruction with the same id # is found), we need to catch the case differently for obst_data in reversed(temp_data): - if obst_data[0][:-1] == obst_id + '_from-hole-': + if obst_data[0][:-1] == obst_id + "_from-hole-": # Find the last obstruction with the same id and set the number of the current # one accordingly - obst_id += '_from-hole-' + str(int(obst_data[0][-1]) + 1) + obst_id += "_from-hole-" + str(int(obst_data[0][-1]) + 1) temp_data.append([obst_id, Extent(*ext), side_surfaces, texture_origin]) @@ -381,8 +396,13 @@ def _load_obstructions(self, smv_file: TextIO, mesh: Mesh): line = smv_file.readline().strip().split() bound_indices = ( - int(float(line[0])), int(float(line[1])), int(float(line[2])), - int(float(line[3])), int(float(line[4])), int(float(line[5]))) + int(float(line[0])), + int(float(line[1])), + int(float(line[2])), + int(float(line[3])), + int(float(line[4])), + int(float(line[5])), + ) color_index = int(line[6]) block_type = int(line[7]) rgba = tuple(float(line[i]) for i in range(8, 12)) if color_index == -3 else () @@ -425,16 +445,14 @@ def _load_geoms(self, smv_file: TextIO, line: str): rgb_line = line[1].split() texture_mapping = texture_line[0] - texture_origin = ( - float(texture_line[1]), float(texture_line[2]), float(texture_line[3])) + texture_origin = (float(texture_line[1]), float(texture_line[2]), float(texture_line[3])) is_terrain = bool(texture_line[4]) rgb = (int(rgb_line[0]), int(rgb_line[1]), int(rgb_line[2])) - if '%' in line[0]: + if "%" in line[0]: surface_id = line[0].split("%")[-1] surface = next((s for s in self.surfaces if s.id() == surface_id), None) - geom = Geometry(file_path, texture_mapping, texture_origin, is_terrain, rgb, - surface=surface) + geom = Geometry(file_path, texture_mapping, texture_origin, is_terrain, rgb, surface=surface) else: geom = Geometry(file_path, texture_mapping, texture_origin, is_terrain, rgb) self.geoms.append(geom) @@ -448,8 +466,7 @@ def _load_vents(self, smv_file: TextIO, mesh: Mesh): def read_common_info(): line = smv_file.readline().strip().split() - return line, [float(line[i]) for i in range(6)], int(line[6]) - 1, self.surfaces[ - int(line[7])] + return line, [float(line[i]) for i in range(6)], int(line[6]) - 1, self.surfaces[int(line[7])] def read_common_info2(): line = smv_file.readline().strip().split() @@ -479,9 +496,9 @@ def read_common_info2(): extent, vent_index, surface = temp_data[v] bound_indices, color_index, draw_type, rgba = read_common_info2() if vent_index not in self.ventilations: - self.ventilations[vent_index] = Ventilation(surface, bound_indices, color_index, - draw_type, rgba=rgba, - texture_origin=texture_origin) + self.ventilations[vent_index] = Ventilation( + surface, bound_indices, color_index, draw_type, rgba=rgba, texture_origin=texture_origin + ) self.ventilations[vent_index]._add_subventilation(mesh, extent) smv_file.readline() @@ -493,24 +510,27 @@ def read_common_info2(): line, extent, vent_index, surface = read_common_info() circular_vent_origin = (float(line[12]), float(line[13]), float(line[14])) radius = float(line[15]) - temp_data.append( - (extent, vent_index, surface, texture_origin, circular_vent_origin, radius)) + temp_data.append((extent, vent_index, surface, texture_origin, circular_vent_origin, radius)) for v in range(n): extent, vent_index, surface, texture_origin, circular_vent_origin, radius = temp_data[v] bound_indices, color_index, draw_type, rgba = read_common_info2() if vent_index not in self.ventilations: - self.ventilations[vent_index] = Ventilation(surface, bound_indices, - color_index, draw_type, rgba=rgba, - texture_origin=texture_origin, - circular_vent_origin=circular_vent_origin, - radius=radius) + self.ventilations[vent_index] = Ventilation( + surface, + bound_indices, + color_index, + draw_type, + rgba=rgba, + texture_origin=texture_origin, + circular_vent_origin=circular_vent_origin, + radius=radius, + ) self.ventilations[vent_index]._add_subventilation(mesh, extent) @log_error("surface") def _load_surface(self, smv_file: TextIO) -> Surface: - """Load the information for a single surface from the smv file at current pointer position. - """ + """Load the information for a single surface from the smv file at current pointer position.""" surface_id = smv_file.readline().strip() @@ -527,27 +547,35 @@ def _load_surface(self, smv_file: TextIO) -> Surface: texture_map = smv_file.readline().strip() texture_map = None if texture_map == "null" else os.path.join(self.root_path, texture_map) - return Surface(surface_id, tmpm, material_emissivity, surface_type, texture_width, - texture_height, texture_map, rgb, transparency) + return Surface( + surface_id, + tmpm, + material_emissivity, + surface_type, + texture_width, + texture_height, + texture_map, + rgb, + transparency, + ) @log_error("slcf") def _load_slice(self, smv_file: TextIO, line: str): - """Loads the slice at current pointer position. - """ + """Loads the slice at current pointer position.""" if "SLCC" in line: cell_centered = True else: cell_centered = False - slice_index = int(line.split('!')[1].strip().split()[0]) + slice_index = int(line.split("!")[1].strip().split()[0]) - slice_id = line.split('%')[1].split('&')[0].strip() if '%' in line else "" + slice_id = line.split("%")[1].split("&")[0].strip() if "%" in line else "" - mesh_index = int(line.split('&')[0].strip().split()[1]) - 1 + mesh_index = int(line.split("&")[0].strip().split()[1]) - 1 mesh = self._meshes[mesh_index] # Read in index ranges for x, y and z - bound_indices = [int(i.strip()) for i in line.split('&')[1].split('!')[0].strip().split()] + bound_indices = [int(i.strip()) for i in line.split("&")[1].split("!")[0].strip().split()] extent, dimension = self._indices_to_extent(bound_indices, mesh) filename = smv_file.readline().strip() @@ -556,25 +584,31 @@ def _load_slice(self, smv_file: TextIO, line: str): unit = smv_file.readline().strip() if slice_index not in self._slices: - self._slices[slice_index] = [ - {"cell_centered": cell_centered, "id": slice_id}] + self._slices[slice_index] = [{"cell_centered": cell_centered, "id": slice_id}] self._slices[slice_index].append( - {"dimension": dimension, "extent": extent, "mesh": mesh, "filename": filename, - "quantity": quantity, "short_name": short_name, "unit": unit}) + { + "dimension": dimension, + "extent": extent, + "mesh": mesh, + "filename": filename, + "quantity": quantity, + "short_name": short_name, + "unit": unit, + } + ) @log_error("slcf") def _load_geomslice(self, smv_file: TextIO, line: str): - """Loads the geomslice at current pointer position. - """ - slice_index = int(line.split('!')[1].strip().split()[0]) + """Loads the geomslice at current pointer position.""" + slice_index = int(line.split("!")[1].strip().split()[0]) - slice_id = "".join(line.split('%')[1].split('&')).strip() if '%' in line else "" + slice_id = "".join(line.split("%")[1].split("&")).strip() if "%" in line else "" - mesh_index = int(line.split('&')[0].strip().split()[1]) - 1 + mesh_index = int(line.split("&")[0].strip().split()[1]) - 1 mesh = self._meshes[mesh_index] # Read in index ranges for x, y and z - bound_indices = [int(i.strip()) for i in line.split('&')[1].split('!')[0].strip().split()] + bound_indices = [int(i.strip()) for i in line.split("&")[1].split("!")[0].strip().split()] extent, _ = self._indices_to_extent(bound_indices, mesh) filename = smv_file.readline().strip() @@ -587,7 +621,7 @@ def _load_geomslice(self, smv_file: TextIO, line: str): if os.path.exists(file_path + ".bnd"): times = list() - with open(file_path + ".bnd", 'r') as bnd_file: + with open(file_path + ".bnd") as bnd_file: for line in bnd_file: times.append(float(line.split()[0])) times = np.array(times) @@ -595,16 +629,22 @@ def _load_geomslice(self, smv_file: TextIO, line: str): times = None if slice_index not in self._slices: - self._geomslices[slice_index] = [ - {"times": times, "id": slice_id}] + self._geomslices[slice_index] = [{"times": times, "id": slice_id}] self._geomslices[slice_index].append( - {"extent": extent, "mesh": mesh, "filename": filename, "geomfilename": geom_filename, - "quantity": quantity, "short_name": short_name, "unit": unit}) + { + "extent": extent, + "mesh": mesh, + "filename": filename, + "geomfilename": geom_filename, + "quantity": quantity, + "short_name": short_name, + "unit": unit, + } + ) @log_error("bndf") def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): - """Loads the boundary data at current pointer position. - """ + """Loads the boundary data at current pointer position.""" line = line.split() mesh_index = int(line[1]) - 1 mesh = self._meshes[mesh_index] @@ -614,7 +654,7 @@ def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): short_name = smv_file.readline().strip() unit = smv_file.readline().strip() - bid = int(filename.split('_')[-1][:-3]) - 1 + bid = int(filename.split("_")[-1][:-3]) - 1 file_path = os.path.join(self.root_path, filename) @@ -624,14 +664,14 @@ def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): lower_bounds = np.array([np.float32(+1e33)], dtype=np.float32) upper_bounds = np.array([np.float32(-1e33)], dtype=np.float32) - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: # Offset of the binary file to the end of the file header. - initial_offset = 3 * fdtype.new((('c', 30),)).itemsize + initial_offset = 3 * fdtype.new((("c", 30),)).itemsize infile.seek(initial_offset) n_patches = fdtype.read(infile, fdtype.INT, 1)[0][0][0] - dtype_patches = fdtype.new((('i', 9),)) + dtype_patches = fdtype.new((("i", 9),)) patch_infos = fdtype.read(infile, dtype_patches, n_patches) initial_offset += fdtype.INT.itemsize + dtype_patches.itemsize * n_patches patch_offset = fdtype.FLOAT.itemsize @@ -641,9 +681,9 @@ def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): for patch_info in patch_infos: patch_info = patch_info[0] extent, dimension = self._indices_to_extent(patch_info[:6], mesh) - patches_data_bytes += fdtype.new((('f', str(dimension.shape(cell_centered=False))),)).itemsize + patches_data_bytes += fdtype.new((("f", str(dimension.shape(cell_centered=False))),)).itemsize - # Time info + # Time info time_bytes = fdtype.FLOAT.itemsize n_t = (os.stat(file_path).st_size - initial_offset) // (time_bytes + patches_data_bytes) @@ -662,8 +702,9 @@ def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): orientation = patch_info[6] obst_index = patch_info[7] - 1 - p = Patch(file_path, dimension, extent, orientation, cell_centered, - patch_offset, initial_offset, n_t, mesh) + p = Patch( + file_path, dimension, extent, orientation, cell_centered, patch_offset, initial_offset, n_t, mesh + ) # "Obstacles" with index -1 give the extent of the (whole) mesh faces and refer to # "closed" mesh faces, therefore that data will be added to the corresponding mesh instead @@ -676,21 +717,22 @@ def _load_boundary_data(self, smv_file: TextIO, line: str, cell_centered: bool): mesh_patches[mesh.id] = list() mesh_patches[mesh.id].append(p) - patch_offset += fdtype.new((('f', str(p.dimension.shape(cell_centered=False))),)).itemsize + patch_offset += fdtype.new((("f", str(p.dimension.shape(cell_centered=False))),)).itemsize for obst_index, p in patches.items(): for patch in p: patch._post_init(patch_offset) - self._subobstructions[mesh.id][obst_index]._add_patches(bid, cell_centered, quantity, - short_name, unit, p, times, - lower_bounds, upper_bounds) + self._subobstructions[mesh.id][obst_index]._add_patches( + bid, cell_centered, quantity, short_name, unit, p, times, lower_bounds, upper_bounds + ) for p in mesh_patches.values(): for patch in p: patch._post_init(patch_offset) - patch.mesh._add_patches(bid, cell_centered, quantity, short_name, unit, p, times, - lower_bounds, upper_bounds) + patch.mesh._add_patches( + bid, cell_centered, quantity, short_name, unit, p, times, lower_bounds, upper_bounds + ) @log_error("geom") def _load_boundary_data_geom(self, smv_file: TextIO, line: str): @@ -705,7 +747,7 @@ def _load_boundary_data_geom(self, smv_file: TextIO, line: str): short_name = smv_file.readline().strip() unit = smv_file.readline().strip() - bid = int(filename_be.split('_')[-1][:-3]) - 1 + bid = int(filename_be.split("_")[-1][:-3]) - 1 file_path_be = os.path.join(self.root_path, filename_be) file_path_gbf = os.path.join(self.root_path, filename_gbf) @@ -713,7 +755,7 @@ def _load_boundary_data_geom(self, smv_file: TextIO, line: str): times = list() lower_bounds = list() upper_bounds = list() - with open(file_path_be + ".bnd", 'r') as bnd_file: + with open(file_path_be + ".bnd") as bnd_file: for line in bnd_file: splits = line.split() times.append(float(splits[0])) @@ -726,13 +768,11 @@ def _load_boundary_data_geom(self, smv_file: TextIO, line: str): if bid >= len(self._geom_data): self._geom_data.append(GeomBoundary(Quantity(quantity, short_name, unit), times, n_t)) - self._geom_data[bid]._add_data(mesh_index, file_path_be, file_path_gbf, lower_bounds, - upper_bounds) + self._geom_data[bid]._add_data(mesh_index, file_path_be, file_path_gbf, lower_bounds, upper_bounds) @log_error("pl3d") def _load_plot_3d(self, smv_file: TextIO, line: str): - """Loads the pl3d at current pointer position. - """ + """Loads the pl3d at current pointer position.""" line = line.strip().split() time = float(line[1]) @@ -745,12 +785,13 @@ def _load_plot_3d(self, smv_file: TextIO, line: str): short_name = smv_file.readline().strip() unit = smv_file.readline().strip() - self.data_3d[i]._add_subplot(filename, time, Quantity(quantity, short_name, unit), i, self._meshes[mesh_index]) + self.data_3d[i]._add_subplot( + filename, time, Quantity(quantity, short_name, unit), i, self._meshes[mesh_index] + ) @log_error("smoke3d") def _load_smoke_3d(self, smv_file: TextIO, line: str): - """Loads the smoke3d at current pointer position. - """ + """Loads the smoke3d at current pointer position.""" line = line.strip().split() mesh_index = int(line[1]) - 1 @@ -763,7 +804,7 @@ def _load_smoke_3d(self, smv_file: TextIO, line: str): times = list() upper_bounds = list() - with open(os.path.join(self.root_path, filename + ".sz"), 'r') as sizefile: + with open(os.path.join(self.root_path, filename + ".sz")) as sizefile: # skip version line sizefile.readline() for line in sizefile: @@ -781,13 +822,12 @@ def _load_smoke_3d(self, smv_file: TextIO, line: str): @log_error("isof") def _load_isosurface(self, smv_file: TextIO, line: str): - """Loads the isosurface at current pointer position. - """ - double_quantity = line[0] == 'T' + """Loads the isosurface at current pointer position.""" + double_quantity = line[0] == "T" mesh_index = int(line.strip().split()[1]) - 1 iso_filename = smv_file.readline().strip() - iso_id = int(iso_filename.split('_')[-1][:-4]) + iso_id = int(iso_filename.split("_")[-1][:-4]) iso_file_path = os.path.join(self.root_path, iso_filename) if double_quantity: @@ -801,22 +841,30 @@ def _load_isosurface(self, smv_file: TextIO, line: str): v_unit = smv_file.readline().strip() if iso_id not in self._isosurfaces: - with open(iso_file_path, 'rb') as infile: + with open(iso_file_path, "rb") as infile: nlevels = fdtype.read(infile, fdtype.INT, 3)[2][0][0] - dtype_header_levels = fdtype.new((('f', nlevels),)) + dtype_header_levels = fdtype.new((("f", nlevels),)) levels = fdtype.read(infile, dtype_header_levels, 1)[0] if double_quantity: if iso_id not in self._isosurfaces: - self._isosurfaces[iso_id] = Isosurface(iso_id, double_quantity, quantity, short_name, - unit, levels, v_quantity=v_quantity, - v_short_name=v_short_name, v_unit=v_unit) - self._isosurfaces[iso_id]._add_subsurface(self._meshes[mesh_index], iso_file_path, - viso_file_path=viso_file_path) + self._isosurfaces[iso_id] = Isosurface( + iso_id, + double_quantity, + quantity, + short_name, + unit, + levels, + v_quantity=v_quantity, + v_short_name=v_short_name, + v_unit=v_unit, + ) + self._isosurfaces[iso_id]._add_subsurface( + self._meshes[mesh_index], iso_file_path, viso_file_path=viso_file_path + ) else: if iso_id not in self._isosurfaces: - self._isosurfaces[iso_id] = Isosurface(iso_id, double_quantity, quantity, short_name, - unit, levels) + self._isosurfaces[iso_id] = Isosurface(iso_id, double_quantity, quantity, short_name, unit, levels) self._isosurfaces[iso_id]._add_subsurface(self._meshes[mesh_index], iso_file_path) @log_error("part") @@ -833,10 +881,14 @@ def _register_particle(self, smv_file: TextIO) -> Particle: quantities.append(Quantity(quantity, short_name, unit)) return Particle(particle_class, quantities, color) - def _load_prt5_meta(self, prts: Union[List[Particle], ParticleCollection, List[Evacuation], EvacCollection], - file_path: str, mesh: Mesh) -> List[float]: - is_evac = type(prts[0]) == Evacuation - with open(file_path, 'r') as bnd_file: + def _load_prt5_meta( + self, + prts: Union[List[Particle], ParticleCollection, List[Evacuation], EvacCollection], + file_path: str, + mesh: Mesh, + ) -> List[float]: + is_evac = isinstance(prts[0], Evacuation) + with open(file_path) as bnd_file: line = bnd_file.readline().strip().split() n_classes = int(line[1]) times = list() @@ -875,8 +927,8 @@ def _load_particle_data(self, smv_file: TextIO, line: str): mesh_index = int(line.split()[1].strip()) - 1 mesh = self._meshes[mesh_index] - times = self._load_prt5_meta(self._particles, file_path + '.bnd', mesh) - if type(self._particles) == list: + times = self._load_prt5_meta(self._particles, file_path + ".bnd", mesh) + if isinstance(self._particles, list): self._particles = ParticleCollection(times, self._particles) self._particles._file_paths[mesh.id] = file_path @@ -906,8 +958,8 @@ def _load_evac_data(self, smv_file: TextIO, line: str): mesh_index, z_offset = line.split()[1:] mesh = self._meshes[int(mesh_index) - 1] - times = self._load_prt5_meta(self._evacs, file_path + '.bnd', mesh)[1:] # First timestep is weird somehow - if type(self._evacs) == list: + times = self._load_prt5_meta(self._evacs, file_path + ".bnd", mesh)[1:] # First timestep is weird somehow + if isinstance(self._evacs, list): self.evacs = EvacCollection(self._evacs, os.path.join(self.root_path, self.chid + "_evac"), times) self.evacs.z_offsets[mesh.id] = float(z_offset) @@ -920,41 +972,41 @@ def _load_evac_data(self, smv_file: TextIO, line: str): @log_error("prof") def _load_profiles(self): for f in glob.glob(str(os.path.join(self.root_path, self.chid)) + "_prof*"): - with open(f, 'r') as infile: + with open(f) as infile: profile_id = infile.readline() infile.readline() # Skip header - data: np.ndarray = np.genfromtxt(infile, delimiter=',', dtype=np.float32, autostrip=True).T + data: np.ndarray = np.genfromtxt(infile, delimiter=",", dtype=np.float32, autostrip=True).T times = data[0] npoints = data[1].astype(int) depths = np.empty((data.shape[1],), dtype=object) values = np.empty((data.shape[1],), dtype=object) for i, n in enumerate(npoints): - depths[i] = data[2: 2 + n, i] - values[i] = data[2 + n:, i] + depths[i] = data[2 : 2 + n, i] + values[i] = data[2 + n :, i] self.profiles[profile_id] = Profile(profile_id, times, npoints, depths, values) @log_error("devc") def _register_device(self, smv_file: TextIO) -> Tuple[str, Device]: - line = smv_file.readline().strip().split('%') + line = smv_file.readline().strip().split("%") device_id = line[0].strip() quantity = None if len(line) > 1: quantity_name = line[1].strip() quantity = Quantity(quantity_name, quantity_name, "") - line = smv_file.readline().strip().split('#')[0].split() + line = smv_file.readline().strip().split("#")[0].split() position = (float(line[0]), float(line[1]), float(line[2])) orientation = (float(line[3]), float(line[4]), float(line[5])) return device_id, Device(device_id, quantity, position, orientation) def _load_DEVC_data(self): - with open(self.devc_path, 'r') as infile: - units = infile.readline().split(',') - names = [name.replace('"', '').replace('\n', '').strip() for name in infile.readline().split(',"')] - values = np.genfromtxt(infile, delimiter=',', dtype=np.float32, autostrip=True) + with open(self.devc_path) as infile: + units = infile.readline().split(",") + names = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(',"')] + values = np.genfromtxt(infile, delimiter=",", dtype=np.float32, autostrip=True) for k in range(len(names)): - if type(self.devices[names[k]]) == list: + if isinstance(self.devices[names[k]], list): for devc in self.devices[names[k]]: if not hasattr(devc, "_data"): # Find the first device in the list that does not yet have any data associated with it @@ -970,10 +1022,10 @@ def _load_DEVC_data(self): line_path = self.devc_path.replace("devc", "line") if os.path.exists(line_path): - with open(line_path, 'r') as infile: + with open(line_path) as infile: units = infile.readline() - names = [name.replace('"', '').replace('\n', '').strip() for name in infile.readline().split(',')] - data = np.genfromtxt(infile, delimiter=',', dtype=np.float32, autostrip=True) + names = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")] + data = np.genfromtxt(infile, delimiter=",", dtype=np.float32, autostrip=True) for k, key in enumerate(names): if key in self.devices: devc = self.devices[key] @@ -985,21 +1037,22 @@ def _load_DEVC_data(self): @log_error("csv") def _load_HRR_data(self, file_path: str) -> Dict[str, np.ndarray]: - with open(file_path, 'r') as infile: + with open(file_path) as infile: infile.readline() - keys = [name.replace('"', '').replace('\n', '').strip() for name in infile.readline().split(',')] + keys = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")] - values = np.loadtxt(file_path, delimiter=',', ndmin=2, skiprows=2) + values = np.loadtxt(file_path, delimiter=",", ndmin=2, skiprows=2) return self._transform_csv_data(keys, values) @log_error("csv") def _load_step_data(self, file_path: str) -> Dict[str, np.ndarray]: - with open(file_path, 'r') as infile: + 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) - float_values = np.loadtxt(file_path, delimiter=',', usecols=range(2, len(keys) + 2), ndmin=2, skiprows=2) + 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 + ) + 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 return data @@ -1008,9 +1061,9 @@ def _load_step_data(self, file_path: str) -> Dict[str, np.ndarray]: def _load_CPU_data(self) -> Dict[str, np.ndarray]: file_path = os.path.join(self.root_path, self.chid + "_cpu.csv") if os.path.exists(file_path): - with open(file_path, 'r') as infile: - keys = [name.replace('"', '').replace('\n', '').strip() for name in infile.readline().split(',')] - values = np.loadtxt(file_path, delimiter=',', ndmin=2, skiprows=1) + with open(file_path) as infile: + keys = [name.replace('"', "").replace("\n", "").strip() for name in infile.readline().split(",")] + values = np.loadtxt(file_path, delimiter=",", ndmin=2, skiprows=1) else: return dict() data = self._transform_csv_data(keys, values) @@ -1025,16 +1078,20 @@ def _transform_csv_data(self, keys, values): arr[i] = values[i][k] return data - def _indices_to_extent(self, indices: Sequence[Union[int, str]], mesh: Mesh) -> Tuple[ - Extent, Dimension]: + def _indices_to_extent(self, indices: Sequence[Union[int, str]], mesh: Mesh) -> Tuple[Extent, Dimension]: co = mesh.coordinates indices = tuple(int(index) for index in indices) x_min, x_max, y_min, y_max, z_min, z_max = indices co_x_min, co_x_max, co_y_min, co_y_max, co_z_min, co_z_max = ( - co['x'][x_min], co['x'][x_max], co['y'][y_min], - co['y'][y_max], co['z'][z_min], co['z'][z_max]) + co["x"][x_min], + co["x"][x_max], + co["y"][y_min], + co["y"][y_max], + co["z"][z_min], + co["z"][z_max], + ) dimension = Dimension(indices[1] - indices[0] + 1, indices[3] - indices[2] + 1, indices[5] - indices[4] + 1) extent = Extent(co_x_min, co_x_max, co_y_min, co_y_max, co_z_min, co_z_max) diff --git a/fdsreader/slcf/__init__.py b/fdsreader/slcf/__init__.py index b22b7a8f..eff6379b 100644 --- a/fdsreader/slcf/__init__.py +++ b/fdsreader/slcf/__init__.py @@ -1,7 +1,4 @@ -from .slice import Slice - -from .slice_collection import SliceCollection - -from .geomslice import GeomSlice - -from .geomslice_collection import GeomSliceCollection +from .geomslice import GeomSlice as GeomSlice +from .geomslice_collection import GeomSliceCollection as GeomSliceCollection +from .slice import Slice as Slice +from .slice_collection import SliceCollection as SliceCollection diff --git a/fdsreader/slcf/geomslice.py b/fdsreader/slcf/geomslice.py index adde0d81..38244c20 100644 --- a/fdsreader/slcf/geomslice.py +++ b/fdsreader/slcf/geomslice.py @@ -1,23 +1,22 @@ +import logging import math import os from copy import deepcopy +from typing import Collection, Dict, List, Union import numpy as np -import logging -from typing import Dict, Collection, Union, List from typing_extensions import Literal -from fdsreader.fds_classes import Mesh -from fdsreader.utils import Quantity, Extent -from fdsreader import settings import fdsreader.utils.fortran_data as fdtype +from fdsreader import settings +from fdsreader.fds_classes import Mesh +from fdsreader.utils import Extent, Quantity _HANDLED_FUNCTIONS = {} def implements(np_function): - """Decorator to register an __array_function__ implementation for GeomSlices. - """ + """Decorator to register an __array_function__ implementation for GeomSlices.""" def decorator(func): _HANDLED_FUNCTIONS[np_function] = func @@ -45,50 +44,49 @@ def __init__(self, parent_slice, filename: str, geom_filename: str, extent: Exte @property def orientation(self) -> Literal[1, 2, 3]: - """Orientation [1,2,3] of the geomslice in case it is 2D, 0 otherwise. - """ + """Orientation [1,2,3] of the geomslice in case it is 2D, 0 otherwise.""" return self._parent_slice.orientation @property def times(self): return self._parent_slice.times - @property def n_t(self) -> int: - """Get the number of timesteps for which data was output. - """ + """Get the number of timesteps for which data was output.""" return self._parent_slice.n_t def _load_geom_data(self): - with open(self.geom_file_path, 'rb') as infile: - dtype_meta = fdtype.new((('i', 3),)) + with open(self.geom_file_path, "rb") as infile: + dtype_meta = fdtype.new((("i", 3),)) infile.seek(2 * fdtype.INT.itemsize + dtype_meta.itemsize + fdtype.FLOAT.itemsize) self.n_verts, self.n_faces, n_vols = fdtype.read(infile, dtype_meta, 1)[0][0] if self.n_verts > 0 and self.n_faces > 0: - dtype_verts = fdtype.new((('f', 3 * self.n_verts),)) - dtype_faces = fdtype.new((('i', 3 * self.n_faces),)) + dtype_verts = fdtype.new((("f", 3 * self.n_verts),)) + dtype_faces = fdtype.new((("i", 3 * self.n_faces),)) # dtype_locations = fdtype.new((('i', self.n_faces),)) # dtype_zero_floats = fdtype.new((('f', 3 * self.n_faces * 2),)) - self._vertices = fdtype.read(infile, dtype_verts, 1)[0][0].reshape((self.n_verts, 3), order='F') - self._faces = fdtype.read(infile, dtype_faces, 1)[0][0].reshape((self.n_faces, 3), order='F') + self._vertices = fdtype.read(infile, dtype_verts, 1)[0][0].reshape((self.n_verts, 3), order="F") + self._faces = fdtype.read(infile, dtype_faces, 1)[0][0].reshape((self.n_faces, 3), order="F") else: self._vertices = np.array([]) self._faces = np.array([]) def _load_data(self): - with open(self.file_path, 'rb') as infile: + with open(self.file_path, "rb") as infile: infile.seek(2 * fdtype.INT.itemsize) if self.n_verts > 0 and self.n_faces > 0: - dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((('i', 4),)), fdtype.new((('f', self.n_faces),))) + dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((("i", 4),)), fdtype.new((("f", self.n_faces),))) else: - dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((('i', 4),))) + dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((("i", 4),))) load_times = self.n_t == -1 if load_times: - self._parent_slice.n_t = (os.stat(self.file_path).st_size - 2 * fdtype.INT.itemsize) // dtype_data.itemsize + self._parent_slice.n_t = ( + os.stat(self.file_path).st_size - 2 * fdtype.INT.itemsize + ) // dtype_data.itemsize self._parent_slice.times = np.empty(self.n_t) self._data = np.empty((self.n_t, self.n_faces), dtype=np.float32) @@ -101,8 +99,7 @@ def _load_data(self): @property def data(self) -> np.ndarray: - """Method to lazy load the geomslice's data. - """ + """Method to lazy load the geomslice's data.""" if not hasattr(self, "_data"): _ = self.vertices # Make sure geom data has been loaded already self._load_data() @@ -110,16 +107,14 @@ def data(self) -> np.ndarray: @property def vertices(self) -> np.ndarray: - """Method to lazy load the geomslice's data. - """ + """Method to lazy load the geomslice's data.""" if not hasattr(self, "_vertices"): self._load_geom_data() return self._vertices @property def faces(self) -> np.ndarray: - """Method to lazy load the geomslice's data. - """ + """Method to lazy load the geomslice's data.""" if not hasattr(self, "_faces"): self._load_geom_data() return self._faces @@ -148,8 +143,7 @@ def vmax(self): return np.max(self.data) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data # del self._vector_data @@ -195,7 +189,9 @@ def __init__(self, root_path: str, geomslice_id: str, times: np.ndarray, multime for mesh_data in multimesh_data: if mesh_data["mesh"].id not in self._subgeomslices: self.quantity = Quantity(mesh_data["quantity"], mesh_data["short_name"], mesh_data["unit"]) - self._subgeomslices[mesh_data["mesh"].id] = SubGeomSlice(self, mesh_data["filename"], mesh_data["geomfilename"], mesh_data["extent"], mesh_data["mesh"]) + self._subgeomslices[mesh_data["mesh"].id] = SubGeomSlice( + self, mesh_data["filename"], mesh_data["geomfilename"], mesh_data["extent"], mesh_data["mesh"] + ) # if "-VELOCITY" in mesh_data["quantity"]: # vector_temp[mesh_data["mesh"]][mesh_data["quantity"]] = mesh_data["filename"] @@ -222,12 +218,14 @@ def __init__(self, root_path: str, geomslice_id: str, times: np.ndarray, multime # del self._subgeomslices[mesh] # vals = self._subgeomslices.values() - self.extent = Extent(min(vals, key=lambda e: e.extent.x_start).extent.x_start, - max(vals, key=lambda e: e.extent.x_end).extent.x_end, - min(vals, key=lambda e: e.extent.y_start).extent.y_start, - max(vals, key=lambda e: e.extent.y_end).extent.y_end, - min(vals, key=lambda e: e.extent.z_start).extent.z_start, - max(vals, key=lambda e: e.extent.z_end).extent.z_end) + self.extent = Extent( + min(vals, key=lambda e: e.extent.x_start).extent.x_start, + max(vals, key=lambda e: e.extent.x_end).extent.x_end, + min(vals, key=lambda e: e.extent.y_start).extent.y_start, + max(vals, key=lambda e: e.extent.y_end).extent.y_end, + min(vals, key=lambda e: e.extent.z_start).extent.z_start, + max(vals, key=lambda e: e.extent.z_end).extent.z_end, + ) if self.extent.x_start == self.extent.x_end: self.orientation = 1 @@ -245,17 +243,17 @@ def __init__(self, root_path: str, geomslice_id: str, times: np.ndarray, multime def get_subgeomslice(self, key: Union[int, str, Mesh]) -> SubGeomSlice: """Returns the :class:`SubGeomSlice` that cuts through the given mesh. When an int is - provided the nth SubGeomSlice will be returned. + provided the nth SubGeomSlice will be returned. """ return self[key] def __getitem__(self, key: Union[int, str, Mesh]) -> SubGeomSlice: """Returns the :class:`SubGeomSlice` that cuts through the given mesh. When an int is - provided the nth SubGeomSlice will be returned. + provided the nth SubGeomSlice will be returned. """ - if type(key) == int: + if isinstance(key, int): return tuple(self._subgeomslices.values())[key] - if type(key) == str: + if isinstance(key, str): return self._subgeomslices[key] return self._subgeomslices[key.id] @@ -264,8 +262,7 @@ def __len__(self): @property def subgeomslices(self) -> List[SubGeomSlice]: - """Get a list with all SubGeomSlices. - """ + """Get a list with all SubGeomSlices.""" return list(self._subgeomslices.values()) # @property @@ -283,11 +280,11 @@ def subgeomslices(self) -> List[SubGeomSlice]: # return 'x', 'y' def get_nearest_timestep(self, time: float) -> int: - """Calculates the nearest timestep for which data has been output for this geomslice. - """ + """Calculates the nearest timestep for which data has been output for this geomslice.""" idx = np.searchsorted(self.times, time, side="left") - if time > 0 and (idx == len(self.times) or math.fabs( - time - self.times[idx - 1]) < math.fabs(time - self.times[idx])): + if time > 0 and ( + idx == len(self.times) or math.fabs(time - self.times[idx - 1]) < math.fabs(time - self.times[idx]) + ): return idx - 1 else: return idx @@ -305,8 +302,7 @@ def get_nearest_timestep(self, time: float) -> int: @property def meshes(self) -> List[Mesh]: - """Returns a list of all meshes this geomslice cuts through. - """ + """Returns a list of all meshes this geomslice cuts through.""" return [subgeomslc.mesh for subgeomslc in self._subgeomslices] # @property @@ -337,8 +333,7 @@ def vmax(self): return np.max(self) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for subgeomslice in self._subgeomslices.values(): subgeomslice.clear_cache() @@ -350,7 +345,7 @@ def vertices(self): counter = 0 for subgeomslice in self._subgeomslices.values(): size = subgeomslice.vertices.shape[0] - vertices[:, counter:counter+size] = subgeomslice.vertices + vertices[:, counter : counter + size] = subgeomslice.vertices counter += size return vertices @@ -363,7 +358,7 @@ def faces(self): counter = 0 for subgeomslice in self._subgeomslices.values(): size = subgeomslice.faces.shape[0] - faces[:, counter:counter + size] = subgeomslice.faces + faces[:, counter : counter + size] = subgeomslice.faces counter += size return faces @@ -376,7 +371,7 @@ def data(self): counter = 0 for subgeomslice in self._subgeomslices.values(): size = subgeomslice.data.shape[1] - data[:, counter:counter + size] = subgeomslice.data + data[:, counter : counter + size] = subgeomslice.data counter += size return data @@ -409,12 +404,12 @@ def std(self): return np.sqrt(sum / N) def __array__(self): - """Method that will be called by numpy when trying to convert the object to a numpy ndarray. - """ + """Method that will be called by numpy when trying to convert the object to a numpy ndarray.""" raise UserWarning( "Slices can not be converted to numpy arrays, but they support all typical numpy" " operations such as np.multiply. If a 'global' array containing all subgeomslices is" - " required, use the 'to_global' method and use the returned numpy-array explicitly.") + " required, use the 'to_global' method and use the returned numpy-array explicitly." + ) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Method that will be called by numpy when using a ufunction with a GeomSlice as input. @@ -425,7 +420,9 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): logging.warning( "The %s method has been used which is not explicitly implemented. Correctness of" " results is not guaranteed. If you require this feature to be implemented please" - " submit an issue on Github where you explain your use case.", method) + " submit an issue on Github where you explain your use case.", + method, + ) input_list = list(inputs) for i, inp in enumerate(inputs): if isinstance(inp, self.__class__): @@ -434,7 +431,8 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): raise UserWarning( f"The {method} operation is not implemented for multiple geomslices as input yet. If" " you require this feature, please request this functionality by submitting an" - " issue on Github.") + " issue on Github." + ) new_slice = deepcopy(self) for subgeomslice in new_slice._subgeomslices.values(): @@ -455,10 +453,12 @@ def __array_function__(self, func, types, args, kwargs): def __repr__(self): # if self.type == '3D': # 3D-Slice - # return f"GeomSlice([3D] quantity={self.quantity}, cell_centered={self.cell_centered}, extent={self.extent})" + # return (f"GeomSlice([3D] quantity={self.quantity}, cell_centered={self.cell_centered}, " + # f"extent={self.extent})") # else: # 2D-Slice - # return f"GeomSlice([2D] quantity={self.quantity}, cell_centered={self.cell_centered}, extent={self.extent}, " \ - # f"extent_dirs={self.extent_dirs}, orientation={self.orientation})" + # return (f"GeomSlice([2D] quantity={self.quantity}, cell_centered={self.cell_centered}, " + # f"extent={self.extent}, extent_dirs={self.extent_dirs}, orientation={self.orientation})") return f"GeomSlice(quantity={self.quantity})" + # __array_function__ implementations diff --git a/fdsreader/slcf/geomslice_collection.py b/fdsreader/slcf/geomslice_collection.py index c8c733a7..8b854f53 100644 --- a/fdsreader/slcf/geomslice_collection.py +++ b/fdsreader/slcf/geomslice_collection.py @@ -8,7 +8,7 @@ class GeomSliceCollection(FDSDataCollection): """Collection of :class:`GeomSlice` objects. Offers extensive functionality for filtering and - using geomslices as well as its subclasses such as :class:`SubSlice`. + using geomslices as well as its subclasses such as :class:`SubSlice`. """ def __init__(self, *geomslices: Iterable[GeomSlice]): @@ -19,21 +19,22 @@ def quantities(self) -> List[Quantity]: return list({slc.name for slc in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): - """Filters all geomslices by a specific quantity. - """ - if type(quantity) == Quantity: + """Filters all geomslices by a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return GeomSliceCollection(x for x in self if x.quantity.name.lower() == quantity.lower() - or x.quantity.short_name.lower() == quantity.lower()) + return GeomSliceCollection( + x + for x in self + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def get_by_id(self, geomslice_id: str): - """Get the geomslice with corresponding id if it exists. - """ + """Get the geomslice with corresponding id if it exists.""" return next((slc for slc in self if slc.id == geomslice_id), None) def get_nearest(self, x: float = None, y: float = None, z: float = None) -> GeomSlice: """Filters the geomslice with the shortest distance to the given point. - If there are multiple geomslices with the same distance, a random one will be selected. + If there are multiple geomslices with the same distance, a random one will be selected. """ d_min = np.finfo(float).max geomslices_min = list() @@ -48,15 +49,15 @@ def get_nearest(self, x: float = None, y: float = None, z: float = None) -> Geom geomslices_min.append(slc) if x is not None: - geomslices_min.sort(key=lambda slc: (slc.extent.x_end - slc.extent.x_start)) + geomslices_min.sort(key=lambda slc: slc.extent.x_end - slc.extent.x_start) if y is not None: - geomslices_min.sort(key=lambda slc: (slc.extent.y_end - slc.extent.y_start)) + geomslices_min.sort(key=lambda slc: slc.extent.y_end - slc.extent.y_start) if z is not None: - geomslices_min.sort(key=lambda slc: (slc.extent.z_end - slc.extent.z_start)) + geomslices_min.sort(key=lambda slc: slc.extent.z_end - slc.extent.z_start) if len(geomslices_min) > 0: return geomslices_min[0] return None def __repr__(self): - return "GeomSliceCollection(" + super(GeomSliceCollection, self).__repr__() + ")" + return "GeomSliceCollection(" + super().__repr__() + ")" diff --git a/fdsreader/slcf/slice.py b/fdsreader/slcf/slice.py index 394f8045..d0f479c2 100644 --- a/fdsreader/slcf/slice.py +++ b/fdsreader/slcf/slice.py @@ -1,23 +1,22 @@ +import logging import math import os from copy import deepcopy +from typing import Collection, Dict, List, Tuple, Union import numpy as np -import logging -from typing import Dict, Collection, Tuple, Union, List from typing_extensions import Literal -from fdsreader.fds_classes import Mesh -from fdsreader.utils import Dimension, Quantity, Extent -from fdsreader import settings import fdsreader.utils.fortran_data as fdtype +from fdsreader import settings +from fdsreader.fds_classes import Mesh +from fdsreader.utils import Dimension, Extent, Quantity _HANDLED_FUNCTIONS = {} def implements(np_function): - """Decorator to register an __array_function__ implementation for Slices. - """ + """Decorator to register an __array_function__ implementation for Slices.""" def decorator(func): _HANDLED_FUNCTIONS[np_function] = func @@ -34,7 +33,7 @@ class SubSlice: :ivar dimension: :class:`Dimension` object containing information about steps in each dimension. """ - _offset = 3 * fdtype.new((('c', 30),)).itemsize + fdtype.new((('i', 6),)).itemsize + _offset = 3 * fdtype.new((("c", 30),)).itemsize + fdtype.new((("i", 6),)).itemsize def __init__(self, parent_slc, filename: str, dimension: Dimension, extent: Extent, mesh: Mesh): self._parent_slice = parent_slc @@ -47,17 +46,16 @@ def __init__(self, parent_slc, filename: str, dimension: Dimension, extent: Exte self.vector_filenames = dict() self._vector_data = dict() - def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ - Literal['x', 'y', 'z'], np.ndarray]: + def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[Literal["x", "y", "z"], np.ndarray]: """Returns a dictionary containing a numpy ndarray with coordinates for each dimension. - For cell-centered slices, the coordinates can be adjusted to represent cell-centered coordinates. + For cell-centered slices, the coordinates can be adjusted to represent cell-centered coordinates. - :param ignore_cell_centered: Whether to shift the coordinates when the slice is cell_centered or not. + :param ignore_cell_centered: Whether to shift the coordinates when the slice is cell_centered or not. """ # orientation = ('x', 'y', 'z')[self.orientation - 1] if self.orientation != 0 else '' # coords = {'x': set(), 'y': set(), 'z': set()} - coords: Dict[Literal['x', 'y', 'z'], np.ndarray] = {} - for dim in ('x', 'y', 'z'): + coords: Dict[Literal["x", "y", "z"], np.ndarray] = {} + for dim in ("x", "y", "z"): co = self.mesh.coordinates[dim].copy() # In case the slice is cell-centered, we will shift the coordinates by half a cell # and remove the last coordinate @@ -65,8 +63,7 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ co = co[:-1] co += abs(co[1] - co[0]) / 2 - coords[dim] = co[ - np.where(np.logical_and(co >= self.extent[dim][0], co <= self.extent[dim][1]))] + coords[dim] = co[np.where(np.logical_and(co >= self.extent[dim][0], co <= self.extent[dim][1]))] if coords[dim].size == 0: coords[dim] = np.array([co[np.argmin(np.abs(co - self.extent[dim][0]))]]) @@ -74,8 +71,7 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ @property def shape(self) -> Tuple[int, int]: - """2D-shape of the slice. - """ + """2D-shape of the slice.""" shape = self.dimension.shape(cell_centered=self.cell_centered) if self.orientation != 0: if len(shape) < 2: @@ -85,14 +81,12 @@ def shape(self) -> Tuple[int, int]: @property def orientation(self) -> Literal[0, 1, 2, 3]: - """Orientation [1,2,3] of the slice in case it is 2D, 0 otherwise. - """ + """Orientation [1,2,3] of the slice in case it is 2D, 0 otherwise.""" return self._parent_slice.orientation @property def cell_centered(self) -> bool: - """Indicates whether centered positioning for data is used. - """ + """Indicates whether centered positioning for data is used.""" return self._parent_slice.cell_centered @property @@ -101,27 +95,25 @@ def times(self): @property def n_t(self) -> int: - """Get the number of timesteps for which data was output. - """ + """Get the number of timesteps for which data was output.""" return self._parent_slice.n_t def _load_data(self, file_path: str, data_out: np.ndarray): # Both cases (cell_centered True/False) output the same number of data points n = self.dimension.size(cell_centered=False) - dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((('f', n),))) + dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((("f", n),))) load_times = self.n_t == -1 if load_times: - self._parent_slice.n_t = (os.stat( - file_path).st_size - self._offset) // dtype_data.itemsize + self._parent_slice.n_t = (os.stat(file_path).st_size - self._offset) // dtype_data.itemsize self._parent_slice.times = np.empty(self.n_t) - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: infile.seek(self._offset) for t, data in enumerate(fdtype.read(infile, dtype_data, self.n_t)): if load_times: self.times[t] = data[0][0] - data = data[1].reshape(self.dimension.shape(cell_centered=False), order='F') + data = data[1].reshape(self.dimension.shape(cell_centered=False), order="F") if self.cell_centered: data_out[t, :] = data[1:, 1:] # Ignore ghost points else: @@ -130,14 +122,14 @@ def _load_data(self, file_path: str, data_out: np.ndarray): def _load_times(self) -> np.ndarray: # Read in (only) the times for which SLCF data is available n = self.dimension.size(cell_centered=False) - dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((('f', n),))) + dtype_data = fdtype.combine(fdtype.FLOAT, fdtype.new((("f", n),))) file_path = os.path.join(self._parent_slice._root_path, self.filename) n_t = (os.stat(file_path).st_size - self._offset) // dtype_data.itemsize times = np.empty(n_t) - with open(file_path, 'rb') as infile: + with open(file_path, "rb") as infile: infile.seek(self._offset) for t, data in enumerate(fdtype.read(infile, dtype_data, n_t)): times[t] = data[0][0] @@ -146,8 +138,7 @@ def _load_times(self) -> np.ndarray: @property def data(self) -> np.ndarray: - """Method to lazy load the slice's data. - """ + """Method to lazy load the slice's data.""" if not hasattr(self, "_data"): file_path = os.path.join(self._parent_slice._root_path, self.filename) self._data = np.empty((self.n_t,) + self.shape, dtype=np.float32) @@ -156,16 +147,13 @@ def data(self) -> np.ndarray: @property def vector_data(self) -> Dict[str, np.ndarray]: - """Method to lazy load the slice's vector data if it exists. - """ + """Method to lazy load the slice's vector data if it exists.""" if not hasattr(self, "_vector_data"): raise AttributeError("There is no vector data available for this slice.") if len(self._vector_data) == 0: for direction in self.vector_filenames.keys(): - file_path = os.path.join(self._parent_slice._root_path, - self.vector_filenames[direction]) - self._vector_data[direction] = np.empty((self.n_t,) + self.shape, - dtype=np.float32) + file_path = os.path.join(self._parent_slice._root_path, self.vector_filenames[direction]) + self._vector_data[direction] = np.empty((self.n_t,) + self.shape, dtype=np.float32) self._load_data(file_path, self._vector_data[direction]) return self._vector_data @@ -178,8 +166,7 @@ def vmax(self): return np.max(self.data) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data del self._vector_data @@ -206,8 +193,7 @@ class Slice(np.lib.mixins.NDArrayOperatorsMixin): :ivar extent: :class:`Extent` object containing 3-dimensional extent information. """ - def __init__(self, root_path: str, slice_id: str, cell_centered: bool, - multimesh_data: Collection[Dict]): + def __init__(self, root_path: str, slice_id: str, cell_centered: bool, multimesh_data: Collection[Dict]): self._root_path = root_path self.cell_centered = cell_centered @@ -223,12 +209,10 @@ def __init__(self, root_path: str, slice_id: str, cell_centered: bool, for mesh_data in multimesh_data: if mesh_data["mesh"].id not in self._subslices: - self.quantity = Quantity(mesh_data["quantity"], mesh_data["short_name"], - mesh_data["unit"]) - self._subslices[mesh_data["mesh"].id] = SubSlice(self, mesh_data["filename"], - mesh_data["dimension"], - mesh_data["extent"], - mesh_data["mesh"]) + self.quantity = Quantity(mesh_data["quantity"], mesh_data["short_name"], mesh_data["unit"]) + self._subslices[mesh_data["mesh"].id] = SubSlice( + self, mesh_data["filename"], mesh_data["dimension"], mesh_data["extent"], mesh_data["mesh"] + ) if "-VELOCITY" in mesh_data["quantity"]: vector_temp[mesh_data["mesh"].id][mesh_data["quantity"]] = mesh_data["filename"] @@ -256,21 +240,22 @@ def __init__(self, root_path: str, slice_id: str, cell_centered: bool, orientations.append(0) self.orientation = 0 if any(o != orientations[0] for o in orientations) else orientations[0] - x_start, x_end, y_start, y_end, z_start, z_end = min(subslices, key=lambda - e: e.extent.x_start).extent.x_start, \ - max(subslices, key=lambda - e: e.extent.x_end).extent.x_end, \ - min(subslices, key=lambda - e: e.extent.y_start).extent.y_start, \ - max(subslices, key=lambda - e: e.extent.y_end).extent.y_end, \ - min(subslices, key=lambda - e: e.extent.z_start).extent.z_start, \ - max(subslices, key=lambda - e: e.extent.z_end).extent.z_end - self.extent = Extent(x_start, x_start if self.orientation == 1 else x_end, - y_start, y_start if self.orientation == 2 else y_end, - z_start, z_start if self.orientation == 3 else z_end) + x_start, x_end, y_start, y_end, z_start, z_end = ( + min(subslices, key=lambda e: e.extent.x_start).extent.x_start, + max(subslices, key=lambda e: e.extent.x_end).extent.x_end, + min(subslices, key=lambda e: e.extent.y_start).extent.y_start, + max(subslices, key=lambda e: e.extent.y_end).extent.y_end, + min(subslices, key=lambda e: e.extent.z_start).extent.z_start, + max(subslices, key=lambda e: e.extent.z_end).extent.z_end, + ) + self.extent = Extent( + x_start, + x_start if self.orientation == 1 else x_end, + y_start, + y_start if self.orientation == 2 else y_end, + z_start, + z_start if self.orientation == 3 else z_end, + ) # Read in the available time steps, using arbitrary the first sub-slice self.times = self.subslices[0]._load_times() @@ -283,17 +268,17 @@ def __init__(self, root_path: str, slice_id: str, cell_centered: bool, def get_subslice(self, key: Union[int, str, Mesh]) -> SubSlice: """Returns the :class:`SubSlice` that cuts through the given mesh. When an int is provided - the nth SubSlice will be returned. + the nth SubSlice will be returned. """ return self[key] def __getitem__(self, key: Union[int, str, Mesh]) -> SubSlice: """Returns the :class:`SubSlice` that cuts through the given mesh. When an int is provided - the nth SubSlice will be returned. + the nth SubSlice will be returned. """ - if type(key) == int: + if isinstance(key, int): return tuple(self._subslices.values())[key] - elif type(key) == str: + elif isinstance(key, str): key = tuple(mesh for mesh in self.meshes if mesh.id == key)[0] return self._subslices[key.id] @@ -302,62 +287,55 @@ def __len__(self): @property def subslices(self) -> List[SubSlice]: - """Get a list with all SubSlices. - """ + """Get a list with all SubSlices.""" return list(self._subslices.values()) @property - def extent_dirs(self) -> Tuple[ - Literal['x', 'y', 'z'], Literal['x', 'y', 'z'], Literal['x', 'y', 'z']]: - """The directions in which there is an extent. All three dimensions in case the slice is 3D. - """ + def extent_dirs(self) -> Tuple[Literal["x", "y", "z"], Literal["x", "y", "z"], Literal["x", "y", "z"]]: + """The directions in which there is an extent. All three dimensions in case the slice is 3D.""" ior = self.orientation if ior == 0: - return 'x', 'y', 'z' + return "x", "y", "z" elif ior == 1: - return 'y', 'z' + return "y", "z" elif ior == 2: - return 'x', 'z' + return "x", "z" else: - return 'x', 'y' + return "x", "y" def get_nearest_timestep(self, time: float) -> int: - """Calculates the nearest timestep for which data has been output for this slice. - """ + """Calculates the nearest timestep for which data has been output for this slice.""" idx = np.searchsorted(self.times, time, side="left") - if time > 0 and (idx == len(self.times) or math.fabs( - time - self.times[idx - 1]) < math.fabs(time - self.times[idx])): + if time > 0 and ( + idx == len(self.times) or math.fabs(time - self.times[idx - 1]) < math.fabs(time - self.times[idx]) + ): return idx - 1 else: return idx - def get_nearest_index(self, dimension: Literal['x', 'y', 'z'], value: float) -> int: - """Get the nearest mesh coordinate index in a specific dimension. - """ + def get_nearest_index(self, dimension: Literal["x", "y", "z"], value: float) -> int: + """Get the nearest mesh coordinate index in a specific dimension.""" coords = self.get_coordinates()[dimension] idx = np.searchsorted(coords, value, side="left") - if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs( - value - coords[idx])): + if idx > 0 and (idx == coords.size or math.fabs(value - coords[idx - 1]) < math.fabs(value - coords[idx])): return idx - 1 else: return idx @property def meshes(self) -> List[Mesh]: - """Returns a list of all meshes this slice cuts through. - """ + """Returns a list of all meshes this slice cuts through.""" return [subslc.mesh for subslc in self._subslices.values()] - def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ - Literal['x', 'y', 'z'], np.ndarray]: + def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[Literal["x", "y", "z"], np.ndarray]: """Returns a dictionary containing a numpy ndarray with coordinates for each dimension. - For cell-centered slices, the coordinates can be adjusted to represent cell-centered coordinates. + For cell-centered slices, the coordinates can be adjusted to represent cell-centered coordinates. - :param ignore_cell_centered: Whether to shift the coordinates when the slice is cell_centered or not. + :param ignore_cell_centered: Whether to shift the coordinates when the slice is cell_centered or not. """ - orientation = ('x', 'y', 'z')[self.orientation - 1] if self.orientation != 0 else '' - coords = {'x': list(), 'y': list(), 'z': list()} - for dim in ('x', 'y', 'z'): + orientation = ("x", "y", "z")[self.orientation - 1] if self.orientation != 0 else "" + coords = {"x": list(), "y": list(), "z": list()} + for dim in ("x", "y", "z"): if orientation == dim: coords[dim] = np.array([self.extent[dim][0]]) continue @@ -370,8 +348,7 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ # floating point inaccuraciesas floats get written out with limited precision from FDS # (sometimes only 6 decimal places, e.g. 1.0 and 1.000001) diff = coords[dim][1:] - coords[dim][:-1] - coords[dim] = np.concatenate((coords[dim][:1], coords[dim][1:][diff > 0.000002]), - axis=0) + coords[dim] = np.concatenate((coords[dim][:1], coords[dim][1:][diff > 0.000002]), axis=0) if len(coords[dim]) == 0: slice_coordinate = self.extent[dim][0] @@ -379,9 +356,11 @@ def get_coordinates(self, ignore_cell_centered: bool = False) -> Dict[ for mesh in self._subslices.keys(): mesh_coords = mesh.coordinates[dim] idx = np.searchsorted(mesh_coords, slice_coordinate, side="left") - if idx > 0 and (idx == mesh_coords.size or math.fabs( - slice_coordinate - mesh_coords[idx - 1]) < math.fabs( - slice_coordinate - mesh_coords[idx])): + if idx > 0 and ( + idx == mesh_coords.size + or math.fabs(slice_coordinate - mesh_coords[idx - 1]) + < math.fabs(slice_coordinate - mesh_coords[idx]) + ): idx = idx + 1 if mesh_coords[idx] - slice_coordinate < nearest_coordinate - slice_coordinate: nearest_coordinate = mesh_coords[idx] @@ -398,12 +377,11 @@ def vmax(self): return np.max(self) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for subslice in self._subslices.values(): subslice.clear_cache() - def get_2d_slice_from_3d(self, slice_direction: Literal['x', 'y', 'z', 1, 2, 3], value: float): + def get_2d_slice_from_3d(self, slice_direction: Literal["x", "y", "z", 1, 2, 3], value: float): """Creates a 2D-Slice from a 3D-Slice by slicing the Slice. :param slice_direction: The direction in which to cut through the slice. :param value: The position at which to start cutting through the slice. @@ -411,24 +389,23 @@ def get_2d_slice_from_3d(self, slice_direction: Literal['x', 'y', 'z', 1, 2, 3], if self.type == "2D": logging.error("Trying to slice a 2D-slice, which might not yield the desired result.") - if type(slice_direction) == str: - slice_dim = {'x': 1, 'y': 2, 'z': 3}[slice_direction] + if isinstance(slice_direction, str): + slice_dim = {"x": 1, "y": 2, "z": 3}[slice_direction] else: slice_dim = slice_direction - slice_direction = ('x', 'y', 'z')[slice_dim - 1] + slice_direction = ("x", "y", "z")[slice_dim - 1] new_slice = deepcopy(self) to_remove = list() for mesh_id, subslc in new_slice._subslices.items(): mesh = subslc.mesh # Check if the new slice cuts through the subslice - cut_index = mesh.coordinate_to_index((value,), (slice_direction,), - cell_centered=self.cell_centered) - cut_value = mesh.get_nearest_coordinate((value,), (slice_direction,), - cell_centered=self.cell_centered)[0] - if mesh.coordinates[slice_direction][0] <= value <= mesh.coordinates[slice_direction][ - -1] and \ - subslc.extent[slice_dim][0] <= cut_value <= subslc.extent[slice_dim][1]: + cut_index = mesh.coordinate_to_index((value,), (slice_direction,), cell_centered=self.cell_centered) + cut_value = mesh.get_nearest_coordinate((value,), (slice_direction,), cell_centered=self.cell_centered)[0] + if ( + mesh.coordinates[slice_direction][0] <= value <= mesh.coordinates[slice_direction][-1] + and subslc.extent[slice_dim][0] <= cut_value <= subslc.extent[slice_dim][1] + ): shape = subslc.dimension.shape(cell_centered=self.cell_centered) indices = [slice(None)] @@ -440,11 +417,13 @@ def get_2d_slice_from_3d(self, slice_direction: Literal['x', 'y', 'z', 1, 2, 3], subslc._data = np.squeeze(subslc.data[tuple(indices)], axis=slice_dim) for direction in subslc.vector_filenames.keys(): - subslc._vector_data[direction] = np.squeeze(subslc.vector_data[direction][tuple(indices)], axis=slice_dim) + subslc._vector_data[direction] = np.squeeze( + subslc.vector_data[direction][tuple(indices)], axis=slice_dim + ) # Change 3D extent to 2D one new_extent = subslc.extent.as_list() - new_extent[(slice_dim - 1) * 2:slice_dim * 2] = (cut_value, cut_value) + new_extent[(slice_dim - 1) * 2 : slice_dim * 2] = (cut_value, cut_value) subslc.extent = Extent(*new_extent) else: to_remove.append(mesh_id) @@ -454,22 +433,27 @@ def get_2d_slice_from_3d(self, slice_direction: Literal['x', 'y', 'z', 1, 2, 3], new_slice.orientation = slice_dim subslices = new_slice._subslices.values() - x_start, x_end, y_start, y_end, z_start, z_end = \ - min(subslices, key=lambda e: e.extent.x_start).extent.x_start, \ - max(subslices, key=lambda e: e.extent.x_end).extent.x_end, \ - min(subslices, key=lambda e: e.extent.y_start).extent.y_start, \ - max(subslices, key=lambda e: e.extent.y_end).extent.y_end, \ - min(subslices, key=lambda e: e.extent.z_start).extent.z_start, \ - max(subslices, key=lambda e: e.extent.z_end).extent.z_end - new_slice.extent = Extent(x_start, x_start if new_slice.orientation == 1 else x_end, - y_start, y_start if new_slice.orientation == 2 else y_end, - z_start, z_start if new_slice.orientation == 3 else z_end) + x_start, x_end, y_start, y_end, z_start, z_end = ( + min(subslices, key=lambda e: e.extent.x_start).extent.x_start, + max(subslices, key=lambda e: e.extent.x_end).extent.x_end, + min(subslices, key=lambda e: e.extent.y_start).extent.y_start, + max(subslices, key=lambda e: e.extent.y_end).extent.y_end, + min(subslices, key=lambda e: e.extent.z_start).extent.z_start, + max(subslices, key=lambda e: e.extent.z_end).extent.z_end, + ) + new_slice.extent = Extent( + x_start, + x_start if new_slice.orientation == 1 else x_end, + y_start, + y_start if new_slice.orientation == 2 else y_end, + z_start, + z_start if new_slice.orientation == 3 else z_end, + ) return new_slice def sort_subslices_cartesian(self): - """Returns all subslices sorted in cartesian coordinates. - """ + """Returns all subslices sorted in cartesian coordinates.""" slices = list(self._subslices.values()) slices_cart = [[slices[0]]] orientation = abs(slices[0].orientation) @@ -504,36 +488,42 @@ def sort_subslices_cartesian(self): slices_cart.append([slc]) return slices_cart - def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: bool = False) -> \ - Union[np.ndarray, Tuple[np.ndarray, np.ndarray], Tuple[ - np.ndarray, Dict[Literal['x', 'y', 'z'], np.ndarray]], Tuple[ - Tuple[np.ndarray, np.ndarray], Tuple[Dict[Literal['x', 'y', 'z'], np.ndarray], Dict[ - Literal['x', 'y', 'z'], np.ndarray]]]]: + def to_global( + self, masked: bool = False, fill: float = 0, return_coordinates: bool = False + ) -> Union[ + np.ndarray, + Tuple[np.ndarray, np.ndarray], + Tuple[np.ndarray, Dict[Literal["x", "y", "z"], np.ndarray]], + Tuple[ + Tuple[np.ndarray, np.ndarray], + Tuple[Dict[Literal["x", "y", "z"], np.ndarray], Dict[Literal["x", "y", "z"], np.ndarray]], + ], + ]: """Creates a global numpy ndarray from all subslices (works for 2D- and 3D-slices). - Note: This method might create a sparse np-array that consumes lots of memory. - Attention: Two global slices are returned in cases where cell-centered slices cut right - through one or more mesh borders. If there is a cell-centered slice that cuts right - through the border of two meshes (mesh1 and mesh2), there will actually be two slices - that could be equally relevant for the user. The one will cells on mesh1 and the one - with cells on mesh2. As there are no cells in between (i.e. where the slice "should" be) - and cell-centered slices output values at the centers of each cell, FDS simply outputs - two slices, one on each side of the mesh borders. The fdsreader will not discard any - data, both slices that are output by FDS are sent to the user for him to decide which - one to use. - - :param masked: Whether to apply the obstruction mask to the slice or not. - :param fill: The fill value to use for masked slice entries. Only used when masked=True. - :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. + Note: This method might create a sparse np-array that consumes lots of memory. + Attention: Two global slices are returned in cases where cell-centered slices cut right + through one or more mesh borders. If there is a cell-centered slice that cuts right + through the border of two meshes (mesh1 and mesh2), there will actually be two slices + that could be equally relevant for the user. The one will cells on mesh1 and the one + with cells on mesh2. As there are no cells in between (i.e. where the slice "should" be) + and cell-centered slices output values at the centers of each cell, FDS simply outputs + two slices, one on each side of the mesh borders. The fdsreader will not discard any + data, both slices that are output by FDS are sent to the user for him to decide which + one to use. + + :param masked: Whether to apply the obstruction mask to the slice or not. + :param fill: The fill value to use for masked slice entries. Only used when masked=True. + :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. """ if len(self._subslices) == 0: if return_coordinates: - return np.array([]), {d: np.array([]) for d in ('x', 'y', 'z')} + return np.array([]), {d: np.array([]) for d in ("x", "y", "z")} else: return np.array([]) subslice_sets = [dict(), dict()] - dimension = ['x', 'y', 'z'][self.orientation - 1] + dimension = ["x", "y", "z"][self.orientation - 1] base_coord = next(iter(self._subslices.values())).get_coordinates(ignore_cell_centered=False)[dimension][0] for mesh, slc in self._subslices.items(): @@ -546,67 +536,69 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b subslice_sets.pop(1) first_result_grid = None + first_result_coordinates = None for subslices in subslice_sets: - coord_min = {'x': math.inf, 'y': math.inf, 'z': math.inf} - coord_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} - for dim in ('x', 'y', 'z'): + coord_min = {"x": math.inf, "y": math.inf, "z": math.inf} + coord_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} + for dim in ("x", "y", "z"): for slc in subslices.values(): co = slc.mesh.coordinates[dim] - co = co[np.where( - np.logical_and(co >= slc.extent[dim][0], co <= slc.extent[dim][1]))] + co = co[np.where(np.logical_and(co >= slc.extent[dim][0], co <= slc.extent[dim][1]))] coord_min[dim] = min(co[0], coord_min[dim]) coord_max[dim] = max(co[-1], coord_max[dim]) # The global grid will use the finest mesh as base and duplicate values of the coarser # meshes. Therefore, we first find the finest mesh and calculate the step size in each # dimension. - step_sizes_min = {'x': coord_max['x'] - coord_min['x'], - 'y': coord_max['y'] - coord_min['y'], - 'z': coord_max['z'] - coord_min['z']} - step_sizes_max = {'x': 0, 'y': 0, 'z': 0} + step_sizes_min = { + "x": coord_max["x"] - coord_min["x"], + "y": coord_max["y"] - coord_min["y"], + "z": coord_max["z"] - coord_min["z"], + } + step_sizes_max = {"x": 0, "y": 0, "z": 0} steps = dict() - global_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} + global_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): for sslc in subslices.values(): step_size = sslc.mesh.coordinates[dim][1] - sslc.mesh.coordinates[dim][0] step_sizes_min[dim] = min(step_size, step_sizes_min[dim]) step_sizes_max[dim] = max(step_size, step_sizes_max[dim]) global_max[dim] = max(sslc.mesh.coordinates[dim][-1], global_max[dim]) - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): if step_sizes_min[dim] == 0: step_sizes_min[dim] = math.inf steps[dim] = 1 else: - steps[dim] = max( - int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), 1) + ( - 0 if self.cell_centered else 1) # + step_sizes_max[dim] / step_sizes_min[dim] + steps[dim] = max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), 1) + ( + 0 if self.cell_centered else 1 + ) # + step_sizes_max[dim] / step_sizes_min[dim] - grid = np.full((self.n_t, steps['x'], steps['y'], steps['z']), np.nan) + grid = np.full((self.n_t, steps["x"], steps["y"], steps["z"]), np.nan) start_idx = dict() end_idx = dict() for slc in subslices.values(): - slc_data = slc.data.copy() if slc.orientation == 0 else np.expand_dims(slc.data.copy(), - axis=slc.orientation) + slc_data = ( + slc.data.copy() if slc.orientation == 0 else np.expand_dims(slc.data.copy(), axis=slc.orientation) + ) if masked: mask = slc.mesh.get_obstruction_mask_slice(slc) for axis in (0, 1, 2): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] if axis == slc.orientation - 1: start_idx[dim] = 0 end_idx[dim] = 1 continue - n_repeat = max(int(round( - (slc.mesh.coordinates[dim][1] - slc.mesh.coordinates[dim][0]) / - step_sizes_min[dim])), 1) + n_repeat = max( + int(round((slc.mesh.coordinates[dim][1] - slc.mesh.coordinates[dim][0]) / step_sizes_min[dim])), + 1, + ) - start_idx[dim] = int(round( - (slc.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) - end_idx[dim] = int(round( - (slc.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) + start_idx[dim] = int(round((slc.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) + end_idx[dim] = int(round((slc.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) # We ignore border points unless they are actually on the border of the simulation space as all # other border points actually appear twice, as the subslices overlap. This only @@ -644,16 +636,21 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b if masked: slc_data = np.where(mask, slc_data, fill) - grid[:, start_idx['x']: end_idx['x'], start_idx['y']: end_idx['y'], - start_idx['z']: end_idx['z']] = slc_data.reshape( - (self.n_t, end_idx['x'] - start_idx['x'], end_idx['y'] - start_idx['y'], - end_idx['z'] - start_idx['z'])) + grid[:, start_idx["x"] : end_idx["x"], start_idx["y"] : end_idx["y"], start_idx["z"] : end_idx["z"]] = ( + slc_data.reshape( + ( + self.n_t, + end_idx["x"] - start_idx["x"], + end_idx["y"] - start_idx["y"], + end_idx["z"] - start_idx["z"], + ) + ) + ) if return_coordinates: coordinates = dict() - for dim_index, dim in enumerate(('x', 'y', 'z')): - coordinates[dim] = np.linspace(coord_min[dim], coord_max[dim], - grid.shape[dim_index + 1]) + for dim_index, dim in enumerate(("x", "y", "z")): + coordinates[dim] = np.linspace(coord_min[dim], coord_max[dim], grid.shape[dim_index + 1]) # Remove dimension corresponding to orientation for 2D slices if self.orientation != 0: @@ -677,10 +674,10 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b return first_result_grid @property - def type(self) -> Literal['2D', '3D']: + def type(self) -> Literal["2D", "3D"]: if self.orientation == 0: - return '3D' - return '2D' + return "3D" + return "2D" @implements(np.amin) def _min(self): @@ -705,18 +702,17 @@ def std(self): :returns: The calculated standard deviation. """ mean = self.mean - sum = np.sum( - [np.sum(np.power(subsclice.data - mean, 2)) for subsclice in self._subslices.values()]) + sum = np.sum([np.sum(np.power(subsclice.data - mean, 2)) for subsclice in self._subslices.values()]) N = np.sum([subsclice.data.size for subsclice in self._subslices.values()]) return np.sqrt(sum / N) def __array__(self): - """Method that will be called by numpy when trying to convert the object to a numpy ndarray. - """ + """Method that will be called by numpy when trying to convert the object to a numpy ndarray.""" raise UserWarning( "Slices can not be converted to numpy arrays, but they support all typical numpy" " operations such as np.multiply. If a 'global' array containing all subslices is" - " required, use the 'to_global' method and use the returned numpy-array explicitly.") + " required, use the 'to_global' method and use the returned numpy-array explicitly." + ) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Method that will be called by numpy when using a ufunction with a Slice as input. @@ -727,7 +723,9 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): logging.warning( "The %s method has been used which is not explicitly implemented. Correctness of" " results is not guaranteed. If you require this feature to be implemented please" - " submit an issue on Github where you explain your use case.", method) + " submit an issue on Github where you explain your use case.", + method, + ) input_list = list(inputs) for i, inp in enumerate(inputs): if isinstance(inp, self.__class__): @@ -736,7 +734,8 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): raise UserWarning( f"The {method} operation is not implemented for multiple slices as input yet. If" " you require this feature, please request this functionality by submitting an" - " issue on Github.") + " issue on Github." + ) new_slice = deepcopy(self) for subslice in new_slice._subslices.values(): @@ -756,10 +755,13 @@ def __array_function__(self, func, types, args, kwargs): return _HANDLED_FUNCTIONS[func](*args, **kwargs) def __repr__(self): - if self.type == '3D': # 3D-Slice + if self.type == "3D": # 3D-Slice return f"Slice([3D] quantity={self.quantity}, cell_centered={self.cell_centered}, extent={self.extent})" else: # 2D-Slice - return f"Slice([2D] quantity={self.quantity}, cell_centered={self.cell_centered}, extent={self.extent}, " \ - f"extent_dirs={self.extent_dirs}, orientation={self.orientation})" + return ( + f"Slice([2D] quantity={self.quantity}, cell_centered={self.cell_centered}, extent={self.extent}, " + f"extent_dirs={self.extent_dirs}, orientation={self.orientation})" + ) + # __array_function__ implementations diff --git a/fdsreader/slcf/slice_collection.py b/fdsreader/slcf/slice_collection.py index aa334e24..dd135a9a 100644 --- a/fdsreader/slcf/slice_collection.py +++ b/fdsreader/slcf/slice_collection.py @@ -8,7 +8,7 @@ class SliceCollection(FDSDataCollection): """Collection of :class:`Slice` objects. Offers additional functionality for filtering and - using slices as well as its subclasses such as :class:`SubSlice`. + using slices as well as its subclasses such as :class:`SubSlice`. """ def __init__(self, *slices: Iterable[Slice]): @@ -19,21 +19,22 @@ def quantities(self) -> List[Quantity]: return list({slc.quantity.name for slc in self}) def filter_by_quantity(self, quantity: Union[str, Quantity]): - """Filters all slices by a specific quantity. - """ - if type(quantity) == Quantity: + """Filters all slices by a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return SliceCollection(x for x in self if x.quantity.name.lower() == quantity.lower() - or x.quantity.short_name.lower() == quantity.lower()) + return SliceCollection( + x + for x in self + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def get_by_id(self, slice_id: str): - """Get the slice with corresponding id if it exists. - """ + """Get the slice with corresponding id if it exists.""" return next((slc for slc in self if slc.id == slice_id), None) def get_nearest(self, x: float = None, y: float = None, z: float = None) -> Slice: """Filters the slice with the shortest distance to the given point. - If there are multiple slices with the same distance, a random one will be selected. + If there are multiple slices with the same distance, a random one will be selected. """ d_min = np.finfo(float).max @@ -49,15 +50,15 @@ def get_nearest(self, x: float = None, y: float = None, z: float = None) -> Slic slices_min.append(slc) if x is not None: - slices_min.sort(key=lambda slc: (slc.extent.x_end - slc.extent.x_start)) + slices_min.sort(key=lambda slc: slc.extent.x_end - slc.extent.x_start) if y is not None: - slices_min.sort(key=lambda slc: (slc.extent.y_end - slc.extent.y_start)) + slices_min.sort(key=lambda slc: slc.extent.y_end - slc.extent.y_start) if z is not None: - slices_min.sort(key=lambda slc: (slc.extent.z_end - slc.extent.z_start)) + slices_min.sort(key=lambda slc: slc.extent.z_end - slc.extent.z_start) if len(slices_min) > 0: return slices_min[0] return None def __repr__(self): - return "SliceCollection(" + super(SliceCollection, self).__repr__() + ")" + return "SliceCollection(" + super().__repr__() + ")" diff --git a/fdsreader/smoke3d/__init__.py b/fdsreader/smoke3d/__init__.py index ca62e895..261118be 100644 --- a/fdsreader/smoke3d/__init__.py +++ b/fdsreader/smoke3d/__init__.py @@ -1,3 +1,2 @@ -from .smoke3d import Smoke3D - -from .smoke3D_collection import Smoke3DCollection +from .smoke3d import Smoke3D as Smoke3D +from .smoke3D_collection import Smoke3DCollection as Smoke3DCollection diff --git a/fdsreader/smoke3d/smoke3D_collection.py b/fdsreader/smoke3d/smoke3D_collection.py index 4e738382..359dd43d 100644 --- a/fdsreader/smoke3d/smoke3D_collection.py +++ b/fdsreader/smoke3d/smoke3D_collection.py @@ -6,7 +6,7 @@ class Smoke3DCollection(FDSDataCollection): """Collection of :class:`Smoke3D` objects. Offers extensive functionality for filtering and using Smoke3Ds as well - as its subclasses such as :class:`SubSmoke3D`. + as its subclasses such as :class:`SubSmoke3D`. """ def __init__(self, *smoke3ds: Iterable[Smoke3D]): @@ -17,12 +17,14 @@ def quantities(self) -> List[Quantity]: return [smoke3d.name for smoke3d in self._elements] def get_by_quantity(self, quantity: Union[Quantity, str]): - """Gets the :class:`Smoke3D` with a specific quantity. - """ - if type(quantity) == Quantity: + """Gets the :class:`Smoke3D` with a specific quantity.""" + if isinstance(quantity, Quantity): quantity = quantity.name - return next(x for x in self._elements if - x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower()) + return next( + x + for x in self._elements + if x.quantity.name.lower() == quantity.lower() or x.quantity.short_name.lower() == quantity.lower() + ) def __repr__(self): - return "Smoke3DCollection(" + super(Smoke3DCollection, self).__repr__() + ")" + return "Smoke3DCollection(" + super().__repr__() + ")" diff --git a/fdsreader/smoke3d/smoke3d.py b/fdsreader/smoke3d/smoke3d.py index 82922549..d84a0a65 100644 --- a/fdsreader/smoke3d/smoke3d.py +++ b/fdsreader/smoke3d/smoke3d.py @@ -1,21 +1,21 @@ import logging +import math import os from copy import deepcopy -from typing import Dict, Tuple, Literal, Union +from typing import Dict, Literal, Tuple, Union + import numpy as np -import math +import fdsreader.utils.fortran_data as fdtype +from fdsreader import settings from fdsreader.fds_classes import Mesh from fdsreader.utils import Quantity -from fdsreader import settings -import fdsreader.utils.fortran_data as fdtype _HANDLED_FUNCTIONS = {np.mean: (lambda pl: pl.mean)} def implements(np_function): - """Decorator to register an __array_function__ implementation for Smoke3Ds. - """ + """Decorator to register an __array_function__ implementation for Smoke3Ds.""" def decorator(func): _HANDLED_FUNCTIONS[np_function] = func @@ -41,12 +41,11 @@ def __init__(self, file_path: str, mesh: Mesh, upper_bounds: np.ndarray, times: @property def data(self) -> np.ndarray: - """Method to lazy load the Smoke3D data of a single mesh. - """ + """Method to lazy load the Smoke3D data of a single mesh.""" if not hasattr(self, "_data"): - with open(self._file_path, 'rb') as infile: - dtype_header = fdtype.new((('i', 8),)) - dtype_nchars = fdtype.new((('i', 2),)) + with open(self._file_path, "rb") as infile: + dtype_header = fdtype.new((("i", 8),)) + dtype_nchars = fdtype.new((("i", 2),)) header = fdtype.read(infile, dtype_header, 1)[0][0] nx, ny, nz = int(header[3]), int(header[5]), int(header[7]) @@ -59,10 +58,10 @@ def data(self) -> np.ndarray: nchars_out = int(fdtype.read(infile, dtype_nchars, 1)[0][0][1]) if nchars_out > 0: - dtype_data = fdtype.new((('u', nchars_out),)) + dtype_data = fdtype.new((("u", nchars_out),)) rle_data = fdtype.read(infile, dtype_data, 1)[0][0] - decoded_data = np.empty(((nx + 1) * (ny + 1) * (nz + 1))) + decoded_data = np.empty((nx + 1) * (ny + 1) * (nz + 1)) # Decode run-length-encoded data (see "RLE" subroutine in smvv.f90) i = 0 @@ -77,16 +76,15 @@ def data(self) -> np.ndarray: value = rle_data[i] repeats = 1 i += 1 - decoded_data[out_pos:out_pos + repeats] = value + decoded_data[out_pos : out_pos + repeats] = value out_pos += repeats - self._data[t, :, :, :] = decoded_data.reshape(data_shape, order='F') + self._data[t, :, :, :] = decoded_data.reshape(data_shape, order="F") return self._data def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" if hasattr(self, "_data"): del self._data @@ -117,23 +115,24 @@ def _add_subsmoke(self, filename: str, mesh: Mesh, upper_bounds: np.ndarray) -> return subsmoke - def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: bool = False) -> \ - Union[np.ndarray, Tuple[np.ndarray, Dict[Literal['x', 'y', 'z'], np.ndarray]]]: + def to_global( + self, masked: bool = False, fill: float = 0, return_coordinates: bool = False + ) -> Union[np.ndarray, Tuple[np.ndarray, Dict[Literal["x", "y", "z"], np.ndarray]]]: """Creates a global numpy ndarray from all subsmokes. - :param masked: Whether to apply the obstruction mask to the data or not. - :param fill: The fill value to use for masked entries. Only used when masked=True. - :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. + :param masked: Whether to apply the obstruction mask to the data or not. + :param fill: The fill value to use for masked entries. Only used when masked=True. + :param return_coordinates: If true, return the matching coordinate for each value on the generated grid. """ if len(self._subsmokes) == 0: if return_coordinates: - return np.array([]), {d: np.array([]) for d in ('x', 'y', 'z')} + return np.array([]), {d: np.array([]) for d in ("x", "y", "z")} else: return np.array([]) - coord_min = {'x': math.inf, 'y': math.inf, 'z': math.inf} - coord_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} - for dim in ('x', 'y', 'z'): + coord_min = {"x": math.inf, "y": math.inf, "z": math.inf} + coord_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} + for dim in ("x", "y", "z"): for subsmoke in self._subsmokes.values(): co = subsmoke.mesh.coordinates[dim] coord_min[dim] = min(co[0], coord_min[dim]) @@ -142,44 +141,51 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b # The global grid will use the finest mesh as base and duplicate values of the coarser # meshes. Therefore, we first find the finest mesh and calculate the step size in each # dimension. - step_sizes_min = {'x': coord_max['x'] - coord_min['x'], - 'y': coord_max['y'] - coord_min['y'], - 'z': coord_max['z'] - coord_min['z']} - step_sizes_max = {'x': 0, 'y': 0, 'z': 0} + step_sizes_min = { + "x": coord_max["x"] - coord_min["x"], + "y": coord_max["y"] - coord_min["y"], + "z": coord_max["z"] - coord_min["z"], + } + step_sizes_max = {"x": 0, "y": 0, "z": 0} steps = dict() - global_max = {'x': -math.inf, 'y': -math.inf, 'z': -math.inf} + global_max = {"x": -math.inf, "y": -math.inf, "z": -math.inf} - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): for subsmoke in self._subsmokes.values(): step_size = subsmoke.mesh.coordinates[dim][1] - subsmoke.mesh.coordinates[dim][0] step_sizes_min[dim] = min(step_size, step_sizes_min[dim]) step_sizes_max[dim] = max(step_size, step_sizes_max[dim]) global_max[dim] = max(subsmoke.mesh.coordinates[dim][-1], global_max[dim]) - for dim in ('x', 'y', 'z'): + for dim in ("x", "y", "z"): if step_sizes_min[dim] == 0: step_sizes_min[dim] = math.inf steps[dim] = 1 else: - steps[dim] = max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), - 1) + 1 # + step_sizes_max[dim] / step_sizes_min[dim] + steps[dim] = ( + max(int(round((coord_max[dim] - coord_min[dim]) / step_sizes_min[dim])), 1) + 1 + ) # + step_sizes_max[dim] / step_sizes_min[dim] - grid = np.full((self.n_t, steps['x'], steps['y'], steps['z']), np.nan) + grid = np.full((self.n_t, steps["x"], steps["y"], steps["z"]), np.nan) for subsmoke in self._subsmokes.values(): subsmoke_data = subsmoke.data.copy() if masked: mask = subsmoke.mesh.get_obstruction_mask(self.times) - start_idx = {dim: int(round( - (subsmoke.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) for dim in ('x', 'y', 'z')} - end_idx = {dim: int(round( - (subsmoke.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) for dim in ('x', 'y', 'z')} + start_idx = { + dim: int(round((subsmoke.mesh.coordinates[dim][0] - coord_min[dim]) / step_sizes_min[dim])) + for dim in ("x", "y", "z") + } + end_idx = { + dim: int(round((subsmoke.mesh.coordinates[dim][-1] - coord_min[dim]) / step_sizes_min[dim])) + for dim in ("x", "y", "z") + } temp_data = dict() temp_mask = dict() for axis in range(3): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] # Temporarily save border points to add them back to the array again later if np.isclose(subsmoke.mesh.coordinates[dim][-1], global_max[dim]): temp_data_slices = [slice(s) for s in subsmoke_data.shape] @@ -192,26 +198,36 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b # We ignore border points unless they are actually on the border of the simulation space as all # other border points actually appear twice, as the subslices overlap. This only # applies for face_centered slices, as cell_centered slices will not overlap. - reduced_shape_slices = (slice(subsmoke.data.shape[0]),) + tuple(slice(1, None) for s in subsmoke.data.shape[1:]) + reduced_shape_slices = (slice(subsmoke.data.shape[0]),) + tuple( + slice(1, None) for s in subsmoke.data.shape[1:] + ) subsmoke_data = subsmoke_data[reduced_shape_slices] if masked: mask = mask[reduced_shape_slices] - n_repeat = max(int(round( - (subsmoke.mesh.coordinates[dim][1] - subsmoke.mesh.coordinates[dim][0]) / - step_sizes_min[dim])), 1) + n_repeat = max( + int( + round( + (subsmoke.mesh.coordinates[dim][1] - subsmoke.mesh.coordinates[dim][0]) + / step_sizes_min[dim] + ) + ), + 1, + ) if n_repeat > 1: subsmoke_data = np.repeat(subsmoke_data, n_repeat, axis=axis + 1) if masked: mask = np.repeat(mask, n_repeat, axis=axis + 1) for axis in range(3): - dim = ('x', 'y', 'z')[axis] + dim = ("x", "y", "z")[axis] # Add border points back again if needed if np.isclose(subsmoke.mesh.coordinates[dim][-1], global_max[dim]): temp_data_slices = [slice(s) for s in subsmoke_data.shape] temp_data_slices[axis + 1] = slice(None) - subsmoke_data = np.concatenate((subsmoke_data, temp_data[dim][tuple(temp_data_slices)]), axis=axis + 1) + subsmoke_data = np.concatenate( + (subsmoke_data, temp_data[dim][tuple(temp_data_slices)]), axis=axis + 1 + ) if masked: mask = np.concatenate((mask, temp_mask[dim][tuple(temp_data_slices)]), axis=axis + 1) @@ -220,14 +236,20 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b if masked: subsmoke_data = np.where(mask, subsmoke_data, fill) - grid[:, start_idx['x']: end_idx['x'], start_idx['y']: end_idx['y'], - start_idx['z']: end_idx['z']] = subsmoke_data.reshape( - (self.n_t, end_idx['x'] - start_idx['x'], end_idx['y'] - start_idx['y'], - end_idx['z'] - start_idx['z'])) + grid[:, start_idx["x"] : end_idx["x"], start_idx["y"] : end_idx["y"], start_idx["z"] : end_idx["z"]] = ( + subsmoke_data.reshape( + ( + self.n_t, + end_idx["x"] - start_idx["x"], + end_idx["y"] - start_idx["y"], + end_idx["z"] - start_idx["z"], + ) + ) + ) if return_coordinates: coordinates = dict() - for dim_index, dim in enumerate(('x', 'y', 'z')): + for dim_index, dim in enumerate(("x", "y", "z")): coordinates[dim] = np.linspace(coord_min[dim], coord_max[dim], grid.shape[dim_index + 1]) if return_coordinates: @@ -236,31 +258,26 @@ def to_global(self, masked: bool = False, fill: float = 0, return_coordinates: b return grid def __getitem__(self, mesh: Mesh): - """Returns the :class:`SubSmoke` that contains data for the given mesh. - """ + """Returns the :class:`SubSmoke` that contains data for the given mesh.""" return self.get_subsmoke(mesh) @property def n_t(self) -> int: - """Get the number of timesteps for which data was output. - """ + """Get the number of timesteps for which data was output.""" return len(self.times) def get_subsmoke(self, mesh: Mesh): - """Returns the :class:`SubSmoke` that contains data for the given mesh. - """ + """Returns the :class:`SubSmoke` that contains data for the given mesh.""" return self._subsmokes[mesh.id] @property def subsmokes(self): - """Returns a list with one SubSmoke3D object per mesh. - """ + """Returns a list with one SubSmoke3D object per mesh.""" return list(self._subsmokes.values()) @property def vmax(self): - """Maximum value of all data at any time. - """ + """Maximum value of all data at any time.""" curr_max = max(np.max(subsmoke3d.upper_bounds) for subsmoke3d in self._subsmokes.values()) if curr_max == np.float32(-1e33): return max(np.max(subsmoke3d.data) for subsmoke3d in self._subsmokes.values()) @@ -268,32 +285,29 @@ def vmax(self): @implements(np.mean) def mean(self) -> np.ndarray: - """Calculates the mean value of all Smoke3D data for this quantity. - """ + """Calculates the mean value of all Smoke3D data for this quantity.""" return np.sum([np.mean(subsmoke.data) for subsmoke in self._subsmokes.values()]) / len(self._subsmokes) @implements(np.std) def std(self) -> np.ndarray: - """Calculates the standard deviation of all Smoke3D data for this quantity. - """ + """Calculates the standard deviation of all Smoke3D data for this quantity.""" mean = self.mean sum = np.sum([np.sum(np.power(subsmoke.data - mean, 2)) for subsmoke in self._subsmokes.values()]) N = np.sum([subsmoke.data.size for subsmoke in self._subsmokes.values()]) return np.sqrt(sum / N) def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for subsmoke in self._subsmokes.values(): subsmoke.clear_cache() def __array__(self): - """Method that will be called by numpy when trying to convert the object to a numpy ndarray. - """ + """Method that will be called by numpy when trying to convert the object to a numpy ndarray.""" raise UserWarning( "Smoke3Ds can not be converted to numpy arrays, but they support all typical numpy" " operations such as np.multiply. If a 'global' array containing all subsmokes is" - " required, please use the 'to_global' method and use the returned numpy-array explicitly.") + " required, please use the 'to_global' method and use the returned numpy-array explicitly." + ) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """Method that will be called by numpy when using a ufunction with a Smoke3D as input. @@ -304,7 +318,9 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): logging.warning( "The %s method has been used which is not explicitly implemented. Correctness of" " results is not guaranteed. If you require this feature to be implemented please" - " submit an issue on Github where you explain your use case.", method) + " submit an issue on Github where you explain your use case.", + method, + ) input_list = list(inputs) for i, inp in enumerate(inputs): if isinstance(inp, self.__class__): @@ -327,5 +343,6 @@ def __array_function__(self, func, types, args, kwargs): return NotImplemented return _HANDLED_FUNCTIONS[func](*args, **kwargs) + # __array_function__ implementations # ... diff --git a/fdsreader/utils/__init__.py b/fdsreader/utils/__init__.py index 63f3b714..5a1738d6 100644 --- a/fdsreader/utils/__init__.py +++ b/fdsreader/utils/__init__.py @@ -1,7 +1,5 @@ -from .data import Quantity +from fdsreader.utils.dimension import Dimension as Dimension +from fdsreader.utils.extent import Extent as Extent -from fdsreader.utils.dimension import Dimension - -from fdsreader.utils.extent import Extent - -from.misc import * +from .data import Quantity as Quantity +from .misc import log_error as log_error diff --git a/fdsreader/utils/data.py b/fdsreader/utils/data.py index 631b3eda..908a1dea 100644 --- a/fdsreader/utils/data.py +++ b/fdsreader/utils/data.py @@ -1,10 +1,13 @@ """ Collection of internal utilities (convenience functions and classes) for data handling. """ + import glob import hashlib import os + import numpy as np + try: # Python <= 3.9 from collections import Iterable @@ -20,6 +23,7 @@ class Quantity: :ivar quantity: The name of the quantity. :ivar unit: The corresponding unit of the quantity. """ + def __init__(self, quantity: str, short_name: str, unit: str): self.short_name = short_name self.unit = unit @@ -44,8 +48,8 @@ def __repr__(self): class Profile: - """Class containing profile data. - """ + """Class containing profile data.""" + def __init__(self, profile_id: str, times: np.ndarray, npoints: np.ndarray, depths: np.ndarray, values: np.ndarray): self.id = profile_id self.times = times @@ -61,9 +65,8 @@ def __repr__(self): def create_hash(path: str): - """Returns the md5 hash as string for the given file. - """ - return str(hashlib.md5(open(path, 'rb').read()).hexdigest()) + """Returns the md5 hash as string for the given file.""" + return str(hashlib.md5(open(path, "rb").read()).hexdigest()) def scan_directory_smv(directory: str): @@ -87,19 +90,18 @@ def get_smv_file(path: str): elif os.path.isdir(path): files = scan_directory_smv(path) if len(files) > 1: - raise IOError("There are multiple simulations in the directory: " + path) + raise OSError("There are multiple simulations in the directory: " + path) elif len(files) == 0: - raise IOError("No simulations were found in the directory: " + path) + raise OSError("No simulations were found in the directory: " + path) return files[0] elif os.path.isfile(path + ".smv"): return path + ".smv" else: - raise IOError("The given path does neither point to a directory nor a file: " + path) + raise OSError("The given path does neither point to a directory nor a file: " + path) class FDSDataCollection: - """(Abstract) Base class for any collection of FDS data. - """ + """(Abstract) Base class for any collection of FDS data.""" def __init__(self, *elements: Iterable): self._elements = tuple(*elements) @@ -120,7 +122,6 @@ def __repr__(self): return "[" + ",\n".join(str(e) for e in self._elements) + "]" def clear_cache(self): - """Remove all data from the internal cache that has been loaded so far to free memory. - """ + """Remove all data from the internal cache that has been loaded so far to free memory.""" for element in self._elements: element.clear_cache() diff --git a/fdsreader/utils/dimension.py b/fdsreader/utils/dimension.py index 82a31896..930ba1e0 100644 --- a/fdsreader/utils/dimension.py +++ b/fdsreader/utils/dimension.py @@ -1,6 +1,6 @@ from functools import reduce from operator import mul -from typing import Tuple, List +from typing import List, Tuple from typing_extensions import Literal @@ -13,14 +13,14 @@ class Dimension: :ivar z: Number of data points in z-direction (end is exclusive). """ - def __init__(self, *args, skip_dimension: Literal['x', 1, 'y', 2, 'z', 3, ''] = ''): + def __init__(self, *args, skip_dimension: Literal["x", 1, "y", 2, "z", 3, ""] = ""): dimensions = list(args) - if skip_dimension in ('x', 1): + if skip_dimension in ("x", 1): dimensions.insert(0, 1) - elif skip_dimension in ('y', 2): + elif skip_dimension in ("y", 2): dimensions.insert(1, 1) - elif skip_dimension in ('z', 3): + elif skip_dimension in ("z", 3): dimensions.append(1) self.x = dimensions[0] @@ -33,17 +33,16 @@ def __eq__(self, other): def __repr__(self, *args, **kwargs): return f"Dimension({self.x}, {self.y}, {self.z})" - def __getitem__(self, dimension: Literal[0, 1, 2, 'x', 'y', 'z']): - if type(dimension) == int: - dimension = ('x', 'y', 'z')[dimension] + def __getitem__(self, dimension: Literal[0, 1, 2, "x", "y", "z"]): + if isinstance(dimension, int): + dimension = ("x", "y", "z")[dimension] return self.__dict__[dimension] def size(self, cell_centered=False): return reduce(mul, self.shape(cell_centered)) def shape(self, cell_centered=False) -> Tuple: - """Method to get the actual number of data points per dimension. - """ + """Method to get the actual number of data points per dimension.""" s = list() c = -1 if cell_centered else 0 if self.x != 1: diff --git a/fdsreader/utils/extent.py b/fdsreader/utils/extent.py index d654bc44..74814bdf 100644 --- a/fdsreader/utils/extent.py +++ b/fdsreader/utils/extent.py @@ -1,13 +1,12 @@ -from typing import Tuple, List, Union +from typing import List, Tuple, Union from typing_extensions import Literal class Extent: - """Three-dimensional value-based extent with support for a missing dimension (2D). - """ + """Three-dimensional value-based extent with support for a missing dimension (2D).""" - def __init__(self, *args, skip_dimension: Literal['x', 1, 'y', 2, 'z', 3, ''] = ''): + def __init__(self, *args, skip_dimension: Literal["x", 1, "y", 2, "z", 3, ""] = ""): self._extents = list() if len(args) % 2 != 0: @@ -15,64 +14,59 @@ def __init__(self, *args, skip_dimension: Literal['x', 1, 'y', 2, 'z', 3, ''] = for i in range(0, len(args), 2): self._extents.append((float(args[i]), float(args[i + 1]))) - if skip_dimension in ('x', 1): + if skip_dimension in ("x", 1): self._extents.insert(0, (0, 0)) - elif skip_dimension in ('y', 2): + elif skip_dimension in ("y", 2): self._extents.insert(1, (0, 0)) - elif skip_dimension in ('z', 3): + elif skip_dimension in ("z", 3): self._extents.append((0, 0)) def __eq__(self, other): return self._extents == other._extents def __repr__(self, *args, **kwargs): - return "Extent([{:.2f}, {:.2f}] x [{:.2f}, {:.2f}] x [{:.2f}, {:.2f}])".format(self.x_start, self.x_end, - self.y_start, self.y_end, - self.z_start, self.z_end) + return ( + f"Extent([{self.x_start:.2f}, {self.x_end:.2f}] x [{self.y_start:.2f}, {self.y_end:.2f}]" + f" x [{self.z_start:.2f}, {self.z_end:.2f}])" + ) - def __getitem__(self, item: Union[Literal['x', 'y', 'z', 1, 2, 3]]): - if item == 'x': + def __getitem__(self, item: Union[Literal["x", "y", "z", 1, 2, 3]]): + if item == "x": return self.x_start, self.x_end - elif item == 'y': + elif item == "y": return self.y_start, self.y_end - elif item == 'z': + elif item == "z": return self.z_start, self.z_end return self._extents[item - 1] @property def x_start(self) -> float: - """Gives the absolute extent in x-direction. - """ + """Gives the absolute extent in x-direction.""" return self._extents[0][0] @property def y_start(self) -> float: - """Gives the absolute extent in y-direction. - """ + """Gives the absolute extent in y-direction.""" return self._extents[1][0] @property def z_start(self) -> float: - """Gives the absolute extent in z-direction. - """ + """Gives the absolute extent in z-direction.""" return self._extents[2][0] @property def x_end(self) -> float: - """Gives the absolute extent in x-direction. - """ + """Gives the absolute extent in x-direction.""" return self._extents[0][1] @property def y_end(self) -> float: - """Gives the absolute extent in y-direction. - """ + """Gives the absolute extent in y-direction.""" return self._extents[1][1] @property def z_end(self) -> float: - """Gives the absolute extent in z-direction. - """ + """Gives the absolute extent in z-direction.""" return self._extents[2][1] def as_tuple(self, reduced=True) -> Tuple[float, ...]: diff --git a/fdsreader/utils/fortran_data.py b/fdsreader/utils/fortran_data.py index 7e5f8f33..d398f125 100644 --- a/fdsreader/utils/fortran_data.py +++ b/fdsreader/utils/fortran_data.py @@ -1,22 +1,31 @@ -from typing import Sequence, BinaryIO, Union, Tuple +from typing import BinaryIO, Sequence, Tuple, Union + import numpy as np -from fdsreader.settings import FORTRAN_DATA_TYPE_CHAR, FORTRAN_DATA_TYPE_FLOAT, FORTRAN_DATA_TYPE_INTEGER, \ - FORTRAN_DATA_TYPE_UINT8, FORTRAN_BACKWARD +from fdsreader.settings import ( + FORTRAN_BACKWARD, + FORTRAN_DATA_TYPE_CHAR, + FORTRAN_DATA_TYPE_FLOAT, + FORTRAN_DATA_TYPE_INTEGER, + FORTRAN_DATA_TYPE_UINT8, +) -_BASE_FORMAT = f"{FORTRAN_DATA_TYPE_INTEGER}, {{}}" + ( - f", {FORTRAN_DATA_TYPE_INTEGER}" if FORTRAN_BACKWARD else "") +_BASE_FORMAT = f"{FORTRAN_DATA_TYPE_INTEGER}, {{}}" + (f", {FORTRAN_DATA_TYPE_INTEGER}" if FORTRAN_BACKWARD else "") -_DATA_TYPES = {'i': FORTRAN_DATA_TYPE_INTEGER, 'f': FORTRAN_DATA_TYPE_FLOAT, 'c': FORTRAN_DATA_TYPE_CHAR, - 'u': FORTRAN_DATA_TYPE_UINT8, '{}': "{}"} +_DATA_TYPES = { + "i": FORTRAN_DATA_TYPE_INTEGER, + "f": FORTRAN_DATA_TYPE_FLOAT, + "c": FORTRAN_DATA_TYPE_CHAR, + "u": FORTRAN_DATA_TYPE_UINT8, + "{}": "{}", +} def _get_dtype_output_format(d, n): - """Returns the correct output format needed to create a numpy dtype depending on input. - """ - if d == 'c': + """Returns the correct output format needed to create a numpy dtype depending on input.""" + if d == "c": return str(n) - if type(n) == int or type(n) == np.int32: + if isinstance(n, (int, np.int32)): return f"({n},)" return str(n) @@ -54,15 +63,15 @@ def combine(*dtypes: np.dtype): type_combination = list() for types in dtypes: for dtype in types.descr: - type_combination.append(tuple(['f' + str(count)] + list(dtype[1:]))) + type_combination.append(tuple(["f" + str(count)] + list(dtype[1:]))) count += 1 return np.dtype(type_combination) # Commonly used datatypes -CHAR = new((('c', 1),)) -INT = new((('i', 1),)) -FLOAT = new((('f', 1),)) +CHAR = new((("c", 1),)) +INT = new((("i", 1),)) +FLOAT = new((("f", 1),)) # Border datatype to get the border of a fortran write PRE_BORDER = np.dtype(FORTRAN_DATA_TYPE_INTEGER) HAS_POST_BORDER = FORTRAN_BACKWARD @@ -77,5 +86,5 @@ def read(infile: BinaryIO, dtype: np.dtype, n: int): :returns: Read in data. """ return np.array( - [[t[i] for i in range(1, len(t), 3)] for t in np.fromfile(infile, dtype=dtype, count=n)], - dtype=object) + [[t[i] for i in range(1, len(t), 3)] for t in np.fromfile(infile, dtype=dtype, count=n)], dtype=object + ) diff --git a/fdsreader/utils/misc.py b/fdsreader/utils/misc.py index 23f616bb..4559604b 100644 --- a/fdsreader/utils/misc.py +++ b/fdsreader/utils/misc.py @@ -1,6 +1,7 @@ import logging -from functools import wraps import sys +from functools import wraps + from fdsreader import settings @@ -14,7 +15,14 @@ def wrapped(*args, **kwargs): if settings.DEBUG: raise e elif not settings.IGNORE_ERRORS: - e = type(e)(f"Module {str(module)}: {str(e)}\nThe error can be safely ignored if not requiring the {str(module)} module. However, please consider to submit an issue on Github including the error message, the stack trace and your FDS input-file so we can reproduce the error and fix it as soon as possible!").with_traceback(sys.exc_info()[2]) + e = type(e)( + f"Module {str(module)}: {str(e)}\nThe error can be safely ignored if not requiring the" + f" {str(module)} module. However, please consider to submit an issue on Github including" + f" the error message, the stack trace and your FDS input-file so we can reproduce the" + f" error and fix it as soon as possible!" + ).with_traceback(sys.exc_info()[2]) logging.warning(e) + return wrapped + return decorated diff --git a/pyproject.toml b/pyproject.toml index c33555ed..ba984a4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,30 +1,28 @@ [build-system] -requires = [ - "setuptools", - "wheel", - "incremental>=24.7.2", # ← Add incremental as a build dependency -] +requires = ["setuptools", "wheel", "setuptools_scm[toml]"] build-backend = "setuptools.build_meta" [project] name = "fdsreader" -dynamic = ["version"] +dynamic = ["version"] description = "Python reader for data generated by FDS." readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.10" license = {file = "LICENSE"} authors = [ {name = "FZJ IAS-7 (Jan Vogelsang, Prof. Dr. Lukas Arnold, Tristan Hehnen)", email = "j.vogelsang@fz-juelich.de"} ] -classifiers=[ - "Programming Language :: Python :: 3", - "Operating System :: OS Independent", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - ] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", +] dependencies = [ - "incremental", "numpy", - "typing_extensions" + "typing_extensions", ] [project.urls] @@ -32,13 +30,56 @@ Repository = "https://github.com/FireDynamics/fdsreader" [project.optional-dependencies] dev = [ - "pytest >=6.0" + "pytest>=7.0", + "pytest-cov", + "ruff", + "coverage[toml]", + "pre-commit", ] -[tool.incremental] +[tool.setuptools_scm] +version_scheme = "post-release" +local_scheme = "no-local-version" [tool.setuptools] -packages = ["fdsreader","fdsreader.bndf","fdsreader.devc","fdsreader.evac","fdsreader.export","fdsreader.fds_classes","fdsreader.geom","fdsreader.isof","fdsreader.part","fdsreader.pl3d","fdsreader.slcf","fdsreader.smoke3d","fdsreader.utils"] +packages = [ + "fdsreader", + "fdsreader.bndf", + "fdsreader.devc", + "fdsreader.evac", + "fdsreader.export", + "fdsreader.fds_classes", + "fdsreader.geom", + "fdsreader.isof", + "fdsreader.part", + "fdsreader.pl3d", + "fdsreader.slcf", + "fdsreader.smoke3d", + "fdsreader.utils", +] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] +ignore = ["UP006", "UP007", "UP035"] # typing generics – schrittweise migrieren + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.coverage.run] +branch = true +source = ["fdsreader"] + +[tool.coverage.report] +fail_under = 30 +exclude_lines = [ + "if TYPE_CHECKING:", + "pragma: no cover", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..3dbb7d42 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.0 +pytest-cov +ruff +coverage[toml] +pre-commit diff --git a/tests/acceptance_tests/test_bndf.py b/tests/acceptance_tests/test_bndf.py index 1513649b..5d6de626 100644 --- a/tests/acceptance_tests/test_bndf.py +++ b/tests/acceptance_tests/test_bndf.py @@ -1,9 +1,41 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_bndf(): - sim = Simulation("./bndf_data") - obst = sim.obstructions.get_nearest(-0.8, 1, 1) - face = obst.get_global_boundary_data_arrays("Wall Temperature")[1] +@pytest.fixture(scope="module") +def bndf_sim(): + return Simulation("./bndf_data") + + +@pytest.fixture(scope="module") +def bndf_obst(bndf_sim): + return bndf_sim.obstructions.get_nearest(-0.8, 1, 1) + +def test_bndf(bndf_obst): + face = bndf_obst.get_global_boundary_data_arrays("Wall Temperature")[1] assert len(face[-1]) == 68 + + +def test_bndf_times_non_empty(bndf_obst): + assert len(bndf_obst.times) > 0 + + +def test_bndf_times_start_at_zero(bndf_obst): + assert bndf_obst.times[0] == pytest.approx(0.0) + + +def test_bndf_times_monotonic(bndf_obst): + assert np.all(np.diff(bndf_obst.times) > 0), "BNDF time steps must be monotonically increasing" + + +def test_bndf_data_shape_matches_times(bndf_obst): + face = bndf_obst.get_global_boundary_data_arrays("Wall Temperature")[1] + assert face.shape[0] == len(bndf_obst.times) + + +def test_bndf_no_nan(bndf_obst): + face = bndf_obst.get_global_boundary_data_arrays("Wall Temperature")[1] + assert not np.isnan(face).any(), "NaN values found in BNDF data" diff --git a/tests/acceptance_tests/test_devc.py b/tests/acceptance_tests/test_devc.py index 9a86a63f..30a674b9 100644 --- a/tests/acceptance_tests/test_devc.py +++ b/tests/acceptance_tests/test_devc.py @@ -1,6 +1,32 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_devc(): - sim = Simulation("./steckler_data") - assert abs(sim.devices["TC_Door"][0].data - 23.58) < 1e-6 +@pytest.fixture(scope="module") +def devc_sim(): + return Simulation("./steckler_data") + + +def test_devc(devc_sim): + assert abs(devc_sim.devices["TC_Door"][0].data - 23.58) < 1e-6 + + +def test_devc_time_channel_non_empty(devc_sim): + time_dev = devc_sim.devices["Time"] + assert len(time_dev.data) > 0 + + +def test_devc_time_channel_starts_at_zero(devc_sim): + time_dev = devc_sim.devices["Time"] + assert time_dev.data[0] == pytest.approx(0.0) + + +def test_devc_time_channel_monotonic(devc_sim): + time_dev = devc_sim.devices["Time"] + assert np.all(np.diff(time_dev.data) > 0), "Time channel must be monotonically increasing" + + +def test_devc_quantity_has_name(devc_sim): + assert devc_sim.devices["TC_Door"][0].quantity_name != "" diff --git a/tests/acceptance_tests/test_geom.py b/tests/acceptance_tests/test_geom.py index 68770a79..896bbc77 100644 --- a/tests/acceptance_tests/test_geom.py +++ b/tests/acceptance_tests/test_geom.py @@ -1,9 +1,33 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_geom(): - sim = Simulation("./geom_data") - geom = sim.geom_data.filter_by_quantity("Radiative Heat Flux")[0] +@pytest.fixture(scope="module") +def geom_sim(): + return Simulation("./geom_data") + + +@pytest.fixture(scope="module") +def geom(geom_sim): + return geom_sim.geom_data.filter_by_quantity("Radiative Heat Flux")[0] + +def test_geom(geom): assert len(geom.faces) == len(geom.data[-1]) == 19816 assert len(geom.vertices) == 40624 + + +def test_geom_data_non_empty(geom): + assert len(geom.data) > 0 + + +def test_geom_faces_match_data(geom): + assert len(geom.faces) == len(geom.data[-1]) + + +def test_geom_vertices_finite(geom): + vertices = np.array(geom.vertices) + assert not np.isnan(vertices).any(), "NaN in Geometrie-Vertices" + assert not np.isinf(vertices).any(), "Inf in Geometrie-Vertices" diff --git a/tests/acceptance_tests/test_isof.py b/tests/acceptance_tests/test_isof.py index aa8a0cc2..b472e563 100644 --- a/tests/acceptance_tests/test_isof.py +++ b/tests/acceptance_tests/test_isof.py @@ -1,10 +1,39 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_isof(): - sim = Simulation("./steckler_data") - isosurface = sim.isosurfaces.filter_by_quantity("TEMP")[0] - vertices, triangles, _ = isosurface.to_global(len(isosurface.times) - 1) +@pytest.fixture(scope="module") +def isof_sim(): + return Simulation("./steckler_data") + + +@pytest.fixture(scope="module") +def isosurface(isof_sim): + return isof_sim.isosurfaces.filter_by_quantity("TEMP")[0] + +def test_isof(isosurface): + vertices, triangles, _ = isosurface.to_global(len(isosurface.times) - 1) assert abs(vertices[-1][0] - 2.80595016) < 1e-6 and abs(vertices[-1][1] - 0.1) < 1e-6 and abs(vertices[-1][2] - 1.83954549) < 1e-6 assert abs(triangles[-1][-1][0] - 4625) < 1e-6 and abs(triangles[-1][-1][1] - 4627) < 1e-6 and abs(triangles[-1][-1][2] - 4708) < 1e-6 + + +def test_isof_times_non_empty(isosurface): + assert len(isosurface.times) > 0 + + +def test_isof_times_start_at_zero(isosurface): + assert isosurface.times[0] == pytest.approx(0.0) + + +def test_isof_times_monotonic(isosurface): + times = np.array(isosurface.times) + assert np.all(np.diff(times) > 0), "ISOF time steps must be monotonically increasing" + + +def test_isof_to_global_returns_vertices(isosurface): + vertices, triangles, _ = isosurface.to_global(0) + assert vertices is not None + assert triangles is not None diff --git a/tests/acceptance_tests/test_part.py b/tests/acceptance_tests/test_part.py index 75eda866..225932f1 100644 --- a/tests/acceptance_tests/test_part.py +++ b/tests/acceptance_tests/test_part.py @@ -1,11 +1,36 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_part(): - sim = Simulation("./part_data") - particles = sim.particles["WATER PARTICLES"] +@pytest.fixture(scope="module") +def part_sim(): + return Simulation("./part_data") + + +@pytest.fixture(scope="module") +def particles(part_sim): + return part_sim.particles["WATER PARTICLES"] + + +def test_part(particles): position = particles.positions[-1][-1] color_data = particles.data["PARTICLE DIAMETER"] - assert abs(position[0] + 0.04036335) < 1e-6 and abs(position[1] - 0.05389348) < 1e-6 and abs(position[2] - 13.596354) < 1e-6 assert abs(color_data[-1][-1] - 2.2600818) < 1e-6 + + +def test_part_positions_non_empty(particles): + assert len(particles.positions) > 0 + + +def test_part_positions_match_timesteps(particles): + assert len(particles.positions) == len(particles.data["PARTICLE DIAMETER"]) + + +def test_part_positions_finite(particles): + for positions_at_t in particles.positions: + arr = np.array(positions_at_t) + assert not np.isnan(arr).any(), "NaN in Partikel-Positionen" + assert not np.isinf(arr).any(), "Inf in Partikel-Positionen" diff --git a/tests/acceptance_tests/test_pl3d.py b/tests/acceptance_tests/test_pl3d.py index 10dc8a68..4b3c2f9c 100644 --- a/tests/acceptance_tests/test_pl3d.py +++ b/tests/acceptance_tests/test_pl3d.py @@ -1,10 +1,31 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_pl3d(): - sim = Simulation("./pl3d_data") - pl_t1 = sim.data_3d.get_by_quantity("Temperature") - data, coordinates = pl_t1.to_global(masked=True, return_coordinates=True) +@pytest.fixture(scope="module") +def pl3d_sim(): + return Simulation("./pl3d_data") + + +@pytest.fixture(scope="module") +def pl3d(pl3d_sim): + return pl3d_sim.data_3d.get_by_quantity("Temperature") + +def test_pl3d(pl3d): + data, coordinates = pl3d.to_global(masked=True, return_coordinates=True) assert abs(data[-1, 41, 27, 0] - 55.85966110229492) < 1e-6 - assert abs(coordinates['x'][41] - 9.25) < 1e-6 + assert abs(coordinates["x"][41] - 9.25) < 1e-6 + + +def test_pl3d_data_non_empty(pl3d): + data = pl3d.to_global(masked=True) + assert data.size > 0 + + +def test_pl3d_no_nan_inf(pl3d): + data = pl3d.to_global(masked=True) + assert not np.isnan(data.data).any(), "NaN values found in PL3D data" + assert not np.isinf(data.data).any(), "Inf values found in PL3D data" diff --git a/tests/acceptance_tests/test_slcf.py b/tests/acceptance_tests/test_slcf.py index 39954d37..9c741ea4 100644 --- a/tests/acceptance_tests/test_slcf.py +++ b/tests/acceptance_tests/test_slcf.py @@ -1,8 +1,44 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_slcf(): - sim = Simulation("./steckler_data") - data, coordinates = sim.slices[0].to_global(masked=True, return_coordinates=True) +@pytest.fixture(scope="module") +def slcf_sim(): + return Simulation("./steckler_data") + + +def test_slcf(slcf_sim): + data, coordinates = slcf_sim.slices[0].to_global(masked=True, return_coordinates=True) assert abs(data[-1, -1, -1] - 33.311744689941406) < 1e-6 - assert abs(coordinates['x'][0] - 0.) < 1e-6 and abs(coordinates['x'][-1] - 3.6) < 1e-6 + assert abs(coordinates["x"][0] - 0.0) < 1e-6 and abs(coordinates["x"][-1] - 3.6) < 1e-6 + + +def test_slcf_times_non_empty(slcf_sim): + assert slcf_sim.slices[0].n_t > 0 + + +def test_slcf_times_start_at_zero(slcf_sim): + assert slcf_sim.slices[0].times[0] == pytest.approx(0.0) + + +def test_slcf_times_monotonic(slcf_sim): + times = slcf_sim.slices[0].times + assert np.all(np.diff(times) > 0), "Time steps must be strictly monotonically increasing" + + +def test_slcf_data_shape_matches_times(slcf_sim): + slc = slcf_sim.slices[0] + data = slc.to_global(masked=True) + assert data.shape[0] == slc.n_t + + +def test_slcf_no_nan_inf(slcf_sim): + data = slcf_sim.slices[0].to_global(masked=True) + assert not np.isnan(data.data).any(), "NaN values found in SLCF data" + assert not np.isinf(data.data).any(), "Inf values found in SLCF data" + + +def test_slcf_quantity_has_name(slcf_sim): + assert slcf_sim.slices[0].quantity.name != "" diff --git a/tests/acceptance_tests/test_smoke3d.py b/tests/acceptance_tests/test_smoke3d.py index 1dc8b081..6e40b74d 100644 --- a/tests/acceptance_tests/test_smoke3d.py +++ b/tests/acceptance_tests/test_smoke3d.py @@ -1,10 +1,42 @@ +import numpy as np +import pytest + from fdsreader import Simulation -def test_smoke3d(): - sim = Simulation("./steckler_data") - smoke = sim.smoke_3d.get_by_quantity("Temperature") +@pytest.fixture(scope="module") +def smoke_sim(): + return Simulation("./steckler_data") + + +@pytest.fixture(scope="module") +def smoke(smoke_sim): + return smoke_sim.smoke_3d.get_by_quantity("Temperature") + + +def test_smoke3d(smoke): data, coordinates = smoke.to_global(masked=True, return_coordinates=True) + assert abs(data[-1, 13, 13, 1] - 77.0) < 1e-6 + assert abs(coordinates["x"][13] - 1.3) < 1e-6 + + +def test_smoke3d_times_non_empty(smoke): + assert smoke.n_t > 0 + + +def test_smoke3d_times_start_at_zero(smoke): + assert smoke.times[0] == pytest.approx(0.0) + + +def test_smoke3d_times_monotonic(smoke): + assert np.all(np.diff(smoke.times) > 0), "SMOKE3D time steps must be monotonically increasing" + + +def test_smoke3d_data_shape_matches_times(smoke): + data = smoke.to_global(masked=True) + assert data.shape[0] == smoke.n_t + - assert abs(data[-1, 13, 13, 1] - 77.) < 1e-6 - assert abs(coordinates['x'][13] - 1.3) < 1e-6 +def test_smoke3d_no_nan(smoke): + data = smoke.to_global(masked=True) + assert not np.isnan(data.data).any(), "NaN values found in SMOKE3D data" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..57c0a6f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import os +from pathlib import Path + + +def pytest_configure(config): + """Change CWD to tests/cases/ so acceptance tests can use relative paths like './steckler_data'.""" + cases_dir = Path(__file__).parent / "cases" + if cases_dir.exists(): + os.chdir(cases_dir) diff --git a/tests/test_basic_read.py b/tests/test_basic_read.py new file mode 100644 index 00000000..01d10eb5 --- /dev/null +++ b/tests/test_basic_read.py @@ -0,0 +1,45 @@ +"""Basic integration tests: load a simulation and verify core properties are accessible.""" + +from pathlib import Path + +import pytest + +from fdsreader import Simulation + +CASES_DIR = Path(__file__).parent / "cases" +TESTS_DIR = Path(__file__).parent + + +@pytest.fixture(scope="module") +def basic_sim(): + return Simulation(str(TESTS_DIR / "test.smv")) + + +@pytest.fixture(scope="module") +def steckler_sim(): + return Simulation(str(CASES_DIR / "steckler_data")) + + +def test_basic_chid(basic_sim): + assert basic_sim.chid == "test" + + +def test_basic_mesh_count(basic_sim): + assert len(basic_sim.meshes) >= 1 + + +def test_steckler_loads(steckler_sim): + assert steckler_sim.chid is not None + assert len(steckler_sim.meshes) > 0 + + +def test_steckler_slices_accessible(steckler_sim): + assert len(steckler_sim.slices) > 0 + + +def test_steckler_devices_accessible(steckler_sim): + assert len(steckler_sim.devices) > 0 + + +def test_steckler_obstructions_accessible(steckler_sim): + assert len(steckler_sim.obstructions) >= 0 diff --git a/tests/test_version_compatibility.py b/tests/test_version_compatibility.py deleted file mode 100644 index 8ca7595a..00000000 --- a/tests/test_version_compatibility.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Basic tests to ensure version compatibility. -""" - -import unittest - -from fdsreader import Simulation - - -class SimTest(unittest.TestCase): - def test_sim(self): - sim = Simulation(".") - self.assertEqual(sim.chid, "test") - - -if __name__ == '__main__': - unittest.main() From fa2a3d858134a25cb4289f4d31b571fa013f6f18 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 09:59:12 +0200 Subject: [PATCH 2/7] test: add FDS input files directly to repository Previously the .fds input files were only accessible after extracting the .tgz archives. By checking them in directly, contributors can inspect and modify input files without unpacking binaries first. This is required to regenerate test data with a new FDS version. --- tests/cases/fds_inputs/input_steckler.fds | 49 + tests/cases/fds_inputs/sphere_radiate.fds | 1443 +++++++++++++++++++++ tests/cases/fds_inputs/steckler.fds | 40 + tests/cases/fds_inputs/vort3d_80.fds | 49 + tests/cases/fds_inputs/wall_005.fds | 108 ++ tests/cases/fds_inputs/water_droplets.fds | 44 + 6 files changed, 1733 insertions(+) create mode 100644 tests/cases/fds_inputs/input_steckler.fds create mode 100644 tests/cases/fds_inputs/sphere_radiate.fds create mode 100644 tests/cases/fds_inputs/steckler.fds create mode 100644 tests/cases/fds_inputs/vort3d_80.fds create mode 100644 tests/cases/fds_inputs/wall_005.fds create mode 100644 tests/cases/fds_inputs/water_droplets.fds diff --git a/tests/cases/fds_inputs/input_steckler.fds b/tests/cases/fds_inputs/input_steckler.fds new file mode 100644 index 00000000..b326d3c6 --- /dev/null +++ b/tests/cases/fds_inputs/input_steckler.fds @@ -0,0 +1,49 @@ +&HEAD CHID='Steckler_010', TITLE='Steckler Compartment, Test 10'/ + +&MESH IJK=36,28,22, XB=0.00,3.60,-1.40,1.40,0.00,2.13 / + +&TIME T_END=100.0, TIME_SHRINK_FACTOR=10. / +&DUMP NFRAMES=100, DT_DEVC=10., DT_HRR=10., SIG_FIGS=4, SIG_FIGS_EXP=2 / + +&MISC TMPA=22. / + +&SURF ID='BURNER',HRRPUA=1048.3,TMP_FRONT=100.,COLOR='ORANGE' / 62.9 kW + +&HOLE XB=2.75,2.95,-0.10,0.15,0.00,1.83 / 2/6 Door + +&VENT XB=1.30,1.50,-0.10,0.10,0.00,0.00, SURF_ID='BURNER' / Position A +&VENT XB=1.25,1.30,-0.05,0.05,0.00,0.00, SURF_ID='BURNER' / +&VENT XB=1.50,1.55,-0.05,0.05,0.00,0.00, SURF_ID='BURNER' / +&VENT XB=1.35,1.45,-0.15,-.10,0.00,0.00, SURF_ID='BURNER' / +&VENT XB=1.35,1.45, 0.10,0.15,0.00,0.00, SURF_ID='BURNER' / + +&SURF ID='FIBER BOARD', DEFAULT=.TRUE., MATL_ID='INSULATION', THICKNESS=0.013, COLOR='BEIGE' / + +&REAC FUEL='METHANE',SOOT_YIELD=0. / + +&OBST XB=2.80,2.90,-1.40,1.40,0.00,2.13 / Wall with door or window + +&VENT MB='XMAX',SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60,-1.40,-1.40,0.00,2.13, SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60, 1.40, 1.40,0.00,2.13, SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60,-1.40, 1.40,2.13,2.13, SURF_ID='OPEN'/ + +&MATL ID = 'INSULATION' + DENSITY = 200. + CONDUCTIVITY = 0.1 + SPECIFIC_HEAT = 1. / + + +&ISOF QUANTITY='TEMPERATURE', VALUE(1)=10., VALUE(2)=30., VALUE(3)=60. / +&ISOF QUANTITY='U-VELOCITY' , VALUE(1)=0.08, QUANTITY2='TEMPERATURE' / + +&DEVC ID='TC_Room', POINTS=44, XB=2.50,2.50,1.10,1.10,0.02,2.11, QUANTITY='TEMPERATURE', Z_ID='Room_z' / +&DEVC ID='TC_Door', POINTS=38, XB=2.85,2.85,0.00,0.00,0.02,1.82, QUANTITY='TEMPERATURE', Z_ID='Door_z' / +&DEVC ID='BP_Door', POINTS=38, XB=2.85,2.85,0.00,0.00,0.02,1.82, QUANTITY='VELOCITY', VELO_INDEX=1, HIDE_COORDINATES=.TRUE. / + +&DEVC ID='HGL Temp', XB=2.50,2.50,1.10,1.10,0.00,2.13, QUANTITY='UPPER TEMPERATURE' / +&DEVC ID='HGL Height', XB=2.50,2.50,1.10,1.10,0.00,2.13, QUANTITY='LAYER HEIGHT' / + +&SLCF PBY=0.0,QUANTITY='TEMPERATURE',VECTOR=.TRUE./ + +&TAIL / diff --git a/tests/cases/fds_inputs/sphere_radiate.fds b/tests/cases/fds_inputs/sphere_radiate.fds new file mode 100644 index 00000000..5478769f --- /dev/null +++ b/tests/cases/fds_inputs/sphere_radiate.fds @@ -0,0 +1,1443 @@ +&HEAD CHID='sphere_radiate', TITLE='Complex Geometry: Test radiation from a sphere, 1 mesh.' / + +&MESH IJK=64,64,64, XB=-2.0,2.0,-2.0,2.0,0.0,4.0 / + +&TIME T_END=10, TIME_SHRINK_FACTOR=50. / +&PRES SOLVER='UGLMAT' / + +&SPEC ID='NITROGEN', BACKGROUND=.TRUE. / + +&RADI ANGLE_INCREMENT=1, TIME_STEP_INCREMENT=1, NUMBER_RADIATION_ANGLES=400, INITIAL_RADIATION_ITERATIONS=1 / + +&SURF ID='WALL', DEFAULT=.TRUE., TMP_FRONT=-273., TAU_T=0., HEAT_TRANSFER_COEFFICIENT=0., EMISSIVITY=1. / +&SURF ID='SPHERE', COLOR='GREEN', TMP_FRONT=500., TAU_T=0., HEAT_TRANSFER_COEFFICIENT=0., EMISSIVITY=1. / + +&BNDF QUANTITY='RADIATIVE HEAT FLUX', CELL_CENTERED=.TRUE. / + +&DEVC ID='HF1', QUANTITY='RADIATIVE HEAT FLUX', XYZ=-2, 0, 2, IOR= 1 / +&DEVC ID='HF2', QUANTITY='RADIATIVE HEAT FLUX', XYZ= 2, 0, 2, IOR=-1 / +&DEVC ID='HF3', QUANTITY='RADIATIVE HEAT FLUX', XYZ= 0,-2, 2, IOR= 2 / +&DEVC ID='HF4', QUANTITY='RADIATIVE HEAT FLUX', XYZ= 0, 2, 2, IOR=-2 / +&DEVC ID='HF5', QUANTITY='RADIATIVE HEAT FLUX', XYZ= 0, 0, 0, IOR= 3 / +&DEVC ID='HF6', QUANTITY='RADIATIVE HEAT FLUX', XYZ= 0, 0, 4, IOR=-3 / +&DEVC ID='HFT', QUANTITY='RADIATIVE HEAT FLUX', STATISTICS='SURFACE INTEGRAL', SURF_ID='WALL', XB=-2,2,-2,2,0,4 / + +&GEOM ID='SPHERE', SURF_ID='SPHERE' +VERTS= + 0.000, 0.000, 1.000, + 1.000, 0.000, 2.000, + 0.000, 0.000, 3.000, + -1.000, 0.000, 2.000, + 0.188, 0.000, 1.018, + 0.369, 0.000, 1.071, + 0.545, 0.000, 1.162, + 0.700, 0.000, 1.286, + 0.827, 0.000, 1.438, + 0.922, 0.000, 1.613, + 0.980, 0.000, 1.802, + 0.982, 0.000, 2.188, + 0.929, 0.000, 2.369, + 0.838, 0.000, 2.545, + 0.714, 0.000, 2.700, + 0.562, 0.000, 2.827, + 0.387, 0.000, 2.922, + 0.198, 0.000, 2.980, + -0.188, 0.000, 1.018, + -0.369, 0.000, 1.071, + -0.545, 0.000, 1.162, + -0.700, 0.000, 1.286, + -0.827, 0.000, 1.438, + -0.922, 0.000, 1.613, + -0.980, 0.000, 1.802, + -0.982, 0.000, 2.188, + -0.929, 0.000, 2.369, + -0.838, 0.000, 2.545, + -0.714, 0.000, 2.700, + -0.562, 0.000, 2.827, + -0.387, 0.000, 2.922, + -0.198, 0.000, 2.980, + -0.014, 0.092, 2.996, + -0.227, 0.133, 2.965, + -0.426, 0.158, 2.891, + -0.604, 0.174, 2.777, + -0.755, 0.181, 2.630, + -0.866, 0.182, 2.465, + -0.941, 0.176, 2.290, + -0.978, 0.178, 2.109, + -0.979, 0.188, 1.918, + -0.939, 0.190, 1.714, + -0.859, 0.186, 1.523, + -0.743, 0.180, 1.356, + -0.591, 0.174, 1.213, + -0.406, 0.160, 1.100, + -0.204, 0.131, 1.030, + 0.001, 0.084, 1.004, + 0.198, 0.168, 2.966, + 0.427, 0.174, 2.887, + 0.605, 0.177, 2.777, + 0.751, 0.181, 2.636, + 0.863, 0.183, 2.470, + 0.939, 0.185, 2.291, + 0.977, 0.188, 2.105, + 0.978, 0.190, 1.908, + 0.938, 0.187, 1.708, + 0.859, 0.183, 1.522, + 0.739, 0.178, 1.351, + 0.585, 0.167, 1.207, + 0.402, 0.151, 1.097, + 0.200, 0.127, 1.029, + -0.043, 0.241, 2.970, + -0.257, 0.294, 2.921, + -0.455, 0.331, 2.827, + -0.626, 0.351, 2.697, + -0.763, 0.359, 2.537, + -0.862, 0.352, 2.363, + -0.923, 0.322, 2.211, + -0.933, 0.358, 2.048, + -0.915, 0.370, 1.842, + -0.855, 0.371, 1.637, + -0.755, 0.365, 1.455, + -0.617, 0.354, 1.297, + -0.440, 0.337, 1.167, + -0.226, 0.300, 1.073, + -0.002, 0.229, 1.027, + 0.283, 0.336, 2.898, + 0.468, 0.340, 2.816, + 0.626, 0.347, 2.699, + 0.756, 0.357, 2.549, + 0.852, 0.356, 2.383, + 0.910, 0.360, 2.208, + 0.929, 0.370, 2.025, + 0.912, 0.371, 1.828, + 0.854, 0.368, 1.633, + 0.753, 0.358, 1.448, + 0.610, 0.343, 1.286, + 0.430, 0.320, 1.156, + 0.222, 0.285, 1.068, + 0.128, 0.326, 2.937, + -0.070, 0.401, 2.913, + -0.274, 0.463, 2.843, + -0.464, 0.501, 2.731, + -0.623, 0.514, 2.590, + -0.741, 0.521, 2.424, + -0.836, 0.507, 2.209, + -0.847, 0.532, 1.982, + -0.812, 0.534, 1.764, + -0.732, 0.534, 1.577, + -0.619, 0.520, 1.412, + -0.461, 0.508, 1.273, + -0.258, 0.480, 1.161, + -0.003, 0.420, 1.092, + 0.326, 0.489, 2.809, + 0.127, 0.466, 2.875, + 0.491, 0.473, 2.732, + 0.611, 0.512, 2.604, + 0.732, 0.517, 2.443, + 0.818, 0.493, 2.296, + 0.834, 0.533, 2.146, + 0.844, 0.535, 1.957, + 0.813, 0.532, 1.764, + 0.734, 0.531, 1.577, + 0.610, 0.515, 1.398, + 0.447, 0.487, 1.250, + 0.254, 0.448, 1.143, + -0.066, 0.564, 2.823, + -0.270, 0.628, 2.730, + -0.465, 0.649, 2.601, + -0.601, 0.641, 2.477, + -0.663, 0.683, 2.308, + -0.743, 0.661, 1.897, + -0.652, 0.703, 1.718, + -0.601, 0.647, 1.531, + -0.470, 0.655, 1.409, + -0.288, 0.643, 1.290, + -0.079, 0.603, 1.206, + 0.107, 0.561, 1.179, + 0.402, 0.628, 2.666, + 0.566, 0.661, 2.492, + 0.697, 0.659, 2.282, + 0.737, 0.674, 2.053, + 0.751, 0.650, 1.885, + 0.686, 0.677, 1.733, + 0.587, 0.667, 1.541, + 0.441, 0.643, 1.374, + 0.271, 0.606, 1.252, + -0.727, 0.681, 2.089, + 0.161, 0.622, 2.767, + -0.042, 0.722, 2.691, + -0.263, 0.770, 2.581, + -0.461, 0.783, 2.418, + -0.553, 0.814, 2.180, + -0.608, 0.792, 1.942, + -0.476, 0.773, 1.581, + -0.297, 0.781, 1.451, + -0.102, 0.749, 1.346, + 0.091, 0.691, 1.283, + 0.200, 0.762, 2.615, + 0.391, 0.757, 2.524, + 0.482, 0.798, 2.361, + 0.629, 0.771, 1.904, + 0.525, 0.799, 1.709, + 0.411, 0.771, 1.514, + 0.260, 0.737, 1.376, + 0.572, 0.810, 2.130, + -0.009, 0.861, 2.509, + -0.246, 0.870, 2.427, + -0.346, 0.904, 2.253, + -0.422, 0.906, 2.030, + -0.471, 0.856, 1.789, + -0.287, 0.890, 1.647, + -0.096, 0.872, 1.520, + 0.083, 0.813, 1.424, + 0.264, 0.863, 2.431, + 0.357, 0.910, 2.210, + 0.406, 0.911, 1.928, + 0.317, 0.890, 1.672, + 0.240, 0.837, 1.508, + -0.123, 0.946, 2.299, + -0.207, 0.973, 2.097, + -0.257, 0.958, 1.871, + -0.071, 0.960, 1.729, + 0.107, 0.912, 1.604, + 0.111, 0.949, 2.293, + 0.170, 0.983, 2.068, + 0.156, 0.972, 1.822, + -0.018, 0.989, 2.146, + -0.039, 0.998, 1.955, + -0.084, -0.110, 2.990, + -0.245, -0.145, 2.959, + -0.431, -0.166, 2.887, + -0.605, -0.178, 2.776, + -0.752, -0.183, 2.633, + -0.866, -0.179, 2.467, + -0.941, -0.170, 2.292, + -0.978, -0.174, 2.114, + -0.979, -0.188, 1.918, + -0.940, -0.190, 1.718, + -0.860, -0.187, 1.524, + -0.742, -0.180, 1.354, + -0.591, -0.168, 1.211, + -0.418, -0.152, 1.104, + -0.233, -0.132, 1.036, + -0.010, -0.113, 1.006, + 0.302, -0.157, 2.940, + 0.472, -0.128, 2.872, + 0.612, -0.162, 2.774, + 0.756, -0.175, 2.630, + 0.867, -0.183, 2.464, + 0.941, -0.186, 2.283, + 0.978, -0.186, 2.095, + 0.978, -0.185, 1.900, + 0.937, -0.185, 1.705, + 0.859, -0.183, 1.522, + 0.742, -0.176, 1.353, + 0.594, -0.168, 1.213, + 0.419, -0.160, 1.106, + 0.221, -0.146, 1.036, + 0.093, -0.146, 2.985, + -0.067, -0.251, 2.966, + -0.269, -0.312, 2.911, + -0.459, -0.342, 2.820, + -0.626, -0.355, 2.695, + -0.763, -0.354, 2.541, + -0.866, -0.336, 2.370, + -0.927, -0.307, 2.217, + -0.933, -0.355, 2.063, + -0.916, -0.374, 1.854, + -0.856, -0.368, 1.637, + -0.754, -0.356, 1.448, + -0.616, -0.343, 1.291, + -0.450, -0.317, 1.165, + -0.271, -0.280, 1.079, + -0.113, -0.237, 1.035, + 0.170, -0.339, 2.925, + 0.448, -0.310, 2.838, + 0.643, -0.335, 2.689, + 0.769, -0.348, 2.537, + 0.857, -0.363, 2.367, + 0.913, -0.365, 2.184, + 0.935, -0.356, 2.001, + 0.915, -0.356, 1.810, + 0.853, -0.360, 1.623, + 0.756, -0.353, 1.449, + 0.623, -0.332, 1.291, + 0.463, -0.319, 1.173, + 0.271, -0.311, 1.089, + 0.062, -0.275, 1.041, + -0.066, -0.425, 2.903, + -0.283, -0.481, 2.830, + -0.471, -0.503, 2.724, + -0.623, -0.512, 2.591, + -0.752, -0.504, 2.425, + -0.854, -0.463, 2.238, + -0.834, -0.551, 2.031, + -0.815, -0.532, 1.771, + -0.732, -0.520, 1.559, + -0.619, -0.504, 1.398, + -0.465, -0.484, 1.258, + -0.285, -0.441, 1.149, + -0.103, -0.375, 1.079, + 0.306, -0.514, 2.802, + 0.518, -0.495, 2.698, + 0.661, -0.469, 2.586, + 0.742, -0.518, 2.426, + 0.804, -0.533, 2.263, + 0.855, -0.514, 2.073, + 0.873, -0.481, 1.913, + 0.820, -0.517, 1.753, + 0.737, -0.523, 1.572, + 0.626, -0.498, 1.400, + 0.508, -0.456, 1.269, + 0.323, -0.501, 1.197, + 0.099, -0.439, 1.107, + 0.095, -0.522, 2.848, + -0.090, -0.596, 2.798, + -0.306, -0.635, 2.710, + -0.475, -0.628, 2.616, + -0.576, -0.665, 2.476, + -0.726, -0.632, 2.273, + -0.735, -0.670, 1.892, + -0.680, -0.662, 1.685, + -0.608, -0.627, 1.514, + -0.472, -0.634, 1.388, + -0.290, -0.601, 1.255, + -0.096, -0.539, 1.163, + 0.140, -0.680, 2.720, + 0.396, -0.656, 2.643, + 0.582, -0.614, 2.533, + 0.665, -0.663, 2.344, + 0.735, -0.664, 2.137, + 0.782, -0.620, 1.932, + 0.698, -0.671, 1.750, + 0.597, -0.661, 1.545, + 0.463, -0.602, 1.350, + 0.107, -0.619, 1.222, + -0.673, -0.734, 2.097, + 0.289, -0.685, 1.331, + -0.107, -0.744, 2.659, + -0.364, -0.764, 2.532, + -0.545, -0.774, 2.321, + -0.566, -0.814, 1.870, + -0.489, -0.764, 1.580, + -0.292, -0.746, 1.401, + -0.094, -0.692, 1.284, + 0.258, -0.802, 2.540, + 0.479, -0.756, 2.446, + 0.567, -0.794, 2.219, + 0.643, -0.765, 1.974, + 0.507, -0.825, 1.751, + 0.421, -0.758, 1.502, + 0.102, -0.765, 1.364, + -0.468, -0.874, 2.128, + 0.054, -0.812, 2.581, + 0.252, -0.804, 1.462, + -0.126, -0.862, 2.490, + -0.315, -0.888, 2.334, + -0.359, -0.898, 1.747, + -0.249, -0.867, 1.568, + -0.075, -0.825, 1.439, + 0.101, -0.894, 2.437, + 0.330, -0.887, 2.324, + 0.444, -0.895, 2.040, + 0.295, -0.880, 1.628, + -0.232, -0.962, 2.145, + -0.350, -0.936, 1.956, + 0.287, -0.947, 1.858, + 0.111, -0.874, 1.527, + -0.079, -0.947, 2.313, + -0.191, -0.937, 1.706, + -0.223, -0.961, 1.835, + 0.117, -0.956, 2.268, + 0.224, -0.969, 2.100, + 0.113, -0.952, 1.717, + -0.043, -0.917, 1.603, + -0.005, -0.992, 2.126, + 0.077, -0.995, 1.931, + -0.061, -0.977, 1.794, + -0.139, -0.990, 1.981, + 0.284, 0.724, 1.954, + 0.078, 0.489, 1.911, + 0.287, 0.410, 2.062, + 0.128, 0.741, 2.051, + 0.285, 0.420, 1.848, + 0.430, 0.609, 2.097, + 0.479, 0.587, 1.927, + -0.008, -0.209, 1.821, + 0.141, -0.160, 1.528, + -0.050, -0.278, 1.567, + -0.128, -0.114, 1.598, + 0.564, 0.516, 2.041, + 0.480, 0.415, 1.926, + 0.526, 0.498, 2.213, + 0.365, 0.605, 2.273, + 0.117, 0.731, 1.866, + 0.240, 0.675, 1.751, + -0.010, 0.520, 2.077, + -0.146, 0.453, 1.821, + -0.006, 0.269, 2.042, + -0.520, -0.554, 2.075, + 0.104, 0.364, 1.787, + -0.030, 0.757, 1.966, + 0.150, 0.534, 1.685, + -0.252, -0.273, 1.782, + -0.178, -0.131, 1.839, + 0.000, 0.001, 1.760, + -0.194, 0.486, 2.558, + -0.038, 0.295, 2.418, + -0.340, 0.485, 2.449, + -0.128, 0.447, 2.231, + 0.162, -0.506, 1.656, + 0.081, -0.469, 1.409, + -0.039, -0.426, 1.710, + 0.075, -0.334, 1.320, + -0.072, -0.151, 1.380, + -0.214, -0.340, 1.343, + 0.109, -0.348, 1.767, + -0.071, -0.526, 1.455, + -0.219, -0.462, 1.434, + -0.074, -0.411, 1.361, + 0.408, -0.606, 2.168, + 0.476, -0.568, 1.992, + 0.179, -0.730, 2.063, + 0.247, -0.663, 2.242, + 0.362, -0.572, 2.337, + 0.257, -0.385, 2.226, + 0.560, -0.507, 2.105, + -0.032, 0.545, 2.521, + 0.205, 0.397, 2.275, + -0.157, 0.740, 2.074, + -0.014, 0.760, 2.112, + -0.262, 0.685, 2.192, + -0.080, -0.531, 2.001, + -0.262, -0.715, 1.955, + -0.099, -0.418, 1.864, + -0.123, -0.601, 1.811, + 0.151, 0.577, 2.465, + 0.084, 0.717, 2.222, + 0.122, 0.472, 2.581, + -0.187, 0.664, 2.326, + -0.319, 0.685, 2.022, + -0.194, 0.723, 1.903, + -0.356, 0.647, 1.841, + -0.418, 0.615, 2.136, + -0.461, 0.601, 1.956, + 0.232, 0.001, 1.416, + 0.207, -0.237, 1.305, + 0.000, 0.001, 1.344, + 0.156, 0.010, 1.192, + 0.329, 0.245, 1.356, + -0.002, 0.175, 1.257, + 0.158, 0.007, 1.612, + 0.000, 0.001, 1.542, + 0.114, 0.148, 1.519, + 0.300, -0.495, 2.486, + 0.195, -0.606, 2.408, + 0.106, -0.512, 2.542, + 0.230, -0.396, 2.616, + -0.584, -0.271, 2.414, + 0.390, -0.379, 2.534, + 0.029, -0.425, 2.304, + 0.063, -0.494, 2.134, + 0.051, -0.270, 2.441, + 0.024, -0.215, 2.164, + 0.139, -0.117, 2.131, + 0.508, -0.506, 2.263, + 0.420, -0.297, 2.372, + 0.446, -0.469, 2.408, + 0.090, -0.733, 2.205, + 0.041, -0.630, 2.444, + 0.305, 0.476, 2.505, + 0.467, 0.392, 2.463, + 0.430, 0.503, 2.374, + 0.311, 0.300, 2.463, + -0.800, -0.142, 2.094, + -0.766, -0.253, 2.179, + -0.714, -0.271, 2.048, + -0.771, -0.139, 2.240, + -0.755, 0.001, 2.142, + -0.073, 0.662, 1.636, + -0.217, 0.673, 1.733, + -0.054, 0.726, 1.796, + 0.082, 0.695, 1.698, + -0.561, -0.398, 1.662, + -0.171, 0.228, 1.296, + -0.116, 0.155, 1.525, + -0.003, 0.317, 1.316, + -0.155, 0.000, 1.192, + -0.311, 0.123, 1.312, + -0.334, 0.257, 1.367, + -0.232, 0.001, 1.415, + -0.196, 0.364, 1.365, + 0.324, -0.583, 1.613, + 0.233, -0.665, 1.716, + 0.435, -0.508, 1.663, + 0.432, -0.007, 1.714, + 0.207, -0.112, 1.982, + 0.381, -0.210, 1.961, + 0.509, 0.001, 1.897, + 0.717, -0.141, 1.774, + 0.705, 0.000, 1.704, + 0.251, 0.051, 2.078, + 0.510, -0.096, 2.049, + 0.490, 0.097, 2.152, + 0.716, 0.143, 1.777, + -0.770, 0.144, 2.237, + -0.584, 0.210, 2.141, + -0.759, 0.265, 2.174, + -0.661, 0.271, 2.279, + 0.746, 0.145, 1.930, + 0.749, 0.000, 1.849, + -0.594, -0.196, 2.139, + 0.120, 0.117, 2.160, + 0.229, 0.188, 2.312, + 0.344, 0.216, 2.133, + -0.439, -0.506, 2.362, + -0.664, -0.137, 2.358, + -0.709, -0.275, 2.303, + -0.522, -0.519, 1.754, + -0.556, -0.521, 1.911, + -0.281, -0.685, 1.803, + -0.379, -0.345, 1.944, + -0.368, -0.573, 1.685, + -0.499, -0.515, 1.601, + -0.385, -0.394, 1.692, + -0.280, 0.413, 2.091, + -0.441, -0.189, 1.813, + -0.576, -0.272, 1.578, + -0.435, 0.000, 1.712, + -0.322, -0.192, 1.622, + 0.250, 0.375, 2.620, + 0.220, 0.254, 2.699, + 0.360, 0.262, 2.627, + 0.097, 0.358, 2.672, + 0.083, 0.204, 2.595, + -0.054, -0.071, 2.640, + 0.128, -0.255, 2.696, + 0.104, 0.001, 2.516, + 0.300, -0.080, 2.556, + 0.287, -0.201, 2.251, + -0.024, -0.022, 2.232, + 0.206, -0.055, 2.381, + -0.060, 0.460, 1.395, + 0.070, 0.532, 1.449, + 0.067, 0.354, 1.483, + 0.088, 0.460, 1.327, + 0.558, -0.399, 1.674, + 0.630, -0.397, 1.811, + 0.529, -0.508, 1.811, + 0.578, -0.269, 1.578, + 0.487, -0.390, 1.539, + 0.058, -0.757, 1.947, + -0.107, -0.759, 1.985, + -0.004, -0.757, 2.096, + 0.640, 0.409, 2.112, + -0.259, 0.280, 1.797, + -0.250, 0.015, 1.896, + -0.392, 0.348, 1.945, + -0.403, 0.192, 1.706, + -0.712, 0.148, 1.778, + -0.508, 0.098, 1.958, + -0.582, 0.281, 1.594, + -0.378, 0.411, 1.704, + -0.652, 0.283, 1.723, + -0.656, 0.142, 1.636, + 0.309, 0.004, 1.229, + -0.567, 0.138, 1.508, + 0.561, 0.396, 2.340, + 0.656, 0.274, 2.295, + 0.673, 0.406, 2.243, + 0.520, 0.314, 2.188, + 0.394, 0.187, 2.287, + -0.566, 0.398, 2.323, + -0.492, 0.525, 2.391, + -0.477, 0.395, 2.452, + -0.377, 0.393, 2.309, + -0.567, -0.135, 1.513, + -0.286, 0.001, 1.561, + -0.632, 0.000, 1.571, + -0.540, 0.011, 1.449, + -0.656, -0.142, 1.637, + -0.481, -0.396, 2.454, + -0.299, -0.385, 2.397, + -0.390, -0.515, 2.505, + -0.389, -0.260, 2.220, + 0.221, -0.523, 1.490, + 0.207, -0.660, 1.558, + 0.078, -0.585, 1.513, + 0.697, 0.284, 1.868, + 0.446, 0.199, 1.803, + 0.624, 0.409, 1.819, + 0.649, 0.412, 1.967, + -0.653, -0.353, 2.182, + -0.372, -0.392, 2.565, + -0.574, -0.384, 2.325, + -0.549, -0.477, 2.206, + -0.627, -0.414, 2.023, + 0.244, -0.379, 1.393, + -0.159, -0.775, 1.757, + -0.050, -0.800, 1.831, + -0.190, -0.660, 1.671, + -0.035, -0.749, 1.676, + -0.463, -0.136, 2.593, + -0.431, 0.000, 2.634, + -0.241, -0.003, 2.585, + -0.377, -0.002, 2.369, + -0.343, -0.267, 2.619, + -0.338, -0.128, 2.687, + -0.546, 0.000, 2.535, + -0.575, -0.139, 2.484, + 0.652, 0.281, 1.720, + 0.656, 0.140, 1.635, + -0.698, 0.282, 1.879, + -0.645, 0.406, 1.986, + -0.619, 0.408, 1.820, + -0.748, 0.144, 1.938, + -0.714, 0.274, 2.037, + 0.304, 0.088, 1.587, + 0.568, 0.147, 1.502, + 0.574, 0.273, 1.580, + 0.539, 0.002, 1.449, + 0.633, 0.000, 1.570, + 0.657, -0.139, 1.634, + 0.459, -0.188, 1.792, + 0.568, -0.134, 1.505, + 0.311, -0.087, 1.588, + 0.230, -0.186, 1.682, + 0.164, 0.047, 1.788, + -0.057, -0.629, 1.572, + 0.121, -0.110, 1.837, + 0.340, -0.351, 1.867, + 0.324, -0.290, 1.541, + 0.039, 0.230, 1.650, + 0.230, 0.334, 1.675, + 0.661, 0.141, 2.360, + 0.579, 0.274, 2.421, + 0.574, 0.139, 2.486, + 0.719, 0.142, 2.223, + 0.711, 0.000, 2.282, + 0.641, 0.000, 2.417, + -0.349, -0.644, 2.083, + -0.272, -0.392, 2.161, + -0.176, -0.730, 2.110, + 0.663, -0.140, 2.355, + 0.000, 0.000, 2.829, + -0.244, -0.657, 2.257, + 0.600, -0.476, 1.948, + 0.699, -0.420, 2.060, + 0.721, -0.397, 1.928, + 0.559, -0.307, 1.944, + 0.700, 0.277, 2.160, + 0.748, 0.144, 2.080, + 0.752, 0.000, 2.144, + 0.170, 0.149, 1.974, + 0.484, 0.194, 2.013, + 0.330, 0.289, 1.949, + -0.001, 0.000, 1.999, + -0.059, -0.724, 2.238, + -0.144, 0.141, 2.122, + -0.255, 0.275, 2.221, + 0.403, 0.389, 2.601, + 0.481, 0.267, 2.537, + -0.695, -0.283, 1.890, + -0.747, -0.143, 1.937, + -0.507, -0.096, 1.957, + -0.766, 0.000, 2.000, + -0.528, 0.008, 2.094, + -0.702, 0.001, 1.708, + -0.419, 0.000, 1.356, + -0.652, -0.280, 1.724, + -0.717, -0.145, 1.785, + -0.748, 0.000, 1.849, + -0.330, -0.185, 2.370, + -0.317, 0.000, 2.754, + -0.462, 0.133, 2.594, + -0.476, -0.387, 1.537, + -0.358, -0.372, 1.430, + 0.465, 0.262, 1.455, + -0.364, -0.496, 1.527, + -0.093, -0.196, 1.201, + 0.051, -0.224, 1.216, + -0.223, -0.229, 1.244, + -0.713, 0.000, 2.283, + -0.641, 0.000, 2.417, + -0.448, -0.100, 2.247, + 0.421, 0.012, 1.355, + 0.194, 0.343, 1.346, + -0.050, 0.394, 1.663, + 0.548, 0.406, 1.681, + 0.448, 0.509, 1.650, + 0.526, 0.519, 1.795, + 0.458, 0.384, 1.551, + 0.456, -0.128, 1.396, + 0.322, -0.123, 1.314, + 0.341, 0.372, 1.427, + 0.666, -0.283, 1.712, + 0.479, -0.255, 1.455, + 0.720, -0.142, 2.217, + 0.657, -0.278, 2.281, + -0.042, -0.227, 1.940, + 0.421, -0.278, 2.138, + 0.620, 0.536, 1.905, + 0.711, 0.284, 2.019, + -0.203, -0.234, 2.694, + -0.064, -0.458, 2.616, + -0.051, -0.192, 2.741, + 0.159, 0.001, 2.752, + 0.105, 0.268, 2.770, + -0.033, 0.185, 2.743, + 0.076, -0.119, 2.805, + -0.238, 0.263, 1.625, + -0.452, 0.133, 1.399, + -0.471, 0.270, 1.464, + -0.132, 0.155, 1.888, + -0.347, 0.393, 1.445, + -0.474, 0.399, 1.539, + 0.748, -0.291, 1.845, + -0.577, 0.139, 2.481, + -0.478, 0.268, 2.532, + -0.583, 0.275, 2.411, + -0.663, 0.140, 2.356, + -0.091, -0.669, 2.385, + 0.591, -0.267, 2.413, + 0.395, -0.091, 2.329, + 0.492, -0.256, 2.528, + -0.342, -0.124, 1.267, + -0.345, -0.243, 1.360, + 0.086, -0.671, 1.636, + 0.087, -0.727, 1.784, + -0.050, 0.429, 2.626, + 0.216, -0.712, 1.893, + 0.062, -0.492, 1.858, + 0.765, -0.291, 2.000, + -0.552, 0.518, 2.067, + -0.571, 0.509, 1.921, + -0.502, 0.518, 2.234, + -0.632, 0.383, 2.158, + 0.385, -0.103, 2.723, + 0.338, -0.233, 2.632, + 0.470, -0.124, 2.594, + 0.299, 0.012, 2.710, + 0.459, 0.000, 2.676, + -0.152, -0.170, 2.474, + -0.129, -0.144, 2.204, + -0.205, -0.270, 2.266, + 0.064, 0.625, 1.558, + 0.331, 0.501, 1.512, + -0.402, 0.141, 2.092, + -0.410, -0.131, 2.095, + -0.219, 0.490, 1.460, + -0.214, -0.066, 2.063, + -0.169, 0.010, 2.378, + 0.056, 0.143, 2.407, + -0.225, 0.594, 1.583, + -0.201, -0.119, 2.786, + -0.163, 0.000, 2.808, + 0.417, -0.375, 1.400, + -0.038, -0.048, 2.443, + 0.315, 0.093, 2.405, + -0.009, 0.058, 2.627, + 0.765, 0.000, 2.000, + 0.300, 0.581, 2.403, + -0.360, 0.502, 1.548, + 0.046, 0.173, 1.854, + -0.302, 0.000, 1.238, + -0.620, -0.404, 1.826, + 0.700, -0.279, 2.141, + 0.749, -0.142, 2.073, + -0.143, 0.168, 2.483, + -0.224, -0.475, 2.536, + -0.274, -0.574, 2.400, + 0.212, 0.603, 1.490, + 0.320, 0.596, 1.630, + 0.198, 0.688, 1.596, + 0.398, 0.605, 1.780, + -0.077, 0.572, 1.501, + -0.753, 0.135, 2.083, + -0.348, 0.591, 2.315, + -0.220, -0.560, 1.559, + -0.494, 0.533, 1.786, + -0.327, 0.122, 2.683, + -0.350, 0.385, 2.558, + 0.749, -0.141, 1.923, + 0.571, -0.398, 2.328, + 0.544, -0.386, 2.482, + -0.326, 0.181, 2.369, + 0.463, 0.136, 2.594, + -0.456, 0.094, 2.239, + 0.353, -0.459, 1.503, + -0.354, 0.259, 2.627, + 0.208, 0.466, 1.425, + -0.081, -0.563, 2.499, + -0.194, 0.235, 2.701, + -0.482, -0.252, 1.455, + -0.477, -0.277, 2.533, + -0.070, -0.091, 2.822, + -0.560, 0.409, 1.676, + -0.493, 0.531, 1.615, + 0.011, -0.009, 1.154, + 0.072, -0.398, 2.648, + 0.547, 0.000, 2.536, + 0.580, -0.134, 2.483, + -0.362, 0.589, 1.681, +FACES= + 289, 294, 305, 1, + 215, 185, 216, 1, + 274, 248, 249, 1, + 271, 272, 293, 1, + 209, 6, 7, 1, + 243, 242, 214, 1, + 330, 326, 329, 1, + 318, 331, 317, 1, + 14, 201, 13, 1, + 18, 49, 33, 1, + 317, 321, 309, 1, + 45, 46, 21, 1, + 115, 87, 88, 1, + 46, 76, 47, 1, + 320, 326, 327, 1, + 66, 94, 95, 1, + 171, 179, 172, 1, + 280, 255, 254, 1, + 306, 308, 313, 1, + 60, 89, 88, 1, + 69, 68, 97, 1, + 313, 298, 306, 1, + 195, 196, 226, 1, + 264, 287, 265, 1, + 46, 47, 20, 1, + 98, 71, 70, 1, + 156, 137, 138, 1, + 94, 66, 65, 1, + 314, 315, 300, 1, + 180, 177, 178, 1, + 50, 49, 17, 1, + 38, 37, 67, 1, + 45, 75, 46, 1, + 325, 315, 314, 1, + 158, 171, 159, 1, + 5, 6, 210, 1, + 313, 314, 298, 1, + 177, 176, 167, 1, + 24, 25, 42, 1, + 279, 298, 280, 1, + 142, 120, 119, 1, + 163, 164, 147, 1, + 127, 148, 128, 1, + 174, 180, 178, 1, + 21, 46, 20, 1, + 128, 104, 103, 1, + 152, 167, 166, 1, + 274, 249, 275, 1, + 224, 194, 225, 1, + 210, 196, 5, 1, + 176, 179, 171, 1, + 53, 14, 13, 1, + 166, 176, 158, 1, + 59, 58, 9, 1, + 105, 78, 79, 1, + 14, 200, 201, 1, + 230, 229, 256, 1, + 15, 16, 199, 1, + 176, 171, 158, 1, + 43, 42, 72, 1, + 160, 171, 172, 1, + 311, 322, 310, 1, + 67, 68, 38, 1, + 139, 97, 122, 1, + 98, 97, 139, 1, + 122, 144, 139, 1, + 97, 98, 70, 1, + 139, 123, 98, 1, + 123, 99, 98, 1, + 108, 109, 131, 1, + 106, 105, 140, 1, + 130, 131, 151, 1, + 132, 152, 131, 1, + 165, 148, 164, 1, + 162, 145, 161, 1, + 139, 144, 145, 1, + 125, 101, 100, 1, + 172, 180, 173, 1, + 161, 173, 162, 1, + 177, 179, 176, 1, + 164, 163, 174, 1, + 170, 165, 175, 1, + 164, 174, 175, 1, + 175, 165, 164, 1, + 179, 177, 180, 1, + 87, 59, 88, 1, + 156, 155, 137, 1, + 115, 116, 137, 1, + 176, 166, 167, 1, + 163, 162, 173, 1, + 172, 173, 161, 1, + 124, 123, 145, 1, + 107, 130, 105, 1, + 168, 153, 154, 1, + 105, 130, 140, 1, + 151, 166, 150, 1, + 140, 150, 141, 1, + 133, 111, 112, 1, + 152, 157, 167, 1, + 144, 122, 143, 1, + 140, 130, 150, 1, + 130, 151, 150, 1, + 151, 152, 166, 1, + 106, 140, 118, 1, + 160, 159, 171, 1, + 68, 96, 97, 1, + 144, 160, 161, 1, + 145, 123, 139, 1, + 74, 101, 102, 1, + 56, 85, 84, 1, + 113, 112, 85, 1, + 115, 137, 136, 1, + 137, 155, 136, 1, + 128, 129, 104, 1, + 61, 60, 7, 1, + 129, 149, 138, 1, + 128, 148, 149, 1, + 121, 120, 143, 1, + 120, 95, 94, 1, + 121, 95, 120, 1, + 157, 152, 132, 1, + 132, 133, 157, 1, + 127, 102, 126, 1, + 127, 128, 103, 1, + 102, 103, 75, 1, + 102, 127, 103, 1, + 34, 63, 64, 1, + 93, 94, 65, 1, + 119, 120, 94, 1, + 132, 110, 111, 1, + 110, 82, 83, 1, + 132, 111, 133, 1, + 103, 104, 76, 1, + 311, 312, 327, 1, + 193, 22, 21, 1, + 74, 102, 75, 1, + 67, 66, 95, 1, + 63, 91, 92, 1, + 83, 84, 111, 1, + 76, 104, 77, 1, + 76, 75, 103, 1, + 76, 46, 75, 1, + 129, 117, 104, 1, + 287, 286, 303, 1, + 96, 95, 121, 1, + 67, 37, 66, 1, + 28, 185, 29, 1, + 68, 67, 96, 1, + 41, 42, 25, 1, + 96, 121, 122, 1, + 67, 95, 96, 1, + 84, 85, 112, 1, + 57, 58, 86, 1, + 136, 114, 115, 1, + 138, 137, 116, 1, + 87, 58, 59, 1, + 253, 240, 266, 1, + 117, 89, 90, 1, + 78, 105, 106, 1, + 91, 63, 49, 1, + 80, 81, 108, 1, + 16, 50, 17, 1, + 80, 79, 51, 1, + 51, 79, 50, 1, + 16, 51, 50, 1, + 130, 107, 108, 1, + 322, 327, 330, 1, + 99, 123, 124, 1, + 43, 72, 73, 1, + 76, 77, 47, 1, + 43, 44, 23, 1, + 64, 93, 65, 1, + 35, 64, 65, 1, + 34, 64, 35, 1, + 320, 307, 316, 1, + 86, 58, 87, 1, + 84, 83, 55, 1, + 57, 56, 11, 1, + 203, 233, 204, 1, + 87, 114, 86, 1, + 57, 85, 56, 1, + 291, 292, 308, 1, + 64, 92, 93, 1, + 63, 33, 49, 1, + 289, 272, 247, 1, + 41, 40, 70, 1, + 69, 39, 68, 1, + 145, 162, 124, 1, + 6, 62, 61, 1, + 59, 60, 88, 1, + 6, 61, 7, 1, + 257, 258, 231, 1, + 5, 48, 62, 1, + 62, 6, 5, 1, + 77, 104, 90, 1, + 48, 5, 1, 1, + 2, 55, 12, 1, + 83, 82, 54, 1, + 84, 55, 56, 1, + 12, 55, 54, 1, + 54, 55, 83, 1, + 21, 20, 194, 1, + 48, 19, 47, 1, + 43, 73, 44, 1, + 258, 257, 282, 1, + 47, 77, 48, 1, + 68, 39, 38, 1, + 40, 26, 39, 1, + 69, 70, 40, 1, + 41, 4, 40, 1, + 28, 38, 27, 1, + 218, 246, 217, 1, + 38, 39, 27, 1, + 249, 248, 221, 1, + 226, 196, 240, 1, + 61, 89, 60, 1, + 62, 90, 61, 1, + 13, 12, 54, 1, + 13, 202, 12, 1, + 51, 52, 80, 1, + 53, 13, 54, 1, + 34, 33, 63, 1, + 253, 278, 252, 1, + 186, 28, 27, 1, + 322, 311, 327, 1, + 18, 33, 3, 1, + 268, 291, 279, 1, + 229, 255, 256, 1, + 255, 229, 228, 1, + 281, 280, 299, 1, + 255, 280, 281, 1, + 304, 307, 320, 1, + 290, 307, 304, 1, + 303, 290, 287, 1, + 303, 307, 290, 1, + 312, 320, 327, 1, + 238, 237, 264, 1, + 19, 196, 195, 1, + 210, 239, 240, 1, + 20, 47, 19, 1, + 240, 196, 210, 1, + 288, 304, 297, 1, + 328, 321, 317, 1, + 328, 325, 324, 1, + 325, 329, 319, 1, + 330, 323, 322, 1, + 329, 325, 328, 1, + 324, 325, 314, 1, + 313, 308, 321, 1, + 237, 238, 208, 1, + 307, 303, 316, 1, + 315, 319, 302, 1, + 319, 329, 326, 1, + 248, 247, 220, 1, + 328, 331, 329, 1, + 308, 292, 309, 1, + 293, 309, 292, 1, + 312, 297, 304, 1, + 304, 320, 312, 1, + 311, 296, 312, 1, + 77, 90, 62, 1, + 60, 8, 7, 1, + 277, 297, 296, 1, + 276, 277, 296, 1, + 312, 296, 297, 1, + 311, 295, 296, 1, + 168, 178, 177, 1, + 177, 167, 168, 1, + 211, 197, 18, 1, + 34, 35, 31, 1, + 254, 267, 279, 1, + 324, 314, 313, 1, + 291, 308, 306, 1, + 291, 269, 292, 1, + 212, 241, 227, 1, + 305, 293, 289, 1, + 218, 188, 219, 1, + 271, 293, 292, 1, + 293, 272, 289, 1, + 303, 302, 316, 1, + 56, 2, 11, 1, + 259, 258, 283, 1, + 237, 208, 207, 1, + 246, 218, 219, 1, + 303, 286, 302, 1, + 286, 262, 285, 1, + 263, 286, 287, 1, + 250, 276, 275, 1, + 250, 275, 249, 1, + 276, 251, 277, 1, + 295, 275, 276, 1, + 181, 211, 3, 1, + 268, 241, 242, 1, + 242, 269, 268, 1, + 269, 242, 243, 1, + 200, 229, 230, 1, + 231, 232, 202, 1, + 283, 258, 282, 1, + 281, 282, 257, 1, + 44, 22, 23, 1, + 242, 241, 213, 1, + 259, 260, 233, 1, + 284, 261, 260, 1, + 232, 258, 259, 1, + 262, 235, 261, 1, + 233, 232, 259, 1, + 259, 284, 260, 1, + 276, 250, 251, 1, + 225, 195, 226, 1, + 251, 252, 277, 1, + 223, 251, 250, 1, + 297, 277, 278, 1, + 252, 251, 224, 1, + 203, 12, 202, 1, + 204, 233, 234, 1, + 235, 262, 236, 1, + 233, 260, 234, 1, + 253, 226, 240, 1, + 225, 252, 224, 1, + 252, 225, 253, 1, + 266, 240, 239, 1, + 278, 288, 297, 1, + 226, 253, 225, 1, + 214, 215, 243, 1, + 4, 26, 40, 1, + 187, 217, 186, 1, + 216, 185, 186, 1, + 245, 216, 217, 1, + 270, 271, 292, 1, + 8, 9, 207, 1, + 263, 287, 264, 1, + 58, 57, 10, 1, + 10, 206, 9, 1, + 236, 263, 237, 1, + 206, 207, 9, 1, + 6, 209, 210, 1, + 209, 238, 239, 1, + 207, 236, 237, 1, + 228, 229, 199, 1, + 231, 230, 257, 1, + 219, 188, 189, 1, + 64, 63, 92, 1, + 289, 247, 273, 1, + 43, 23, 24, 1, + 208, 209, 7, 1, + 265, 287, 290, 1, + 265, 266, 239, 1, + 210, 209, 239, 1, + 208, 238, 209, 1, + 200, 199, 229, 1, + 203, 232, 233, 1, + 202, 13, 201, 1, + 1, 196, 19, 1, + 231, 258, 232, 1, + 231, 202, 201, 1, + 194, 193, 21, 1, + 194, 20, 195, 1, + 193, 194, 224, 1, + 251, 223, 224, 1, + 22, 45, 21, 1, + 250, 249, 222, 1, + 216, 186, 217, 1, + 27, 187, 186, 1, + 31, 35, 30, 1, + 28, 186, 185, 1, + 35, 65, 36, 1, + 8, 208, 7, 1, + 59, 9, 8, 1, + 207, 208, 8, 1, + 53, 52, 14, 1, + 200, 14, 15, 1, + 200, 15, 199, 1, + 201, 230, 231, 1, + 192, 191, 23, 1, + 223, 192, 193, 1, + 45, 22, 44, 1, + 192, 23, 22, 1, + 189, 190, 220, 1, + 221, 222, 249, 1, + 184, 214, 183, 1, + 214, 242, 213, 1, + 29, 184, 30, 1, + 184, 183, 30, 1, + 244, 245, 271, 1, + 236, 207, 206, 1, + 82, 110, 109, 1, + 205, 206, 10, 1, + 79, 78, 50, 1, + 17, 198, 16, 1, + 190, 189, 25, 1, + 24, 23, 191, 1, + 190, 24, 191, 1, + 189, 188, 4, 1, + 42, 43, 24, 1, + 221, 190, 191, 1, + 25, 24, 190, 1, + 222, 221, 191, 1, + 214, 213, 183, 1, + 183, 31, 30, 1, + 228, 227, 254, 1, + 182, 31, 183, 1, + 32, 31, 182, 1, + 158, 150, 166, 1, + 121, 143, 122, 1, + 97, 96, 122, 1, + 174, 173, 180, 1, + 180, 172, 179, 1, + 144, 143, 160, 1, + 161, 160, 172, 1, + 158, 141, 150, 1, + 141, 142, 119, 1, + 119, 118, 141, 1, + 141, 118, 140, 1, + 115, 114, 87, 1, + 136, 154, 135, 1, + 135, 154, 153, 1, + 135, 114, 136, 1, + 82, 81, 53, 1, + 152, 151, 131, 1, + 110, 132, 109, 1, + 100, 73, 72, 1, + 101, 74, 73, 1, + 146, 163, 147, 1, + 126, 146, 147, 1, + 101, 126, 102, 1, + 118, 93, 92, 1, + 157, 168, 167, 1, + 52, 81, 80, 1, + 53, 54, 82, 1, + 66, 36, 65, 1, + 35, 36, 30, 1, + 36, 66, 37, 1, + 9, 58, 10, 1, + 153, 133, 134, 1, + 91, 49, 78, 1, + 106, 92, 91, 1, + 71, 41, 70, 1, + 100, 72, 99, 1, + 41, 71, 42, 1, + 32, 34, 31, 1, + 33, 34, 32, 1, + 39, 69, 40, 1, + 97, 70, 69, 1, + 74, 75, 45, 1, + 74, 44, 73, 1, + 38, 28, 37, 1, + 36, 37, 29, 1, + 36, 29, 30, 1, + 60, 59, 8, 1, + 14, 52, 15, 1, + 193, 192, 22, 1, + 28, 29, 37, 1, + 29, 185, 184, 1, + 309, 293, 305, 1, + 295, 276, 296, 1, + 273, 274, 294, 1, + 318, 305, 294, 1, + 331, 328, 317, 1, + 317, 305, 318, 1, + 3, 32, 181, 1, + 49, 18, 17, 1, + 32, 182, 181, 1, + 211, 18, 3, 1, + 198, 197, 228, 1, + 309, 321, 308, 1, + 328, 324, 321, 1, + 313, 321, 324, 1, + 316, 302, 319, 1, + 301, 315, 302, 1, + 319, 315, 325, 1, + 316, 319, 326, 1, + 294, 295, 310, 1, + 294, 289, 273, 1, + 288, 290, 304, 1, + 278, 253, 266, 1, + 288, 278, 266, 1, + 252, 278, 277, 1, + 245, 272, 271, 1, + 216, 245, 244, 1, + 168, 169, 178, 1, + 302, 285, 301, 1, + 285, 261, 284, 1, + 234, 260, 261, 1, + 274, 275, 295, 1, + 288, 265, 290, 1, + 266, 265, 288, 1, + 282, 281, 299, 1, + 281, 256, 255, 1, + 256, 281, 257, 1, + 219, 220, 247, 1, + 223, 250, 222, 1, + 223, 193, 224, 1, + 182, 183, 213, 1, + 241, 212, 213, 1, + 184, 185, 215, 1, + 184, 215, 214, 1, + 244, 243, 215, 1, + 244, 215, 216, 1, + 235, 236, 206, 1, + 199, 16, 198, 1, + 213, 212, 182, 1, + 211, 212, 227, 1, + 218, 187, 188, 1, + 25, 189, 4, 1, + 27, 26, 187, 1, + 238, 265, 239, 1, + 200, 230, 201, 1, + 230, 256, 257, 1, + 199, 198, 228, 1, + 11, 204, 205, 1, + 203, 2, 12, 1, + 206, 205, 235, 1, + 197, 17, 18, 1, + 197, 211, 227, 1, + 25, 4, 41, 1, + 26, 4, 188, 1, + 182, 212, 181, 1, + 181, 212, 211, 1, + 127, 126, 147, 1, + 148, 147, 164, 1, + 125, 126, 101, 1, + 116, 115, 88, 1, + 116, 117, 138, 1, + 104, 117, 90, 1, + 61, 90, 89, 1, + 142, 158, 159, 1, + 159, 160, 143, 1, + 128, 149, 129, 1, + 149, 148, 165, 1, + 117, 129, 138, 1, + 149, 165, 156, 1, + 156, 138, 149, 1, + 170, 175, 169, 1, + 114, 113, 86, 1, + 134, 112, 113, 1, + 157, 133, 153, 1, + 107, 79, 80, 1, + 109, 132, 131, 1, + 130, 108, 131, 1, + 73, 100, 101, 1, + 125, 124, 146, 1, + 126, 125, 146, 1, + 125, 100, 124, 1, + 89, 116, 88, 1, + 117, 116, 89, 1, + 109, 108, 81, 1, + 81, 52, 53, 1, + 134, 133, 112, 1, + 45, 44, 74, 1, + 299, 298, 314, 1, + 112, 111, 84, 1, + 318, 294, 310, 1, + 295, 311, 310, 1, + 323, 331, 318, 1, + 326, 330, 327, 1, + 320, 316, 326, 1, + 318, 310, 323, 1, + 331, 330, 329, 1, + 331, 323, 330, 1, + 299, 314, 300, 1, + 3, 33, 32, 1, + 283, 284, 259, 1, + 300, 283, 282, 1, + 315, 301, 300, 1, + 300, 301, 283, 1, + 285, 284, 301, 1, + 270, 269, 243, 1, + 270, 243, 244, 1, + 248, 274, 273, 1, + 272, 246, 247, 1, + 248, 273, 247, 1, + 264, 265, 238, 1, + 262, 263, 236, 1, + 227, 267, 254, 1, + 268, 267, 241, 1, + 191, 192, 222, 1, + 222, 192, 223, 1, + 225, 194, 195, 1, + 19, 195, 20, 1, + 187, 218, 217, 1, + 56, 55, 2, 1, + 203, 202, 232, 1, + 205, 204, 234, 1, + 2, 204, 11, 1, + 5, 196, 1, 1, + 27, 39, 26, 1, + 26, 188, 187, 1, + 156, 165, 170, 1, + 154, 136, 155, 1, + 77, 62, 48, 1, + 147, 148, 127, 1, + 170, 155, 156, 1, + 170, 169, 155, 1, + 107, 105, 79, 1, + 80, 108, 107, 1, + 98, 99, 71, 1, + 305, 317, 309, 1, + 262, 286, 263, 1, + 264, 237, 263, 1, + 246, 219, 247, 1, + 246, 272, 245, 1, + 300, 282, 299, 1, + 2, 203, 204, 1, + 168, 157, 153, 1, + 153, 134, 135, 1, + 93, 119, 94, 1, + 10, 57, 11, 1, + 163, 146, 162, 1, + 163, 173, 174, 1, + 72, 42, 71, 1, + 161, 145, 144, 1, + 10, 11, 205, 1, + 85, 57, 86, 1, + 302, 286, 285, 1, + 174, 178, 175, 1, + 146, 124, 162, 1, + 100, 99, 124, 1, + 72, 71, 99, 1, + 221, 220, 190, 1, + 262, 261, 285, 1, + 234, 235, 205, 1, + 1, 19, 48, 1, + 135, 113, 114, 1, + 85, 86, 113, 1, + 169, 154, 155, 1, + 169, 175, 178, 1, + 217, 246, 245, 1, + 221, 248, 220, 1, + 294, 274, 295, 1, + 244, 271, 270, 1, + 268, 269, 291, 1, + 234, 261, 235, 1, + 291, 306, 279, 1, + 310, 322, 323, 1, + 143, 120, 142, 1, + 220, 219, 189, 1, + 299, 280, 298, 1, + 269, 270, 292, 1, + 227, 241, 267, 1, + 306, 298, 279, 1, + 15, 52, 51, 1, + 16, 15, 51, 1, + 83, 111, 110, 1, + 301, 284, 283, 1, + 268, 279, 267, 1, + 280, 254, 279, 1, + 228, 254, 255, 1, + 82, 109, 81, 1, + 227, 228, 197, 1, + 49, 50, 78, 1, + 198, 17, 197, 1, + 91, 78, 106, 1, + 92, 106, 118, 1, + 118, 119, 93, 1, + 169, 168, 154, 1, + 135, 134, 113, 1, + 159, 143, 142, 1, + 141, 158, 142, 1, +/ + +&TAIL / diff --git a/tests/cases/fds_inputs/steckler.fds b/tests/cases/fds_inputs/steckler.fds new file mode 100644 index 00000000..872caaae --- /dev/null +++ b/tests/cases/fds_inputs/steckler.fds @@ -0,0 +1,40 @@ +&HEAD CHID='out_Steckler_013', TITLE='Steckler Compartment, Test 013'/ + +## Computational domain, simulation time and ambient values +&MESH IJK=18,14,11, XB=0.00,3.60,-1.40,1.40,0.00,2.20 / +&TIME T_END=1800.0, TIME_SHRINK_FACTOR=10./ +&MISC TMPA=20.0 / + +## Fire definition +&REAC FUEL='METHANE', SOOT_YIELD=0. / +&SURF ID='BURNER', HRRPUA=1572.0, TMP_FRONT=100.0, COLOR='ORANGE' / + +## Geometry, here a wall with a hole +&OBST XB=2.80,2.90, -1.40,1.40, 0.00,2.20 / Wall with door +&HOLE XB=2.75,2.95, -0.30,0.30, 0.00,1.83 / Door opening + +## Burner surface +&VENT XB=1.30,1.50, -0.10,0.10, 0.00,0.00, SURF_ID='BURNER' / + +## Domain boundary conditions, here open, but in the compartment +&VENT MB='XMAX',SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60, -1.40,-1.40, 0.00,2.20, SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60, 1.40, 1.40, 0.00,2.20, SURF_ID='OPEN'/ +&VENT XB= 2.90,3.60, -1.40, 1.40, 2.20,2.20, SURF_ID='OPEN'/ + +## Boundary conditions of the walls, default for all walls +&SURF ID='FIBER BOARD', DEFAULT=.TRUE., MATL_ID='INSULATION', THICKNESS=0.013 / +&MATL ID = 'INSULATION' + DENSITY = 200. + CONDUCTIVITY = 0.1 + SPECIFIC_HEAT = 1. / + +## Output definition +&DEVC ID='TC_Room', POINTS=44, XB=2.50,2.50,1.10,1.10,0.02,2.11, QUANTITY='TEMPERATURE', Z_ID='Room_z' / +&DEVC ID='TC_Door', POINTS=38, XB=2.85,2.85,0.00,0.00,0.02,1.82, QUANTITY='TEMPERATURE', Z_ID='Door_z' / +&DEVC ID='BP_Door', POINTS=38, XB=2.85,2.85,0.00,0.00,0.02,1.82, QUANTITY='VELOCITY', VELO_INDEX=1, HIDE_COORDINATES=.TRUE. / + +&DEVC ID='TC_Door_Single', XB=2.85,2.85,0.00,0.00,1.62,1.62, QUANTITY='TEMPERATURE'/ +&DEVC ID='BP_Door_Single', XB=2.85,2.85,0.00,0.00,1.62,1.62, QUANTITY='VELOCITY'/ + +&TAIL / \ No newline at end of file diff --git a/tests/cases/fds_inputs/vort3d_80.fds b/tests/cases/fds_inputs/vort3d_80.fds new file mode 100644 index 00000000..d7d64249 --- /dev/null +++ b/tests/cases/fds_inputs/vort3d_80.fds @@ -0,0 +1,49 @@ +&HEAD CHID='vort3d_80', TITLE='2D Vortex Simulation, Mesh Size = 80' / + +&TIME T_END=50.0/ + +&MESH ID='Mesh1', IJK=24,36,12, XB=-1.0, 5.0,-1.0,8.0,0.0,3.0/ +&MESH ID='Mesh2', IJK=24,36,12, XB= 5.0,11.0,-1.0,8.0,0.0,3.0/ + + +&REAC ID='POLYURETHANE', + FYI='NFPA Babrauskas', + FUEL='REAC_FUEL', + C=6.3, + H=7.1, + O=2.1, + N=1.0, + SOOT_YIELD=0.1/ + +&SURF ID='FIRE', + COLOR='RED', + HRRPUA=50.0/ + +&OBST XB=5.0,5.5,5.5,7.5,0.0,2.5, RGB=255,255,255, TRANSPARENCY=0.407843, SURF_ID='INERT'/ AcDb3dSolid - 14AD +&OBST XB=-0.5,0.0,7.0,7.5,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 13D9 +&OBST XB=-0.5,10.5,7.5,8.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 13D9 +&OBST XB=10.0,10.5,7.0,7.5,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 13D9 +&OBST XB=-0.5,0.0,0.0,5.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=-0.5,0.0,5.5,6.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=-0.5,1.5,5.0,5.5,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=-0.5,10.5,-0.5,0.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=8.5,10.5,5.0,5.5,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=10.0,10.5,0.0,5.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=10.0,10.5,5.5,6.0,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16F9 +&OBST XB=2.5,7.5,5.0,5.5,0.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 16FE +&OBST XB=1.5,2.5,5.0,5.5,2.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 1445 +&OBST XB=7.5,8.5,5.0,5.5,2.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 1452 +&OBST XB=-0.5,0.0,6.0,7.0,2.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 1456 +&OBST XB=10.0,10.5,6.0,7.0,2.0,2.5, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 1463 +&OBST XB=-0.5,10.5,-0.5,8.0,2.5,2.75, RGB=91,91,91, SURF_ID='INERT'/ AcDb3dSolid - 1469 + +&VENT SURF_ID='FIRE', XB=9.5,10.0,6.0,6.5,0.0,0.0/ Vent +&VENT SURF_ID='OPEN', XB=11.0,11.0,-1.0,8.0,0.0,3.0/ Mesh Vent: Mesh1 [XMAX] +&VENT SURF_ID='OPEN', XB=-1.0,-1.0,-1.0,8.0,0.0,3.0/ Mesh Vent: Mesh1 [XMIN] +&VENT SURF_ID='OPEN', XB=-1.0,11.0,8.0,8.0,0.0,3.0/ Mesh Vent: Mesh1 [YMAX] +&VENT SURF_ID='OPEN', XB=-1.0,11.0,-1.0,-1.0,0.0,3.0/ Mesh Vent: Mesh1 [YMIN] +&VENT SURF_ID='OPEN', XB=-1.0,11.0,-1.0,8.0,3.0,3.0 XYZ=5.0,4.5,3.0, RADIUS=0.5/ Mesh Vent: Mesh1 [ZMAX] + +&DUMP DT_PL3D=10, PLOT3D_QUANTITY(1:5)='TEMPERATURE', 'U-VELOCITY','V-VELOCITY','W-VELOCITY','HRRPUV' / + +&TAIL / \ No newline at end of file diff --git a/tests/cases/fds_inputs/wall_005.fds b/tests/cases/fds_inputs/wall_005.fds new file mode 100644 index 00000000..d60aa8f0 --- /dev/null +++ b/tests/cases/fds_inputs/wall_005.fds @@ -0,0 +1,108 @@ +&HEAD CHID='wall_test' / + +&TIME T_END=20.0, TIME_SHRINK_FACTOR=10./ + +&MULT ID='mesh', DX=1, DY=0.8, I_UPPER=3, J_UPPER=5,/ + +&MESH IJK= 20,16,72 XB=-1.5,-0.5,-2,-1.2,-0.1000,3.5, MULT_ID='mesh' / + +&DUMP RENDER_FILE='Scene.ge1' + STATUS_FILES=.TRUE. + SMOKE3D=.FALSE. / + +/&MISC RESTART=.TRUE./ + +&VENT MB='XMIN', SURF_ID='OPEN'/ +&VENT MB='XMAX', SURF_ID='OPEN'/ +&VENT MB='YMIN', SURF_ID='OPEN'/ +&VENT MB='YMAX', SURF_ID='OPEN'/ +&VENT MB='ZMIN', SURF_ID='OPEN'/ +&VENT MB='ZMAX', SURF_ID='OPEN'/ + + +&SURF ID='wall', MATL_ID='Stahlbeton',THICKNESS=0.08, BACKING="EXPOSED"/ +&MATL ID = 'Stahlbeton', CONDUCTIVITY = 2.3, SPECIFIC_HEAT = 1, DENSITY = 2300./ +&SURF ID='Boden', MATL_ID='Stahlbeton',THICKNESS=0.2, BACKING="EXPOSED"/ + +/Boden +&OBST ID='Boden' XB=-1,2,-1.2,2.4,-0.1,0, SURF_ID='Boden'/ + +/Decke +&OBST ID='Decke' XB=-1,2,-1.2,2.4,3,3.2, SURF_ID='Boden'/ + +/Wand rechts +&OBST ID='Wand_r' XB=-0.85,-0.8,-1,2.4,0,3, SURF_ID='wall'/ + +/Wand vorne +&OBST ID='Wand_v' XB=-1,2,-1.2,-1,0,3, SURF_ID='wall'/ + +&OBST ID='Golf_brandfläche' XB= -0.3,1.1,0.4,1.7,0.4,0.45 / + +/Brand +&SPEC ID='PUR', LUMPED_COMPONENT_ONLY=.FALSE., FORMULA='C4.837353H7.917138N0.427652O1.745690' / +&SPEC ID='OXYGEN', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C0.000H0.000O2.000N0.000Cl0.000' / +&SPEC ID='HCN', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C1.000H1.000O0.000N1.000Cl0.000' / +&SPEC ID='AMMONIA', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C0.000H3.000O0.000N1.000Cl0.000' / +&SPEC ID='CARBON MONOXIDE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C1.000H0.000O1.000N0.000Cl0.000' / +&SPEC ID='CARBON DIOXIDE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C1.000H0.000O2.000N0.000Cl0.000' / +&SPEC ID='WATER VAPOR', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C0.000H2.000O1.000N0.000Cl0.000' / +&SPEC ID='NITROGEN', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C0.000H0.000O0.000N2.000Cl0.000' / +&SPEC ID='SOOT', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C1.000H0.000O0.000N0.000Cl0.000' / +&SPEC ID='BENZENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C6.000H6.000O0.000N0.000Cl0.000' / +&SPEC ID='BENZONITRILE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C7.000H5.000O0.000N1.000Cl0.000' / +&SPEC ID='BIPHENYL', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C12.000H10.000O0.000N0.000Cl0.000' / +&SPEC ID='INDENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C9.000H8.000O0.000N0.000Cl0.000' / +&SPEC ID='ISOQUINOLINE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C9.000H7.000O0.000N1.000Cl0.000' / +&SPEC ID='NAPHTHALENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C10.000H8.000O0.000N0.000Cl0.000' / +&SPEC ID='NAPHTHALENE-1-CARBONITRILE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C11.000H7.000O0.000N1.000Cl0.000' / +&SPEC ID='STYRENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C8.000H8.000O0.000N0.000Cl0.000' / +&SPEC ID='TOLUENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C7.000H8.000O0.000N0.000Cl0.000' / +&SPEC ID='XYLENE', LUMPED_COMPONENT_ONLY=.TRUE., FORMULA='C8.000H10.000O0.000N0.000Cl0.000' / + + +&SPEC ID = 'AIR', + SPEC_ID(1) = 'OXYGEN', VOLUME_FRACTION(1) = 1.000, + SPEC_ID(2) = 'NITROGEN', VOLUME_FRACTION(2) = 3.760, + BACKGROUND=.TRUE. / + +&SPEC ID='PRODUCTS', + SPEC_ID(1)='HCN', VOLUME_FRACTION(1)=0.038951, + SPEC_ID(2)='AMMONIA', VOLUME_FRACTION(2)=0.091211, + SPEC_ID(3)='CARBON MONOXIDE', VOLUME_FRACTION(3)=1.789193, + SPEC_ID(4)='CARBON DIOXIDE', VOLUME_FRACTION(4)=2.194841, + SPEC_ID(5)='WATER VAPOR', VOLUME_FRACTION(5)=3.653950, + SPEC_ID(6)='NITROGEN', VOLUME_FRACTION(6)=15.346936, + SPEC_ID(7)='SOOT', VOLUME_FRACTION(7)=0.483967, + SPEC_ID(8)='BENZENE', VOLUME_FRACTION(8)=0.022844, + SPEC_ID(9)='BENZONITRILE', VOLUME_FRACTION(9)=0.008317, + SPEC_ID(10)='BIPHENYL', VOLUME_FRACTION(10)=0.000334, + SPEC_ID(11)='INDENE', VOLUME_FRACTION(11)=0.001073, + SPEC_ID(12)='ISOQUINOLINE', VOLUME_FRACTION(12)=0.001932, + SPEC_ID(13)='NAPHTHALENE', VOLUME_FRACTION(13)=0.005348, + SPEC_ID(14)='NAPHTHALENE-1-CARBONITRILE', VOLUME_FRACTION(14)=0.000995, + SPEC_ID(15)='STYRENE', VOLUME_FRACTION(15)=0.002278, + SPEC_ID(16)='TOLUENE', VOLUME_FRACTION(16)=0.002920, + SPEC_ID(17)='XYLENE', VOLUME_FRACTION(17)=0.000123/ + + +&VENT XB=-0.3,1.1,0.4,1.7,0.45,0.45 SURF_ID='Brandfläche'/ +&REAC ID='Brandfläche', FUEL='PUR', SPEC_ID_NU = 'PUR','AIR','PRODUCTS', NU= -1,-4.043567,1., HEAT_OF_COMBUSTION=40000 / +&SURF ID='Brandfläche',SPEC_ID='PUR', HRRPUA=2823.129, COLOR='RED', RAMP_Q='fire_ramp'/ + +/Vorgabe der Brandkurve + +&RAMP ID='fire_ramp', T=0, F=0.0/ +&RAMP ID='fire_ramp', T=240, F=0.1687/ +&RAMP ID='fire_ramp', T=960, F=0.1687/ +&RAMP ID='fire_ramp', T=1440, F=0.6627/ +&RAMP ID='fire_ramp', T=1500, F=1.0/ +&RAMP ID='fire_ramp', T=1620, F=0.5422/ +&RAMP ID='fire_ramp', T=2280, F=0.1205/ +&RAMP ID='fire_ramp', T=4200, F=0/ + +/Wall Temperature + +&BNDF QUANTITY='WALL TEMPERATURE', CELL_CENTERED=.TRUE./ +&BNDF QUANTITY='BACK WALL TEMPERATURE', CELL_CENTERED=.TRUE./ + +&TAIL / diff --git a/tests/cases/fds_inputs/water_droplets.fds b/tests/cases/fds_inputs/water_droplets.fds new file mode 100644 index 00000000..a67d35ba --- /dev/null +++ b/tests/cases/fds_inputs/water_droplets.fds @@ -0,0 +1,44 @@ +1 L water droplets cascading down over a stack of boxes. +The purpose of this case is to test functionality of the droplet routine. + +&HEAD CHID='cascade', TITLE='Water droplets cascading over a stack of boxes' / + +&MESH IJK=16,16,56, XB=-2.0,2.0,-2.0,2.0,0.0,14.0 / + +&TIME T_END=1.0 / + +&MISC HUMIDITY=0. / + +! ALLOW_UNDERSIDE_PARTICLES moved from MISC to SURF in FDS 6.9+ +&SURF ID='BOX', COLOR='TAN', ALLOW_UNDERSIDE_PARTICLES=.TRUE. / + +&OBST XB=-1.5, 1.5,-1.5, 1.5, 1.0, 3.0, SURF_ID='BOX' / +&OBST XB=-1.5, 1.5,-1.5, 1.5, 4.0, 6.0, SURF_ID='BOX' / +&OBST XB=-1.5, 1.5,-1.5, 1.5, 7.0, 9.0, SURF_ID='BOX' / + + VENT MB='XMIN', SURF_ID='OPEN' / + VENT MB='XMAX', SURF_ID='OPEN' / + VENT MB='YMIN', SURF_ID='OPEN' / + VENT MB='YMAX', SURF_ID='OPEN' / + +&SPEC ID='WATER VAPOR'/ +&PART ID='WATER PARTICLES', SPEC_ID='WATER VAPOR', DIAMETER=1000., SAMPLING_FACTOR=1, QUANTITIES='PARTICLE DIAMETER','PARTICLE VELOCITY' / + +&PROP ID='nozzle', PART_ID='WATER PARTICLES', PARTICLE_VELOCITY=2.0, FLOW_RATE=6., FLOW_RAMP='flow', SPRAY_ANGLE=30.,40. / +&RAMP ID='flow', T= 0., F=0. / +&RAMP ID='flow', T= 2., F=1. / +&RAMP ID='flow', T=10., F=1. / +&RAMP ID='flow', T=12., F=0. / + +&DEVC ID='N-1', XYZ=0.0,0.0,13.7, PROP_ID='nozzle', QUANTITY='TIME', SETPOINT=0.0 / + +&DUMP DT_DEVC=1. / + +&DEVC ID='water liquid', QUANTITY='AMPUA', PART_ID='WATER PARTICLES', SPATIAL_STATISTIC='SURFACE INTEGRAL', XB=-2,2,-2,2,0,1 / +&DEVC ID='water vapor', QUANTITY='DENSITY', SPEC_ID='WATER VAPOR', SPATIAL_STATISTIC='VOLUME INTEGRAL', XB=-2,2,-2,2,0,14 / +&DEVC ID='water mass', QUANTITY='CONTROL VALUE', CTRL_ID='Add', XYZ=1,1,1, UNITS='kg' / +&CTRL ID='Add', FUNCTION_TYPE='SUM', INPUT_ID='water liquid','water vapor' / + +&RADI RADIATION=.FALSE./ + +&TAIL / \ No newline at end of file From 9c1a4c19796cc81d8f5adec327864fe6d530d2fb Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 09:59:53 +0200 Subject: [PATCH 3/7] test: add FDS 6.10.1 test data for all output types Regenerated test cases (steckler, bndf, devc, part, pl3d) using FDS 6.10.1 to verify fdsreader compatibility with the current FDS release. All output types read correctly except geometry (geom_data) which is affected by a breaking change in the FDS 6.10.1 SMV format: the BGEOM block referencing .gbf files was removed. This will be tracked as a separate issue. Also fixes water_droplets.fds for FDS 6.10+: ALLOW_UNDERSIDE_PARTICLES was moved from &MISC to &SURF in FDS 6.9. --- tests/cases/bndf_data_fds6100.tgz | 3 +++ tests/cases/devc_data_fds6100.tgz | 3 +++ tests/cases/part_data_fds6100.tgz | 3 +++ tests/cases/pl3d_data_fds6100.tgz | 3 +++ tests/cases/steckler_data_fds6100.tgz | 3 +++ 5 files changed, 15 insertions(+) create mode 100644 tests/cases/bndf_data_fds6100.tgz create mode 100644 tests/cases/devc_data_fds6100.tgz create mode 100644 tests/cases/part_data_fds6100.tgz create mode 100644 tests/cases/pl3d_data_fds6100.tgz create mode 100644 tests/cases/steckler_data_fds6100.tgz diff --git a/tests/cases/bndf_data_fds6100.tgz b/tests/cases/bndf_data_fds6100.tgz new file mode 100644 index 00000000..60144f29 --- /dev/null +++ b/tests/cases/bndf_data_fds6100.tgz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:721eab1c864cda3c5e69daaa6f570c50eb448b59085e8e71b532cb9ef2d980bb +size 1442952 diff --git a/tests/cases/devc_data_fds6100.tgz b/tests/cases/devc_data_fds6100.tgz new file mode 100644 index 00000000..2724220e --- /dev/null +++ b/tests/cases/devc_data_fds6100.tgz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:127c76909fcd92a9b699086e3498e33bf42706a899d784f5370df740853ec4cb +size 990919 diff --git a/tests/cases/part_data_fds6100.tgz b/tests/cases/part_data_fds6100.tgz new file mode 100644 index 00000000..3ff38aee --- /dev/null +++ b/tests/cases/part_data_fds6100.tgz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41fa495353518854808b29f3baacd5f27f0424f9611c42feccbe16f207f79cc9 +size 390183 diff --git a/tests/cases/pl3d_data_fds6100.tgz b/tests/cases/pl3d_data_fds6100.tgz new file mode 100644 index 00000000..0239ffef --- /dev/null +++ b/tests/cases/pl3d_data_fds6100.tgz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92d5dd73c2e417956cd38267c4f604b71a93a55e2d7f34334b6968f59b1d77f9 +size 2693493 diff --git a/tests/cases/steckler_data_fds6100.tgz b/tests/cases/steckler_data_fds6100.tgz new file mode 100644 index 00000000..a7413d1e --- /dev/null +++ b/tests/cases/steckler_data_fds6100.tgz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cef0948df6b7b702f53f3ecf66cef72f4ecac9970bfabb3209ac4727a17c5ee +size 12443429 From 51cf11ac86a348b2484d12be274a1710b049e90c Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 10:07:32 +0200 Subject: [PATCH 4/7] fix: use editable install in CI so coverage can track source files --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ce405e..8420d4ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies - run: pip install ".[dev]" + run: pip install -e ".[dev]" - name: Prepare test cases run: cd tests/cases && for f in *.tgz; do tar -xzvf "$f"; done - name: Run tests From 75ed46d6285dab9c781bd8e390b1d20eb5bb3cba Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 10:26:51 +0200 Subject: [PATCH 5/7] chore: add CONTRIBUTING.md, codecov config, update .gitignore - Add CONTRIBUTING.md: development setup, test instructions, ruff usage, how to regenerate test data, known critical bugs as good-first-issues - Add codecov.yml: 50% project coverage target, 40% patch target - Update .gitignore: ignore extracted test case directories, pickle cache files, ruff cache, and .claude/ internal directory - Stage deletion of requirements.txt (replaced by requirements-dev.txt) --- .gitignore | 27 +++++++- CONTRIBUTING.md | 99 ++++++++++++++++++++++++++++ codecov.yml | 13 ++++ requirements.txt | 163 ----------------------------------------------- 4 files changed, 138 insertions(+), 164 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 codecov.yml delete mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 13d73043..77637714 100644 --- a/.gitignore +++ b/.gitignore @@ -508,4 +508,29 @@ examples/ !examples/**/*.fds /docs/build/ -Dockerfile \ No newline at end of file +Dockerfile + +# fdsreader specific +# Extracted test case directories (only .tgz archives are tracked) +tests/cases/steckler_data/ +tests/cases/bndf_data/ +tests/cases/devc_data/ +tests/cases/geom_data/ +tests/cases/part_data/ +tests/cases/pl3d_data/ +tests/cases/steckler_data_fds*/ +tests/cases/bndf_data_fds*/ +tests/cases/devc_data_fds*/ +tests/cases/geom_data_fds*/ +tests/cases/geom_data_new/ +tests/cases/part_data_fds*/ +tests/cases/pl3d_data_fds*/ + +# Pickle cache files +*.pickle + +# Ruff cache +.ruff_cache/ + +# Claude Code internal directory +.claude/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5bb53b94 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing to fdsreader + +Thank you for your interest in contributing! This guide explains how to set up +your development environment and what to expect from the contribution process. + +## Development setup + +```bash +# 1. Fork and clone the repository +git clone https://github.com/FireDynamics/fdsreader.git +cd fdsreader + +# 2. Install in editable mode with dev dependencies +pip install -e ".[dev]" + +# 3. Set up pre-commit hooks (runs ruff automatically before each commit) +pre-commit install + +# 4. Extract test data +cd tests/cases && for f in *.tgz; do tar -xzvf "$f"; done && cd ../.. +``` + +## Running tests + +```bash +# Run all tests +pytest tests/ + +# With coverage report +pytest tests/ --cov=fdsreader --cov-report=term-missing +``` + +All 46 tests must pass before opening a pull request. + +## Code style + +This project uses [ruff](https://docs.astral.sh/ruff/) for linting and formatting. + +```bash +# Check for issues +ruff check fdsreader/ + +# Auto-fix issues +ruff check fdsreader/ --fix + +# Format code +ruff format fdsreader/ +``` + +The CI will fail if ruff reports any errors. If you have pre-commit installed, +ruff runs automatically on every commit. + +## Regenerating test data + +Test data archives (`.tgz`) are generated with specific FDS versions and stored +in `tests/cases/`. The corresponding FDS input files (`.fds`) are in +`tests/cases/fds_inputs/` and can be used to regenerate the data. + +```bash +FDS=/path/to/fds_openmp +BASE=tests/cases + +mkdir -p $BASE/steckler_data_fds +cd $BASE/steckler_data_fds +$FDS $BASE/fds_inputs/input_steckler.fds + +# Repeat for other cases, then archive: +cd $BASE +tar -czf steckler_data_fds.tgz steckler_data_fds/ +``` + +See the [FDS version compatibility table](README.md#fds-version-compatibility) +for which versions have been tested. + +## Open issues and known bugs + +Before starting work please check the +[issue tracker](https://github.com/FireDynamics/fdsreader/issues) for known bugs. + +### Critical bugs (good first issues) + +| File | Line | Bug | +|------|------|-----| +| `fdsreader/utils/extent.py` | 14 | `ValueError` is created but never raised → silent data corruption | +| `fdsreader/utils/misc.py` | 19 | `log_error` decorator returns `None` when an exception is caught | +| `fdsreader/utils/data.py` | 66 | `open()` without context manager → potential file handle leak | + +### FDS 6.10.1 compatibility + +Geometry data (`geom_data`) cannot be read from FDS 6.10.1 outputs because +the `BGEOM` block was removed from the SMV format. A fix requires updating +`simulation.py` to handle the new format. + +## Pull request checklist + +- [ ] Tests pass (`pytest tests/`) +- [ ] No ruff errors (`ruff check fdsreader/`) +- [ ] New features include tests +- [ ] Commit messages follow the existing style (see `git log --oneline`) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..739051ac --- /dev/null +++ b/codecov.yml @@ -0,0 +1,13 @@ +coverage: + status: + project: + default: + target: 50% + threshold: 2% + patch: + default: + target: 40% + +comment: + layout: "reach,diff,flags,files" + behavior: default diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 61de388d..00000000 --- a/requirements.txt +++ /dev/null @@ -1,163 +0,0 @@ -aiohttp==3.9.1 -aiosignal==1.3.1 -alabaster==0.7.13 -appdirs==1.4.4 -argcomplete==3.1.6 -argon2-cffi==23.1.0 -argon2-cffi-bindings==21.2.0 -asttokens==2.4.1 -async-timeout==4.0.3 -asynctest==0.13.0 -attrs==23.1.0 -autodocsumm==0.2.11 -Automat==22.10.0 -Babel==2.13.1 -backcall==0.2.0 -beautifulsoup4==4.12.2 -bleach==6.1.0 -bpyutils==0.5.8 -brotlipy==0.7.0 -certifi==2023.11.17 -cffi==1.16.0 -charset-normalizer==2.0.12 -click==8.1.7 -colorama==0.4.6 -comm==0.2.0 -constantly==23.10.4 -contourpy==1.2.0 -cryptography==41.0.7 -cycler==0.12.1 -debugpy==1.8.0 -decorator==5.1.1 -defusedxml==0.7.1 -dill==0.3.7 -docutils==0.20.1 -dpcpp-cpp-rt==2024.0.0 -entrypoints==0.4 -environment-kernels==1.2.0 -executing==2.0.1 -fastjsonschema==2.19.0 -fonttools==4.45.1 -frozenlist==1.4.0 -hyperlink==21.0.0 -idna==3.6 -imageio==2.33.0 -imageio-ffmpeg==0.4.9 -imagesize==1.4.1 -importlib-metadata==6.8.0 -importlib-resources==6.1.1 -incremental==22.10.0 -intel-cmplr-lib-rt==2024.0.0 -intel-cmplr-lic-rt==2024.0.0 -intel-opencl-rt==2024.0.0 -intel-openmp==2024.0.0 -ipykernel==6.27.1 -ipython==8.18.1 -ipython-genutils==0.2.0 -jaraco.classes==3.3.0 -jedi==0.19.1 -jeepney==0.8.0 -Jinja2==3.1.2 -jsonschema==4.20.0 -jsonschema-specifications==2023.11.1 -jupyter_client==8.6.0 -jupyter_core==5.5.0 -jupyterlab_pygments==0.3.0 -keyring==24.3.0 -kiwisolver==1.4.5 -markdown-it-py==3.0.0 -MarkupSafe==2.1.3 -matplotlib==3.8.2 -matplotlib-inline==0.1.6 -mdurl==0.1.2 -meshio==5.3.4 -mistune==3.0.2 -mkl==2024.0.0 -more-itertools==10.1.0 -multidict==6.0.4 -multiprocess==0.70.15 -nbclient==0.9.0 -nbconvert==7.11.0 -nbformat==5.9.2 -nbsphinx==0.9.3 -nest-asyncio==1.5.8 -nh3==0.2.14 -numpy==1.26.2 -olefile==0.46 -packaging==23.2 -pandocfilters==1.5.0 -parso==0.8.3 -pathos==0.3.1 -pexpect==4.9.0 -pickleshare==0.7.5 -Pillow==10.1.0 -pkginfo==1.9.6 -platformdirs==4.0.0 -pooch==1.8.0 -pox==0.3.3 -ppft==1.7.6.7 -prometheus-client==0.19.0 -prompt-toolkit==3.0.41 -psutil==5.9.6 -ptyprocess==0.7.0 -pure-eval==0.2.2 -pyasn1==0.5.1 -pyasn1-modules==0.3.0 -pycparser==2.21 -Pygments==2.17.2 -pyparsing==3.1.1 -PyQt5==5.15.10 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.13.0 -pyrsistent==0.20.0 -python-dateutil==2.8.2 -pytz==2023.3.post1 -pyvista==0.42.3 -pyvistaqt==0.11.0 -pywinpty==0.5.7 -PyYAML==6.0.1 -pyzmq==25.1.1 -QtPy==2.4.1 -readme-renderer==42.0 -referencing==0.31.0 -requests==2.31.0 -requests-toolbelt==1.0.0 -rfc3986==2.0.0 -rich==13.7.0 -rpds-py==0.13.1 -scooby==0.9.2 -SecretStorage==3.3.3 -setuptools-scm==8.0.4 -six==1.16.0 -snowballstemmer==2.2.0 -soupsieve==2.5 -Sphinx==7.2.6 -sphinx-rtd-theme==2.0.0 -sphinxcontrib-applehelp==1.0.7 -sphinxcontrib-devhelp==1.0.5 -sphinxcontrib-htmlhelp==2.0.4 -sphinxcontrib-jquery==4.1 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.6 -sphinxcontrib-serializinghtml==1.1.9 -stack-data==0.6.3 -tbb==2021.11.0 -terminado==0.18.0 -testpath==0.6.0 -tinycss2==1.2.1 -tomli==2.0.1 -tornado==6.4 -traitlets==5.14.0 -transforms3d==0.4.1 -twine==4.0.2 -Twisted==23.10.0 -typing_extensions==4.8.0 -urllib3==2.1.0 -vtk==9.3.0 -wcwidth==0.2.12 -webencodings==0.5.1 -wincertstore==0.2 -wslink==1.12.4 -yarl==1.9.3 -zipp==3.17.0 -zope.interface==6.1 From 59cb325de7a70f822ed8c5645bb7938af425922d Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 10:42:37 +0200 Subject: [PATCH 6/7] Add ReadTheDocs config, fix docs version import, add release guide - .readthedocs.yaml: auto-build docs on every push to master - docs/conf.py: replace deleted _version import with importlib.metadata - docs/releasing.rst: step-by-step release guide for maintainers - docs/index.rst: add Maintainer Guide section to TOC - pyproject.toml: add [docs] optional dependencies group --- .readthedocs.yaml | 17 +++++++++ docs/conf.py | 8 ++-- docs/index.rst | 6 +++ docs/releasing.rst | 91 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 6 +++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/releasing.rst diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..c2c21460 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/docs/conf.py b/docs/conf.py index e5c99e9b..4069aefb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,10 +12,9 @@ # import os import sys +from importlib.metadata import version, PackageNotFoundError sys.path.insert(0, os.path.abspath('..')) -from fdsreader._version import __version__ -# import sphinx_rtd_theme # -- Project information ----------------------------------------------------- @@ -24,7 +23,10 @@ author = 'FZJ IAS-7 (Prof. Dr. Lukas Arnold, Jan Vogelsang)' # The full version, including alpha/beta/rc tags -release = str(__version__) +try: + release = version("fdsreader") +except PackageNotFoundError: + release = "unknown" # -- General configuration --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index e31705a1..7caaadfd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,12 @@ FDSReader documentation! ======================== +.. toctree:: + :maxdepth: 1 + :caption: Maintainer Guide + + releasing + .. toctree:: :maxdepth: 1 :caption: FDSReader diff --git a/docs/releasing.rst b/docs/releasing.rst new file mode 100644 index 00000000..d7ac73c9 --- /dev/null +++ b/docs/releasing.rst @@ -0,0 +1,91 @@ +Releasing a New Version +======================= + +This page explains how to publish a new version of fdsreader to PyPI. +The entire process is automated — no manual upload or token handling is required. + +Overview +-------- + +Versioning is driven entirely by **Git tags**. +When you push a tag that starts with ``v``, GitHub Actions automatically: + +1. Builds the Python package (wheel + sdist) +2. Publishes it to PyPI (stable release) or TestPyPI (beta release) +3. Creates a GitHub Release with auto-generated release notes + +Prerequisites (one-time setup) +------------------------------- + +Before the first release, a maintainer must configure **Trusted Publishing** on PyPI: + +* Log in to `pypi.org `_ with the project account +* Go to **Account settings → Publishing → Add a new publisher** +* Fill in: + + * PyPI project name: ``fdsreader`` + * GitHub owner: ``FireDynamics`` + * Repository: ``fdsreader`` + * Workflow name: ``release.yml`` + * Environment name: ``pypi`` + +* Repeat for `test.pypi.org `_ with environment name ``testpypi`` + +This setup only needs to be done once. +See :ref:`Trusted Publishing docs ` for details. + +Stable release (publishes to PyPI) +----------------------------------- + +.. code-block:: bash + + # 1. Make sure master is up to date and all tests are green + git checkout master + git pull + + # 2. Tag the release (use semantic versioning: MAJOR.MINOR.PATCH) + git tag -a v1.12.0 -m "Version 1.12.0" + + # 3. Push the tag — this triggers the release workflow + git push origin v1.12.0 + +That's it. GitHub Actions takes over from here. +The new version appears on `PyPI `_ +within a few minutes. + +Beta / pre-release (publishes to TestPyPI) +------------------------------------------- + +If you want to test the package on TestPyPI before a stable release: + +.. code-block:: bash + + git tag -a v1.12.0b1 -m "Version 1.12.0 beta 1" + git push origin v1.12.0b1 + +Tags containing ``a``, ``b``, or ``rc`` (e.g. ``v1.12.0b1``, ``v1.12.0rc1``) +are automatically routed to TestPyPI instead of PyPI. + +Checking the release +-------------------- + +After pushing the tag: + +1. Go to **GitHub → Actions → Release** to watch the build progress +2. Check the **GitHub Releases** page for the auto-generated release notes +3. Verify the new version on `pypi.org/project/fdsreader `_ + +Updating the FDS compatibility table +-------------------------------------- + +After testing with a new FDS version, update the compatibility table in ``README.md`` +and commit the change to master before tagging the release. + +Version number guidelines +-------------------------- + +We follow `Semantic Versioning `_: + +* **PATCH** (1.x.y → 1.x.(y+1)): Bug fixes, no breaking changes +* **MINOR** (1.x.y → 1.(x+1).0): New features, backwards compatible +* **MAJOR** (1.x.y → 2.0.0): Breaking changes to the public API diff --git a/pyproject.toml b/pyproject.toml index ba984a4a..aab9d142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,12 @@ dev = [ "coverage[toml]", "pre-commit", ] +docs = [ + "sphinx>=7.0", + "sphinx-rtd-theme", + "autodocsumm", + "nbsphinx", +] [tool.setuptools_scm] version_scheme = "post-release" From 63283f16db5c7ee8e708e4a74ce8469bf7b16356 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Mon, 4 May 2026 10:53:38 +0200 Subject: [PATCH 7/7] Add pre-commit config with ruff hooks Runs ruff check --fix and ruff format automatically before each commit to prevent lint errors from reaching CI. --- .pre-commit-config.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..2029b7b7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=10000] + exclude: ^tests/cases/