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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

Fundamental algorithms from the field of fire science and fire safety engineering, for computations with Python.

Documentation is available here: [![FireSciPy Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://FireDynamics.github.io/FireSciPy/)
[![FireSciPy Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://FireDynamics.github.io/FireSciPy/)
[![PyPI version](https://badge.fury.io/py/firescipy.svg)](https://pypi.org/project/firescipy/)


## Installation

```
pip install firescipy
```

Examples are available in Jupyter notebooks in the [FireSciPy repo on GitHub](https://github.com/FireDynamics/FireSciPy).

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ functions, and utility modules used in FireSciPy.
reference/pyrolysis
reference/handcalculation
reference/utils
reference/instruments
46 changes: 46 additions & 0 deletions docs/reference/instruments.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Instruments Module
==================

This subpackage provides parsers for reading experimental instrument export
files commonly used in fire science and fire safety engineering research.
Supported formats include thermal analysis (STA, DSC, TGA) and calorimetry
(MCC, Cone Calorimeter) instruments.

A generic reader with automatic file type detection is available via
:func:`firescipy.instruments.read_instrument_file`.


Reader
------

.. automodule:: firescipy.instruments.reader
:members:
:undoc-members:
:show-inheritance:


Netzsch STA / DSC / TGA
------------------------

.. automodule:: firescipy.instruments.netzsch_sta
:members:
:undoc-members:
:show-inheritance:


DEATAK MCC
----------

.. automodule:: firescipy.instruments.deatak_mcc
:members:
:undoc-members:
:show-inheritance:


Netzsch Cone Calorimeter
------------------------

.. automodule:: firescipy.instruments.netzsch_cone
:members:
:undoc-members:
:show-inheritance:
6 changes: 3 additions & 3 deletions docs/tutorials/pyrolysis/mock_kinetics_computation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,12 @@ Looping over the above dictionary, a temperature program is created for each nom
t_array = fsp.utils.series_to_numpy(hr_model["Time"])
T_array = fsp.utils.series_to_numpy(hr_model["Temperature"])
# Get overview over temperature resolution
ΔT = temp_model[1] - temp_model[0]
ΔT = T_array[1] - T_array[0]
print(f"Temperature resolution ({hr_label}): ΔT = {ΔT} K")
# Compute conversion for decelerating reaction (n-th order)
t_sol, alpha_sol = fsp.pyrolysis.modeling.solve_kinetics(
t_array=time_model,
T_array=temp_model,
t_array=t_array,
T_array=T_array,
A=A,
E=E,
alpha0=alpha0,
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "firescipy"
version = "0.0.6"
version = "0.0.9"
description = "FireSciPy: Fundamental algorithms from the field of fire science, for computations with Python."
readme = "README.md"
keywords = ["Fire Safety Engineering", "fire", "pyrolysis", "kinetics", "FDS"]
Expand Down Expand Up @@ -32,6 +32,12 @@ dependencies = [
[tool.hatch.build.targets.wheel]
packages = ["src/firescipy"]

[project.optional-dependencies]
test = ["pytest>=7.0"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[project.urls]
Repository = "https://github.com/FireDynamics/FireSciPy"
Documentation = "https://firedynamics.github.io/FireSciPy/"
Expand Down
1 change: 1 addition & 0 deletions src/firescipy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from . import pyrolysis
from . import constants
from . import handcalculation
from . import instruments
from importlib.metadata import PackageNotFoundError, version


Expand Down
8 changes: 8 additions & 0 deletions src/firescipy/instruments/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

from .reader import read_instrument_file, detect_file_type, SUPPORTED_TYPES
from .netzsch_sta import read_netzsch_sta_file
from .deatak_mcc import read_deatak_mcc_file
from .netzsch_cone import read_netzsch_cone_file
123 changes: 123 additions & 0 deletions src/firescipy/instruments/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

from pathlib import Path


class InstrumentFile:
"""
Generic file loader for laboratory text exports.

Responsibilities
----------------
- read raw bytes
- decode using fallback encodings
- repair known character issues
- expose text and lines
- provide small helpers for line-based inspection
"""

def __init__(
self,
file_path,
encodings=None,
replacements=None,
strip_bom=True,
):
# Store the file path as a Path object for reliable cross-platform handling.
self.file_path = Path(file_path)

# List of encodings to try in order. Many lab instruments export files
# with Windows-specific encodings (cp1252, latin1) rather than UTF-8.
# The first encoding that decodes the file without errors is used.
self.encodings = encodings or [
"utf-8",
"cp1252",
"latin1",
"utf-16",
"utf-16-le",
"utf-16-be",
]

# Character repairs applied after decoding. Some instruments export
# special characters (e.g. degree sign °) using byte sequences that
# do not survive encoding conversion cleanly. These replacements fix
# the most common cases. The order matters: more specific patterns
# (e.g. "°C") must come before broader ones (e.g. "°") to avoid
# partial replacements.
self.replacements = replacements or {
"›": "°",
"›C": "°C", # cp1252: byte › decodes to › before C → °C
"›": "°", # cp1252: byte › decodes to › standalone → °
"�C": "°C", # UTF-8 mojibake for degree-Celsius
"�": "°", # Unicode replacement character U+FFFD → degree sign
}

# If True, strip the Byte Order Mark (BOM) that some editors and
# instruments prepend to UTF-8 or UTF-16 files.
self.strip_bom = strip_bom

# These attributes are populated by read().
self.raw_bytes = None # original file content as bytes
self.text = None # decoded and repaired full text
self.lines = None # text split into individual lines
self.used_encoding = None # whichever encoding succeeded

def read(self):
# Read the entire file as raw bytes first, before any decoding.
self.raw_bytes = self.file_path.read_bytes()

# Try each encoding in order until one succeeds.
self.text, self.used_encoding = self._decode_bytes(self.raw_bytes)

# Fix known character encoding artefacts in the decoded text.
self.text = self._repair_text(self.text)

# Remove a leading BOM character if present (common in UTF-8/16 files).
if self.strip_bom:
self.text = self.text.lstrip("")

# Split into lines for line-by-line processing by the parsers.
self.lines = self.text.splitlines()
return self

def _decode_bytes(self, raw_bytes):
last_error = None

# Try each candidate encoding and return on the first success.
for enc in self.encodings:
try:
return raw_bytes.decode(enc), enc
except UnicodeDecodeError as exc:
last_error = exc

# None of the encodings worked — raise a descriptive error.
raise ValueError(
f"Could not decode file '{self.file_path}' with tried encodings: {self.encodings}"
) from last_error

def _repair_text(self, text):
# Apply each search-and-replace pair from the replacements dict.
for old, new in self.replacements.items():
text = text.replace(old, new)
return text

def preview(self, start=0, stop=10):
# Quick inspection helper: return a slice of lines without printing all.
if self.lines is None:
raise RuntimeError("Call read() first.")
return self.lines[start:stop]

def find_first_line(self, startswith=None, contains=None):
# Search for the first line that matches a prefix or a substring.
# Returns the line index, or None if no match is found.
if self.lines is None:
raise RuntimeError("Call read() first.")

for idx, line in enumerate(self.lines):
if startswith is not None and line.startswith(startswith):
return idx
if contains is not None and contains in line:
return idx
return None
Loading
Loading