diff --git a/.github/actions/check_namespace_file_consistency.py b/.github/actions/check_namespace_file_consistency.py
new file mode 100644
index 0000000..938b8b5
--- /dev/null
+++ b/.github/actions/check_namespace_file_consistency.py
@@ -0,0 +1,67 @@
+"""
+Dev-only namespace consistency checker.
+
+- Reads a fixed Markdown file to get the list of standard variable names.
+- Imports `svn` from `firebench` to get the authoritative list from code.
+- Compares both lists and prints differences.
+- No argparse, no unit/type checks, no JSON.
+
+Usage:
+ in root directory: make check-consistency-namespace
+"""
+
+import re
+from pathlib import Path
+
+# Docs path
+NAMESPACE_DOCS = Path("docs/namespace.md")
+_MD_NAME_RE = re.compile(r"-\s*`([^`]+)`")
+
+
+def get_md_variables(md_path: Path) -> list[str]:
+ """Return the list of variable names listed in the Markdown spec."""
+ text = md_path.read_text(encoding="utf-8")
+ return _MD_NAME_RE.findall(text)
+
+
+def get_svn_variables() -> list[str]:
+ """Return the list of variable names from `firebench.svn`.
+
+ Supports several shapes:
+ - Enum class (uses __members__)
+ - dict of names
+ - list/tuple/set of names
+ - module / object with UPPERCASE attributes
+ """
+ from firebench import svn
+
+ # Enum class (most common)
+ members = getattr(svn, "__members__", None)
+ if isinstance(members, dict):
+ return sorted(members.keys())
+
+ # collect UPPERCASE attribute names on the object/module
+ names = [n for n in dir(svn) if n.isupper() and not n.startswith("_")]
+ return sorted(set(names))
+
+
+def check_consistency() -> None:
+ md_vars = set(get_md_variables(NAMESPACE_DOCS))
+ svn_vars = set(get_svn_variables())
+
+ only_in_docs = sorted(md_vars - svn_vars)
+ only_in_svn = sorted(svn_vars - md_vars)
+ common = sorted(md_vars & svn_vars)
+
+ print("== Namespace Consistency Check ==")
+ print(f"Common: {len(common)}")
+ print(f"Only in docs (missing from svn): {len(only_in_docs)}")
+ for name in only_in_docs:
+ print(f" - {name}")
+ print(f"Only in svn (missing from docs): {len(only_in_svn)}")
+ for name in only_in_svn:
+ print(f" - {name}")
+
+
+if __name__ == "__main__":
+ check_consistency()
diff --git a/.github/actions/update_changelog_in_docs.py b/.github/actions/update_changelog_in_docs.py
index fb6450d..ece8c42 100644
--- a/.github/actions/update_changelog_in_docs.py
+++ b/.github/actions/update_changelog_in_docs.py
@@ -17,7 +17,7 @@ def update_changelog_in_docs():
changelog_content = root_file.read()
# Front matter for the docs/changelog.md file
- front_matter = """# 11. """
+ front_matter = """# 12. """
# Combine front matter and changelog content
full_changelog_content = front_matter + changelog_content[2:]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c27ff09..35b97eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).
+## [0.8.0] - 2026 / 01 / 14
+### Added
+- 2021 Caldor case FB001 documentation and benchmarks (See Zenodo FireBench for release)
+- package `metrics`: contains kpi functions, metrics functions for perimeters, 1D datasets, confusion matrix.
+- package `standardize`: contains standardization functions for landfire, mtbs, ravg, synoptic, geotiff.
+- package `signing`: contains functions for certification (hardware encryption) and verification of certificates (`verify_certificate_in_dict`, `verify_certificates_in_h5`). Verification functions require `gpg` (not needed for benchmarking functions).
+- Public Key for certificates verification
+
+### Documentation
+- FireBench Standard file format
+- Add Key Performance Indicators, Metrics, Score and Normalization information
+
## [0.7.0] - 2025 / 08 / 09
### Added
- `anderson_2015_stats`: Plot statistics from the Anderson 2015 dataset.
diff --git a/Makefile b/Makefile
index 3310882..9241bde 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
# Declare these targets as phony to avoid conflicts with files of the same name
-.PHONY: test test-cov lint update-lint-score code-formatting bandit update-docs-changelog generate-api-doc docs clean
+.PHONY: test test-cov lint update-lint-score code-formatting bandit update-docs-changelog check-consistency-namespace generate-api-doc docs clean
# Run all tests without coverage report
test:
@@ -32,6 +32,10 @@ bandit:
update-docs-changelog:
python .github/actions/update_changelog_in_docs.py
+# Check consistency between namespace in package and in docs
+check-consistency-namespace:
+ python .github/actions/check_namespace_file_consistency.py
+
# Update API documentation
generate-api-doc:
python .github/actions/generate_api_docs.py
diff --git a/README.md b/README.md
index 085d387..921408f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://github.com/wirc-sjsu/firebench/actions/workflows/pages/pages-build-deployment)
[](https://codecov.io/github/wirc-sjsu/firebench)
[](https://github.com/wirc-sjsu/firebench/actions/workflows/security.yml)
-
+
[](https://github.com/wirc-sjsu/firebench/actions/workflows/pylint.yml)
[](https://github.com/wirc-sjsu/firebench/actions/workflows/black.yml)

diff --git a/docs/_static/benchmarks/FB001/Caldor_bd_distribution.png b/docs/_static/benchmarks/FB001/Caldor_bd_distribution.png
new file mode 100644
index 0000000..67a8400
Binary files /dev/null and b/docs/_static/benchmarks/FB001/Caldor_bd_distribution.png differ
diff --git a/docs/_static/benchmarks/FB001/Caldor_bd_map.png b/docs/_static/benchmarks/FB001/Caldor_bd_map.png
new file mode 100644
index 0000000..357cfbb
Binary files /dev/null and b/docs/_static/benchmarks/FB001/Caldor_bd_map.png differ
diff --git a/docs/_static/benchmarks/FB001/Caldor_burnt_area.png b/docs/_static/benchmarks/FB001/Caldor_burnt_area.png
new file mode 100644
index 0000000..09e5acf
Binary files /dev/null and b/docs/_static/benchmarks/FB001/Caldor_burnt_area.png differ
diff --git a/docs/_static/benchmarks/FB001/Caldor_perimeters.png b/docs/_static/benchmarks/FB001/Caldor_perimeters.png
new file mode 100644
index 0000000..1ab92f9
Binary files /dev/null and b/docs/_static/benchmarks/FB001/Caldor_perimeters.png differ
diff --git a/docs/_static/benchmarks/FB001/RAVG_BA_final.png b/docs/_static/benchmarks/FB001/RAVG_BA_final.png
new file mode 100644
index 0000000..47a7b5a
Binary files /dev/null and b/docs/_static/benchmarks/FB001/RAVG_BA_final.png differ
diff --git a/docs/_static/benchmarks/FB001/RAVG_CBI_final.png b/docs/_static/benchmarks/FB001/RAVG_CBI_final.png
new file mode 100644
index 0000000..a7c2e66
Binary files /dev/null and b/docs/_static/benchmarks/FB001/RAVG_CBI_final.png differ
diff --git a/docs/_static/benchmarks/FB001/RAVG_CC_final.png b/docs/_static/benchmarks/FB001/RAVG_CC_final.png
new file mode 100644
index 0000000..a715fef
Binary files /dev/null and b/docs/_static/benchmarks/FB001/RAVG_CC_final.png differ
diff --git a/docs/_static/benchmarks/FB001/RAVG_CC_masked.png b/docs/_static/benchmarks/FB001/RAVG_CC_masked.png
new file mode 100644
index 0000000..5f0e55a
Binary files /dev/null and b/docs/_static/benchmarks/FB001/RAVG_CC_masked.png differ
diff --git a/docs/_static/benchmarks/FB001/mtbs_map.jpg b/docs/_static/benchmarks/FB001/mtbs_map.jpg
new file mode 100644
index 0000000..3ab3d5e
Binary files /dev/null and b/docs/_static/benchmarks/FB001/mtbs_map.jpg differ
diff --git a/docs/_static/images/Metrics_diagram.png b/docs/_static/images/Metrics_diagram.png
new file mode 100644
index 0000000..9cd0797
Binary files /dev/null and b/docs/_static/images/Metrics_diagram.png differ
diff --git a/docs/_static/images/Score_card_example.png b/docs/_static/images/Score_card_example.png
new file mode 100644
index 0000000..033b186
Binary files /dev/null and b/docs/_static/images/Score_card_example.png differ
diff --git a/docs/_static/images/Scoring_diagram.png b/docs/_static/images/Scoring_diagram.png
new file mode 100644
index 0000000..acbd94c
Binary files /dev/null and b/docs/_static/images/Scoring_diagram.png differ
diff --git a/docs/_static/images/Scoring_diagram_example.png b/docs/_static/images/Scoring_diagram_example.png
new file mode 100644
index 0000000..b684d80
Binary files /dev/null and b/docs/_static/images/Scoring_diagram_example.png differ
diff --git a/docs/api/metrics.rst b/docs/api/metrics.rst
index 306ed93..9c6f884 100644
--- a/docs/api/metrics.rst
+++ b/docs/api/metrics.rst
@@ -9,3 +9,17 @@ Perimeters
:undoc-members:
:show-inheritance:
+Confusion Matrix
+----------------
+
+.. automodule:: firebench.metrics.confusion_matrix.binary_performance
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+.. automodule:: firebench.metrics.confusion_matrix.utils
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+
diff --git a/docs/benchmarks/California/01_Caldor.md b/docs/benchmarks/California/01_Caldor.md
new file mode 100644
index 0000000..05f44f1
--- /dev/null
+++ b/docs/benchmarks/California/01_Caldor.md
@@ -0,0 +1,1271 @@
+# 2021 Caldor Fire
+
+**Version**: 2026.0
+**Case ID**: FB001
+**FireBench IO std version**: >= 1.0
+**Date of last update**: 01/14/2025
+
+## Contributors
+- Aurélien Costes, [Wildfire Interdisciplinary Research Center](https://www.wildfirecenter.org/), San Jose State University, [aurelien.costes@sjsu.edu](mailto:aurelien.costes@sjsu.edu), [ORCID](https://orcid.org/0000-0003-4543-5107)
+- Angel Farguell Caus, [Wildfire Interdisciplinary Research Center](https://www.wildfirecenter.org/), San Jose State University, [angel.farguellcaus@sjsu.edu](mailto:[angel.farguellcaus@sjsu.edu), [ORCID](https://orcid.org/0000-0003-2395-220X)
+- Adam Kochanski, [Wildfire Interdisciplinary Research Center](https://www.wildfirecenter.org/), San Jose State University, [adam.kochanski@sjsu.edu](mailto:[adam.kochanski@sjsu.edu), [ORCID](https://orcid.org/0000-0001-7820-2831)
+
+## Description
+
+This collection of benchmarks uses the public resources about the 2021 Caldor Fire.
+It contains over 300 benchmarks on various datasets.
+It contains observation datasets for:
+- Building damaged (CALFIRE)
+- Burn severity (MTBS)
+- Burn severity (RAVG)
+- Canopy bottom height (LANDFIRE)
+- Canopy bulk density (LANDFIRE)
+- Canopy cover loss (RAVG)
+- Canopy height (LANDFIRE)
+- Infrared fire perimeters (NIROPS)
+- Live basal area change (RAVG)
+- Weather stations (Synoptic)
+
+## Buildings damage
+
+### Dataset
+
+The data has been collected using **CAL FIRE Damage Inspection (DINS) Data** (version of 2025/11/05).
+The original CSV file containing multiple fires has been processed to extract only the buildings damaged by the Caldor Fire. The dataset includes the positions (lat, lon) of buildings within the area of influence of the fire. The state of buildings is one of the following:
+- 'No Damage',
+- 'Affected (1-9%)',
+- 'Minor (10-25%)',
+- 'Major (26-50%)',
+- 'Destroyed (>50%)',
+- 'Inaccessible'.
+
+
+The sha256 of the source file is: *0190a5a51aafafa20270fe046a7ae17a53697b1fb218ff8096a3d8ebbc9ef983*.
+
+If the evaluated model does not explicitly represent individual buildings, it should treat all buildings within a cell as sharing the cell value for building damage (deterministic models) or the median of the building damage distribution (probabilistic models).
+
+Figure 1 shows the spatial distribution of building damage for the Caldor Fire.
+
+
+
+ Fig. 1
+
+ :
+
+ Building damage map
+
+
+
+Figure 2 shows the distribution of building damage for the Caldor Fire. The following Table shows the number of structures in each damage category.
+Damage category | Counts [-]
+---------------------- | -----------------
+No Damage | 3356
+Affected (1-9%) | 56
+Minor (10-25%) | 18
+Major (26-50%) | 7
+Destroyed (>50%) | 1005
+Inaccessible | 2
+Total | 4444
+
+
+
+
+ Fig. 2
+
+ :
+
+ Distribution of buildings damage
+
+
+
+### Processing of dataset
+
+*Performed at obs dataset level*
+
+The data from the original CSV file were standardized without modification.
+The column names from the original csv file were corrected from "* Damage" to "Damage" and "* Incident Name" to "Incident Name" to simplify processing.
+
+#### Binary classes of building damage
+
+*Performed at benchmark run level*
+
+To perform some calculations, the damaged building classes can be aggregated to form binary classes. The `Inaccessible` is ignored. The following aggregation method is used:
+- `unburnt` binary class contains `No Damage`, `Affected (1-9%)`, and `Minor (10-25%)`,
+- `burnt` binary class contains `Major (26-50%)`, and `Destroyed (>50%)`.
+
+### Benchmarks
+
+See Key Performance Indicator (KPI) and normalization definitions [here](../../metrics/index.md).
+
+#### Binary Structure Loss Accuracy
+
+**Short IDs**: BD01
+**KPI**: Binary Structure Loss Accuracy
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss Accuracy
+This benchmark is performed on the binary classes for damaged buildings.
+
+#### Binary Structure Loss Precision
+
+**Short IDs**: BD02
+**KPI**: Binary Structure Loss Precision
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss Precision
+This benchmark is performed on the binary classes for damaged buildings.
+
+#### Binary Structure Loss Recall
+
+**Short IDs**: BD03
+**KPI**: Binary Structure Loss Recall
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss Recall
+This benchmark is performed on the binary classes for damaged buildings.
+
+#### Binary Structure Loss Specificity
+
+**Short IDs**: BD04
+**KPI**: Binary Structure Loss Specificity
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss Specificity
+This benchmark is performed on the binary classes for damaged buildings.
+
+#### Binary Structure Loss Negative Predictive Value
+
+**Short IDs**: BD05
+**KPI**: Binary Structure Loss Negative Predictive Value
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss Negative Predictive Value
+This benchmark is performed on the binary classes for damaged buildings.
+
+#### Binary Structure Loss F1 Score
+
+**Short IDs**: BD06
+**KPI**: Binary Structure Loss F1 Score
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary Structure Loss F1 Score
+This benchmark is performed on the binary classes for damaged buildings.
+
+
+## Burn severity from MTBS
+
+### Dataset
+
+The data has been collected using [Monitoring Trends in Burning Severity](https://mtbs.gov/) (MTBS).
+The original zip file contains burn severity, pre/post burn images, and the final fire perimeter.
+The source of the burn severity used in FireBench is the file `ca3858612053820210815_20210805_20220723_dnbr6.tif`. The source of the final fire perimeter is the kmz file `ca3858612053820210815_20210805_20220723.kmz`.
+
+The burn severity categories, described with the corresponding index used in the dataset, are the following:
+- 'no data': 0
+- 'unburnt to low': 1
+- 'low': 2
+- 'moderate': 3
+- 'high': 4
+- 'increased greenness': 5
+
+The hashes of the original source files are:
+- zip file: 171b9604c0654d8612eaabcfcad93d2374762661ab34b4d62718630a13469841
+- tif dnbr6: 33db74d3c5798c41ff3a4fc5ee57da9105fdc7a75d7f8af0d053d2f82cfdc0b6
+- final perimeter kmz: 4ed7a0ee585f8118b65a29375a3d5ee8a69e85a95ee155205ba5d781289c6e2b
+
+Figure 3 shows the MTBS map from the original source.
+
+
+
+
+ Fig. 3
+
+ :
+
+ Map of burn severity from MTBS. Source: MTBS (`ca3858612053820210815_map.pdf`)
+
+
+
+### Processing of dataset
+
+*Performed at obs dataset level*
+
+The burn severity array is extracted from the original file without any modification. The latitude and longitude array are reconstructed using projection parameters (see `firebench.standardize.mtbs.standardize_mtbs_from_geotiff`). The final perimeter has been processed using QGIS. The original data (kmz file) has been imported and cleaned. Extra perimeters have been removed to conserve only the final fire perimeter. No modification to the polygons has been performed. Then, the multipolygons were exported to kml format and integrated into the dataset HDF5 file.
+
+#### Binary classes for high severity
+
+*Performed at benchmark run level*
+
+To perform the high-severity benchmarks using a binary confusion matrix, we construct a binary field based on the high-severity index. All points will have a burn severity of 4 ('high') and will be assigned the value 1. The other points are assigned a value of 0. This processing is done when the benchmark is performed.
+
+### Benchmarks
+
+See Key Performance Indicator (KPI) and normalization definitions [here](../../metrics/index.md).
+
+#### Binary High Severity Accuracy
+
+**Short IDs**: SV01
+**KPI**: Binary High Severity Accuracy
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity Accuracy
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+#### Binary High Severity Precision
+
+**Short IDs**: SV02
+**KPI**: Binary High Severity Precision
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity Precision
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+#### Binary High Severity Recall
+
+**Short IDs**: SV03
+**KPI**: Binary High Severity Recall
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity Recall
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+#### Binary High Severity Specificity
+
+**Short IDs**: SV04
+**KPI**: Binary High Severity Specificity
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity Specificity
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+#### Binary High Severity Negative Predictive Value
+
+**Short IDs**: SV05
+**KPI**: Binary High Severity Negative Predictive Value
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity Negative Predictive Value
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+#### Binary High Severity F1 Score
+
+**Short IDs**: SV06
+**KPI**: Binary High Severity F1 Score
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Severity F1 Score
+This benchmark is performed on the binary classes for high severity points (Binary High severity processed variable)
+
+## Canopy cover loss
+
+### Dataset
+
+The data has been collected using [Rapid Assessment of Vegetation Condition after Wildfire](https://burnseverity.cr.usgs.gov/ravg/) (RAVG).
+The source of the canopy cover loss used in FireBench is the dataset over CONUS for 2021, `ravg_2021_cc5.tif`. The region around the Caldor Fire has been processed and standardized using the following bounding box:
+- south west: (38.4, -120.8)
+- north east: (39.0, -119.7)
+
+The canopy cover loss categories, described with the corresponding index used in the dataset, are the following:
+- 'Unmappable': 0
+- '0%': 1
+- '>0-<25%': 2
+- '25-<50%': 3
+- '50-<75%': 4
+- '75-100%': 5
+- 'Outide burn area': 9
+
+In addition, a bounding box has been used to remove the data from another fire (forced to `0`):
+- south west: (38.6, -119.9)
+- north east: (38.805, -119.7)
+
+Figure 4 shows the processed RAVG dataset available in FireBench.
+
+
+
+
+ Fig. 4
+
+ :
+
+ Map of standardized canopy cover loss from RAVG for Caldor Fire.
+
+
+
+### Processing of dataset
+
+*Performed at obs dataset level*
+
+A bounding box has been used to remove the data from another fire (forced to `0`):
+- south west: (38.6, -119.9)
+- north east: (38.805, -119.7)
+
+#### Masking using LANDFIRE dataset
+
+*Performed at benchmark run level*
+
+To perform an evaluation of high canopy cover loss, a mask is defined using three LANDFIRE datasets:
+- Canopy bulk density
+- Canopy height
+- Canopy bottom height
+
+The variable `masked high binary canopy cover loss` used in various benchmarks is computed only where all LANDFIRE canopy variables (interpolated using the nearest method on the RAVG grid) are strictly greater than 0 (presence of canopy fuel) and is defined as a binary variable:
+- `1` if RAVG canopy cover loss value is `5`,
+- `0` if RAVG canopy cover loss value is between `1` and `4`,
+- `nan` otherwise.
+
+Figure 5 shows the processed `masked high binary canopy cover loss` dataset used for related benchmarks.
+
+
+
+
+ Fig. 5
+
+ :
+
+ Map of standardized canopy cover loss from RAVG for Caldor Fire.
+
+
+
+### Benchmarks
+
+See Key Performance Indicator (KPI) and normalization definitions [here](../../metrics/index.md).
+
+#### Masked High Binary Canopy Cover Loss Accuracy
+
+**Short IDs**: CC01
+**KPI**: Binary High Canopy Cover Loss Accuracy
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss Accuracy
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+#### Masked High Binary Canopy Cover Precision
+
+**Short IDs**: CC02
+**KPI**: Binary High Canopy Cover Loss Precision
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss Precision
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+#### Masked High Binary Canopy Cover Recall
+
+**Short IDs**: CC03
+**KPI**: Binary High Canopy Cover Loss Recall
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss Recall
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+#### Masked High Binary Canopy Cover Specificity
+
+**Short IDs**: CC04
+**KPI**: Binary High Canopy Cover Loss Specificity
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss Specificity
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+#### Masked High Binary Canopy Cover Negative Predictive Value
+
+**Short IDs**: CC05
+**KPI**: Binary High Canopy Cover Loss Negative Predictive Value
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss Negative Predictive Value
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+#### Masked High Binary Canopy Cover F1 Score
+
+**Short IDs**: CC06
+**KPI**: Binary High Canopy Cover Loss F1 Score
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: Binary High Canopy Cover Loss F1 Score
+This benchmark is performed on the binary classes `masked high binary canopy cover loss`.
+
+## Infrared fire perimeters
+### Dataset
+
+The infrared fire perimeters have been gathered from [NIROPS](https://ftp.wildfire.gov/public/incident_specific_data/calif_n/2021_FEDERAL_Incidents/CA-ENF-024030_Caldor/IR/NIROPS/) dataset.
+Every orginal file has been manually processed to extract only the perimeter. The time stamp of the perimeter has been defined from the imaging report (e.g. [Report for 2021/08/17](https://ftp.wildfire.gov/public/incident_specific_data/calif_n/2021_FEDERAL_Incidents/CA-ENF-024030_Caldor/IR/NIROPS/20210818/20210818_Caldor_IR_Topo_11x17.pdf)) using the `Imagery Date` and `Imagery Time`. The burn area obtained using the KML file and python tools has been verified against the `Interpreted Acreage` when specified in the reports. Each fire perimeter (see Fig. 6) is stored as a group within the HDF5 data file with attributes containing the path of the KML file that contains the fire perimeter dataset.
+The perimeters have been processed from August 17th (first IR perimeter available) to September 10th, when the burn area is 99% if the final burn area, as shown in Figure 7 (source: [CALFIRE](https://www.fire.ca.gov/incidents/2021/8/14/caldor-fire/)).
+The final dataset contains 21 perimeters.
+
+The following study periods (see Fig. 7) are defined in the following Table:
+
+Name | Start time | End time | Duration | Burn area [acre]
+-----|-------------------|--------------------|---------------|-----------------
+W1 | Aug 17 20h20 PDT | Sep 10 23h34 PDT | 24d 3h 14min | 166,256
+W2 | Aug 19 20h45 PDT | Aug 21 21h15 PDT | 2d 0h 30min | 24,941
+W3 | Aug 26 02h30 PDT | Aug 28 20h30 PDT | 2d 18h 0min | 19,992
+W4 | Aug 28 20h30 PDT | Sep 3 00h40 PDT | 5d 4h 10min | 56,272
+
+Figure 6 shows the processed fire perimeter as a colored solid contour. The color of the contour indicates the timestamp of the perimeter.
+
+
+
+
+ Fig. 6
+
+ :
+
+ Infrared fire perimeters from August 17th to September 10th.
+
+
+
+
+
+
+ Fig. 7
+
+ :
+
+ Burn area derived from IR perimeters from August 17th to September 10th. The red dashed line shows the final burn area from CALFIRE. The orange dashed line shows the final burn area from the MTBS final perimeter.
+
+
+
+### Benchmarks
+
+See Key Performance Indicator (KPI) and normalization definitions [here](../../metrics/index.md).
+
+#### Average Jaccard Index over study period
+
+**Short IDs**: See Table
+**KPI**: Average Jaccard Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP01 | W1 | Average Jaccard Index W1
+FP02 | W2 | Average Jaccard Index W2
+FP03 | W3 | Average Jaccard Index W3
+FP04 | W4 | Average Jaccard Index W4
+
+#### Minimum Jaccard Index over study period
+
+**Short IDs**: See Table
+**KPI**: Minimum Jaccard Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP05 | W1 | Minimum Jaccard Index W1
+FP06 | W2 | Minimum Jaccard Index W2
+FP07 | W3 | Minimum Jaccard Index W3
+FP08 | W4 | Minimum Jaccard Index W4
+
+#### Maximum Jaccard Index over study period
+
+**Short IDs**: See Table
+**KPI**: Maximum Jaccard Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP09 | W1 | Minimum Jaccard Index W1
+FP10 | W2 | Minimum Jaccard Index W2
+FP11 | W3 | Minimum Jaccard Index W3
+FP12 | W4 | Minimum Jaccard Index W4
+
+#### Average Dice-Sorensen Index over study period
+
+**Short IDs**: See Table
+**KPI**: Average Dice-Sorensen Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP13 | W1 | Average Dice-Sorensen Index W1
+FP14 | W2 | Average Dice-Sorensen Index W2
+FP15 | W3 | Average Dice-Sorensen Index W3
+FP16 | W4 | Average Dice-Sorensen Index W4
+
+#### Minimum Dice-Sorensen Index over study period
+
+**Short IDs**: See Table
+**KPI**: Minimum Dice-Sorensen Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP17 | W1 | Minimum Dice-Sorensen Index W1
+FP18 | W2 | Minimum Dice-Sorensen Index W2
+FP19 | W3 | Minimum Dice-Sorensen Index W3
+FP20 | W4 | Minimum Dice-Sorensen Index W4
+
+#### Maximum Dice-Sorensen Index over study period
+
+**Short IDs**: See Table
+**KPI**: Maximum Dice-Sorensen Index
+**Normalization**: Linear Bounded Normalization with $a=0$, $b=1$
+**Name in Score Card**: See Table
+The first perimeter at the start of the period can serve as an initial condition for the fire perimeter. The first perimeter is not used to compute any metric.
+The area preserving project used is EPSG:5070.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card
+-----|--------------|-------------------
+FP21 | W1 | Minimum Dice-Sorensen Index W1
+FP22 | W2 | Minimum Dice-Sorensen Index W2
+FP23 | W3 | Minimum Dice-Sorensen Index W3
+FP24 | W4 | Minimum Dice-Sorensen Index W4
+
+#### Final Burn Area Bias
+
+**Short IDs**: See Table
+**KPI**: Burn Area Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+The first perimeter, at the start of the period, can be used as initial condition for the fire perimeter.
+The bias is calculated on the last perimeter of the study period as the difference between the model and the observed burn area.
+A bias of $m$ acres, representing $B_{50}$% of burn area during the study period, will lead to a score of 50.00. The value of $m$ represents the benchmark difficulty (smaller $m$ means greater difficulty) and must be chosen by the community.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card | $m$ | $B_{50}$
+-----|--------------|--------------------|--------|---------
+FP25 | W1 | Burn Area Bias W1 | 80,000 | 48%
+FP26 | W2 | Burn Area Bias W2 | 5,000 | 20%
+FP27 | W3 | Burn Area Bias W3 | 5,000 | 25%
+FP28 | W4 | Burn Area Bias W4 | 17,000 | 30%
+
+#### Burn Area RMSE
+
+**Short IDs**: See Table
+**KPI**: Burn Area RMSE
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+The first perimeter, at the start of the period, can be used as initial condition for the fire perimeter.
+A bias of $m$ acres, representing $B_{50}$% of burn area during the study period, will lead to a score of 50.00. The value of $m$ represents the benchmark difficulty (smaller $m$ means greater difficulty) and must be chosen by the community.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Name in Score Card | $m$ | $B_{50}$
+-----|--------------|--------------------|--------|---------
+FP29 | W1 | Burn Area RMSE W1 | 80,000 | 48%
+FP30 | W2 | Burn Area RMSE W2 | 5,000 | 20%
+FP31 | W3 | Burn Area RMSE W3 | 5,000 | 25%
+FP32 | W4 | Burn Area RMSE W4 | 17,000 | 30%
+
+## Weather stations
+
+### Dataset
+
+Weather stations datasets have been gathered from [Synoptics](https://synopticdata.com).
+All the stations available in the following bounding box have been processed:
+- south west: (38.4, -120.8)
+- north east: (39.0, -119.7)
+
+The following variables have been processed (following FireBench namespace):
+- air_temperature
+- relative_humidity
+- solar_radiation
+- fuel_moisture_content_10h
+- wind_direction
+- wind_gust
+- wind_speed
+
+```{note}
+If you want to process more variables or require new benchmarks for existing variables, please reach out to the FireBench team to integrate these changes into a future version of the benchmarks.
+```
+
+Some stations don't have data for the period W1 and have been excluded from the dataset.
+The list of excluded stations for missing data in the study period is:
+403_PG, 412_PG, 413_PG, F9934.
+Also, some stations did not meet the data quality criterion and have been excluded from the dataset.
+The list of excluded stations for data quality reasons is:
+AV833, BLCC1, C9148, COOPDAGN2, COOPMINN2, FOIC1, FPDC1, G0658, GEOC1, LNLC1, PFHC1, SBKC1, SLPC1, STAN2, UTRC1, WDFC1, XOHC1.
+
+Sensor height data has been extracted following the sensor height priority rules defined here.
+The current version of knowledge about sensor heights for the case weather stations are:
+- 10 stations with a complete dataset (sensor height found in the source file)
+- 98 stations with missing metadata
+- 21 stations skipped
+- 81 datasets with sensor height metadata
+- 0 datasets from trusted stations from the FireBench database
+- 0 datasets from trusted history from the FireBench database
+- 5 datasets from the FireBench provider default database
+- 394 datasets using FireBench default metadata
+
+Therefore, 81 datasets are considered trusted and will be used in the benchmarks `trusted source only` (TSO).
+All 399 datasets are used in benchmarks "all sources".
+
+```{note}
+If you have information about sensor height and want to help increase the number of trusted datasets, please get in touch with the FireBench Team.
+```
+
+Weather stations are stored in the HDF5 file using their STID.
+
+### Benchmarks
+
+See Key Performance Indicator (KPI) and normalization definitions [here](../../metrics/index.md).
+
+#### Air temperature
+
+**Short IDs**: See Table
+**KPI**: Air temperature MAE/RMSE/Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+Each metric (MAE, RMSE, Bias) is calculated for each station for both model and observational dataset for a specified period. Then we apply summary statistics (*e.g.*, min, mean, Q3) across all available weather stations before applying the normalization.
+Implementation of metrics are `firebench.metrics.stats.mae`, `firebench.metrics.stats.rmse`, `firebench.metrics.stats.bias`.
+Datasets are converted into `degC` for comparison.
+The normalization parameter $m$ sets which KPI value gives a Score of 50. It represents the difficulty of the benchmark.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Summary stats func | Name in Score Card | $m$ | trusted source only
+------|--------------|--------------------|-------------------------|---------|--------------------
+WX001 | W1 | MAE | Air temp MAE min W1 TSO | 5.0 degC | False
+WX002 | W1 | MAE | Air temp MAE mean W1 TSO | 5.0 degC | False
+WX003 | W1 | MAE | Air temp MAE max W1 TSO | 5.0 degC | False
+WX004 | W1 | MAE | Air temp MAE min W1 | 5.0 degC | True
+WX005 | W1 | MAE | Air temp MAE mean W1 | 5.0 degC | True
+WX006 | W1 | MAE | Air temp MAE max W1 | 5.0 degC | True
+WX007 | W1 | RMSE | Air temp RMSE min W1 TSO | 5.0 degC | False
+WX008 | W1 | RMSE | Air temp RMSE mean W1 TSO | 5.0 degC | False
+WX009 | W1 | RMSE | Air temp RMSE max W1 TSO | 5.0 degC | False
+WX010 | W1 | RMSE | Air temp RMSE min W1 | 5.0 degC | True
+WX011 | W1 | RMSE | Air temp RMSE mean W1 | 5.0 degC | True
+WX012 | W1 | RMSE | Air temp RMSE max W1 | 5.0 degC | True
+WX013 | W1 | Bias | Air temp Bias min W1 TSO | 5.0 degC | False
+WX014 | W1 | Bias | Air temp Bias mean W1 TSO | 5.0 degC | False
+WX015 | W1 | Bias | Air temp Bias max W1 TSO | 5.0 degC | False
+WX016 | W1 | Bias | Air temp Bias min W1 | 5.0 degC | True
+WX017 | W1 | Bias | Air temp Bias mean W1 | 5.0 degC | True
+WX018 | W1 | Bias | Air temp Bias max W1 | 5.0 degC | True
+WX019 | W2 | MAE | Air temp MAE min W2 TSO | 5.0 degC | False
+WX020 | W2 | MAE | Air temp MAE mean W2 TSO | 5.0 degC | False
+WX021 | W2 | MAE | Air temp MAE max W2 TSO | 5.0 degC | False
+WX022 | W2 | MAE | Air temp MAE min W2 | 5.0 degC | True
+WX023 | W2 | MAE | Air temp MAE mean W2 | 5.0 degC | True
+WX024 | W2 | MAE | Air temp MAE max W2 | 5.0 degC | True
+WX025 | W2 | RMSE | Air temp RMSE min W2 TSO | 5.0 degC | False
+WX026 | W2 | RMSE | Air temp RMSE mean W2 TSO | 5.0 degC | False
+WX027 | W2 | RMSE | Air temp RMSE max W2 TSO | 5.0 degC | False
+WX028 | W2 | RMSE | Air temp RMSE min W2 | 5.0 degC | True
+WX029 | W2 | RMSE | Air temp RMSE mean W2 | 5.0 degC | True
+WX030 | W2 | RMSE | Air temp RMSE max W2 | 5.0 degC | True
+WX031 | W2 | Bias | Air temp Bias min W2 TSO | 5.0 degC | False
+WX032 | W2 | Bias | Air temp Bias mean W2 TSO | 5.0 degC | False
+WX033 | W2 | Bias | Air temp Bias max W2 TSO | 5.0 degC | False
+WX034 | W2 | Bias | Air temp Bias min W2 | 5.0 degC | True
+WX035 | W2 | Bias | Air temp Bias mean W2 | 5.0 degC | True
+WX036 | W2 | Bias | Air temp Bias max W2 | 5.0 degC | True
+WX037 | W3 | MAE | Air temp MAE min W3 TSO | 5.0 degC | False
+WX038 | W3 | MAE | Air temp MAE mean W3 TSO | 5.0 degC | False
+WX039 | W3 | MAE | Air temp MAE max W3 TSO | 5.0 degC | False
+WX040 | W3 | MAE | Air temp MAE min W3 | 5.0 degC | True
+WX041 | W3 | MAE | Air temp MAE mean W3 | 5.0 degC | True
+WX042 | W3 | MAE | Air temp MAE max W3 | 5.0 degC | True
+WX043 | W3 | RMSE | Air temp RMSE min W3 TSO | 5.0 degC | False
+WX044 | W3 | RMSE | Air temp RMSE mean W3 TSO | 5.0 degC | False
+WX045 | W3 | RMSE | Air temp RMSE max W3 TSO | 5.0 degC | False
+WX046 | W3 | RMSE | Air temp RMSE min W3 | 5.0 degC | True
+WX047 | W3 | RMSE | Air temp RMSE mean W3 | 5.0 degC | True
+WX048 | W3 | RMSE | Air temp RMSE max W3 | 5.0 degC | True
+WX049 | W3 | Bias | Air temp Bias min W3 TSO | 5.0 degC | False
+WX050 | W3 | Bias | Air temp Bias mean W3 TSO | 5.0 degC | False
+WX051 | W3 | Bias | Air temp Bias max W3 TSO | 5.0 degC | False
+WX052 | W3 | Bias | Air temp Bias min W3 | 5.0 degC | True
+WX053 | W3 | Bias | Air temp Bias mean W3 | 5.0 degC | True
+WX054 | W3 | Bias | Air temp Bias max W3 | 5.0 degC | True
+WX055 | W4 | MAE | Air temp MAE min W4 TSO | 5.0 degC | False
+WX056 | W4 | MAE | Air temp MAE mean W4 TSO | 5.0 degC | False
+WX057 | W4 | MAE | Air temp MAE max W4 TSO | 5.0 degC | False
+WX058 | W4 | MAE | Air temp MAE min W4 | 5.0 degC | True
+WX059 | W4 | MAE | Air temp MAE mean W4 | 5.0 degC | True
+WX060 | W4 | MAE | Air temp MAE max W4 | 5.0 degC | True
+WX061 | W4 | RMSE | Air temp RMSE min W4 TSO | 5.0 degC | False
+WX062 | W4 | RMSE | Air temp RMSE mean W4 TSO | 5.0 degC | False
+WX063 | W4 | RMSE | Air temp RMSE max W4 TSO | 5.0 degC | False
+WX064 | W4 | RMSE | Air temp RMSE min W4 | 5.0 degC | True
+WX065 | W4 | RMSE | Air temp RMSE mean W4 | 5.0 degC | True
+WX066 | W4 | RMSE | Air temp RMSE max W4 | 5.0 degC | True
+WX067 | W4 | Bias | Air temp Bias min W4 TSO | 5.0 degC | False
+WX068 | W4 | Bias | Air temp Bias mean W4 TSO | 5.0 degC | False
+WX069 | W4 | Bias | Air temp Bias max W4 TSO | 5.0 degC | False
+WX070 | W4 | Bias | Air temp Bias min W4 | 5.0 degC | True
+WX071 | W4 | Bias | Air temp Bias mean W4 | 5.0 degC | True
+WX072 | W4 | Bias | Air temp Bias max W4 | 5.0 degC | True
+
+#### Relative Humidity
+
+**Short IDs**: See Table
+**KPI**: Relative humidity MAE/RMSE/Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+Each metric (MAE, RMSE, Bias) is calculated for each station for both model and observational dataset for a specified period. Then we apply summary statistics (*e.g.*, min, mean, Q3) across all available weather stations before applying the normalization.
+Implementation of metrics are `firebench.metrics.stats.mae`, `firebench.metrics.stats.rmse`, `firebench.metrics.stats.bias`.
+Datasets are converted into `percent` for comparison.
+The normalization parameter $m$ sets which KPI value gives a Score of 50. It represents the difficulty of the benchmark.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Summary stats func | Name in Score Card | $m$ | trusted source only
+------|--------------|--------------------|-------------------------|---------|--------------------
+WX073 | W1 | MAE | RH MAE min W1 TSO | 15.0 percent | False
+WX074 | W1 | MAE | RH MAE mean W1 TSO | 15.0 percent | False
+WX075 | W1 | MAE | RH MAE max W1 TSO | 15.0 percent | False
+WX076 | W1 | MAE | RH MAE min W1 | 15.0 percent | True
+WX077 | W1 | MAE | RH MAE mean W1 | 15.0 percent | True
+WX078 | W1 | MAE | RH MAE max W1 | 15.0 percent | True
+WX079 | W1 | RMSE | RH RMSE min W1 TSO | 15.0 percent | False
+WX080 | W1 | RMSE | RH RMSE mean W1 TSO | 15.0 percent | False
+WX081 | W1 | RMSE | RH RMSE max W1 TSO | 15.0 percent | False
+WX082 | W1 | RMSE | RH RMSE min W1 | 15.0 percent | True
+WX083 | W1 | RMSE | RH RMSE mean W1 | 15.0 percent | True
+WX084 | W1 | RMSE | RH RMSE max W1 | 15.0 percent | True
+WX085 | W1 | Bias | RH Bias min W1 TSO | 15.0 percent | False
+WX086 | W1 | Bias | RH Bias mean W1 TSO | 15.0 percent | False
+WX087 | W1 | Bias | RH Bias max W1 TSO | 15.0 percent | False
+WX088 | W1 | Bias | RH Bias min W1 | 15.0 percent | True
+WX089 | W1 | Bias | RH Bias mean W1 | 15.0 percent | True
+WX090 | W1 | Bias | RH Bias max W1 | 15.0 percent | True
+WX091 | W2 | MAE | RH MAE min W2 TSO | 15.0 percent | False
+WX092 | W2 | MAE | RH MAE mean W2 TSO | 15.0 percent | False
+WX093 | W2 | MAE | RH MAE max W2 TSO | 15.0 percent | False
+WX094 | W2 | MAE | RH MAE min W2 | 15.0 percent | True
+WX095 | W2 | MAE | RH MAE mean W2 | 15.0 percent | True
+WX096 | W2 | MAE | RH MAE max W2 | 15.0 percent | True
+WX097 | W2 | RMSE | RH RMSE min W2 TSO | 15.0 percent | False
+WX098 | W2 | RMSE | RH RMSE mean W2 TSO | 15.0 percent | False
+WX099 | W2 | RMSE | RH RMSE max W2 TSO | 15.0 percent | False
+WX100 | W2 | RMSE | RH RMSE min W2 | 15.0 percent | True
+WX101 | W2 | RMSE | RH RMSE mean W2 | 15.0 percent | True
+WX102 | W2 | RMSE | RH RMSE max W2 | 15.0 percent | True
+WX103 | W2 | Bias | RH Bias min W2 TSO | 15.0 percent | False
+WX104 | W2 | Bias | RH Bias mean W2 TSO | 15.0 percent | False
+WX105 | W2 | Bias | RH Bias max W2 TSO | 15.0 percent | False
+WX106 | W2 | Bias | RH Bias min W2 | 15.0 percent | True
+WX107 | W2 | Bias | RH Bias mean W2 | 15.0 percent | True
+WX108 | W2 | Bias | RH Bias max W2 | 15.0 percent | True
+WX109 | W3 | MAE | RH MAE min W3 TSO | 15.0 percent | False
+WX110 | W3 | MAE | RH MAE mean W3 TSO | 15.0 percent | False
+WX111 | W3 | MAE | RH MAE max W3 TSO | 15.0 percent | False
+WX112 | W3 | MAE | RH MAE min W3 | 15.0 percent | True
+WX113 | W3 | MAE | RH MAE mean W3 | 15.0 percent | True
+WX114 | W3 | MAE | RH MAE max W3 | 15.0 percent | True
+WX115 | W3 | RMSE | RH RMSE min W3 TSO | 15.0 percent | False
+WX116 | W3 | RMSE | RH RMSE mean W3 TSO | 15.0 percent | False
+WX117 | W3 | RMSE | RH RMSE max W3 TSO | 15.0 percent | False
+WX118 | W3 | RMSE | RH RMSE min W3 | 15.0 percent | True
+WX119 | W3 | RMSE | RH RMSE mean W3 | 15.0 percent | True
+WX120 | W3 | RMSE | RH RMSE max W3 | 15.0 percent | True
+WX121 | W3 | Bias | RH Bias min W3 TSO | 15.0 percent | False
+WX122 | W3 | Bias | RH Bias mean W3 TSO | 15.0 percent | False
+WX123 | W3 | Bias | RH Bias max W3 TSO | 15.0 percent | False
+WX124 | W3 | Bias | RH Bias min W3 | 15.0 percent | True
+WX125 | W3 | Bias | RH Bias mean W3 | 15.0 percent | True
+WX126 | W3 | Bias | RH Bias max W3 | 15.0 percent | True
+WX127 | W4 | MAE | RH MAE min W4 TSO | 15.0 percent | False
+WX128 | W4 | MAE | RH MAE mean W4 TSO | 15.0 percent | False
+WX129 | W4 | MAE | RH MAE max W4 TSO | 15.0 percent | False
+WX130 | W4 | MAE | RH MAE min W4 | 15.0 percent | True
+WX131 | W4 | MAE | RH MAE mean W4 | 15.0 percent | True
+WX132 | W4 | MAE | RH MAE max W4 | 15.0 percent | True
+WX133 | W4 | RMSE | RH RMSE min W4 TSO | 15.0 percent | False
+WX134 | W4 | RMSE | RH RMSE mean W4 TSO | 15.0 percent | False
+WX135 | W4 | RMSE | RH RMSE max W4 TSO | 15.0 percent | False
+WX136 | W4 | RMSE | RH RMSE min W4 | 15.0 percent | True
+WX137 | W4 | RMSE | RH RMSE mean W4 | 15.0 percent | True
+WX138 | W4 | RMSE | RH RMSE max W4 | 15.0 percent | True
+WX139 | W4 | Bias | RH Bias min W4 TSO | 15.0 percent | False
+WX140 | W4 | Bias | RH Bias mean W4 TSO | 15.0 percent | False
+WX141 | W4 | Bias | RH Bias max W4 TSO | 15.0 percent | False
+WX142 | W4 | Bias | RH Bias min W4 | 15.0 percent | True
+WX143 | W4 | Bias | RH Bias mean W4 | 15.0 percent | True
+WX144 | W4 | Bias | RH Bias max W4 | 15.0 percent | True
+
+#### Wind Speed
+
+**Short IDs**: See Table
+**KPI**: Wind Speed MAE/RMSE/Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+Each metric (MAE, RMSE, Bias) is calculated for each station for both model and observational dataset for a specified period. Then we apply summary statistics (*e.g.*, min, mean, Q3) across all available weather stations before applying the normalization.
+Implementation of metrics are `firebench.metrics.stats.mae`, `firebench.metrics.stats.rmse`, `firebench.metrics.stats.bias`.
+Datasets are converted into `m/s` for comparison.
+The normalization parameter $m$ sets which KPI value gives a Score of 50. It represents the difficulty of the benchmark.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Summary stats func | Name in Score Card | $m$ | trusted source only
+------|--------------|--------------------|-------------------------|---------|--------------------
+WX145 | W1 | MAE | Wind Speed MAE min W1 TSO | 5.0 m/s | False
+WX146 | W1 | MAE | Wind Speed MAE mean W1 TSO | 5.0 m/s | False
+WX147 | W1 | MAE | Wind Speed MAE max W1 TSO | 5.0 m/s | False
+WX148 | W1 | MAE | Wind Speed MAE min W1 | 5.0 m/s | True
+WX149 | W1 | MAE | Wind Speed MAE mean W1 | 5.0 m/s | True
+WX150 | W1 | MAE | Wind Speed MAE max W1 | 5.0 m/s | True
+WX151 | W1 | RMSE | Wind Speed RMSE min W1 TSO | 5.0 m/s | False
+WX152 | W1 | RMSE | Wind Speed RMSE mean W1 TSO | 5.0 m/s | False
+WX153 | W1 | RMSE | Wind Speed RMSE max W1 TSO | 5.0 m/s | False
+WX154 | W1 | RMSE | Wind Speed RMSE min W1 | 5.0 m/s | True
+WX155 | W1 | RMSE | Wind Speed RMSE mean W1 | 5.0 m/s | True
+WX156 | W1 | RMSE | Wind Speed RMSE max W1 | 5.0 m/s | True
+WX157 | W1 | Bias | Wind Speed Bias min W1 TSO | 5.0 m/s | False
+WX158 | W1 | Bias | Wind Speed Bias mean W1 TSO | 5.0 m/s | False
+WX159 | W1 | Bias | Wind Speed Bias max W1 TSO | 5.0 m/s | False
+WX160 | W1 | Bias | Wind Speed Bias min W1 | 5.0 m/s | True
+WX161 | W1 | Bias | Wind Speed Bias mean W1 | 5.0 m/s | True
+WX162 | W1 | Bias | Wind Speed Bias max W1 | 5.0 m/s | True
+WX163 | W2 | MAE | Wind Speed MAE min W2 TSO | 5.0 m/s | False
+WX164 | W2 | MAE | Wind Speed MAE mean W2 TSO | 5.0 m/s | False
+WX165 | W2 | MAE | Wind Speed MAE max W2 TSO | 5.0 m/s | False
+WX166 | W2 | MAE | Wind Speed MAE min W2 | 5.0 m/s | True
+WX167 | W2 | MAE | Wind Speed MAE mean W2 | 5.0 m/s | True
+WX168 | W2 | MAE | Wind Speed MAE max W2 | 5.0 m/s | True
+WX169 | W2 | RMSE | Wind Speed RMSE min W2 TSO | 5.0 m/s | False
+WX170 | W2 | RMSE | Wind Speed RMSE mean W2 TSO | 5.0 m/s | False
+WX171 | W2 | RMSE | Wind Speed RMSE max W2 TSO | 5.0 m/s | False
+WX172 | W2 | RMSE | Wind Speed RMSE min W2 | 5.0 m/s | True
+WX173 | W2 | RMSE | Wind Speed RMSE mean W2 | 5.0 m/s | True
+WX174 | W2 | RMSE | Wind Speed RMSE max W2 | 5.0 m/s | True
+WX175 | W2 | Bias | Wind Speed Bias min W2 TSO | 5.0 m/s | False
+WX176 | W2 | Bias | Wind Speed Bias mean W2 TSO | 5.0 m/s | False
+WX177 | W2 | Bias | Wind Speed Bias max W2 TSO | 5.0 m/s | False
+WX178 | W2 | Bias | Wind Speed Bias min W2 | 5.0 m/s | True
+WX179 | W2 | Bias | Wind Speed Bias mean W2 | 5.0 m/s | True
+WX180 | W2 | Bias | Wind Speed Bias max W2 | 5.0 m/s | True
+WX181 | W3 | MAE | Wind Speed MAE min W3 TSO | 5.0 m/s | False
+WX182 | W3 | MAE | Wind Speed MAE mean W3 TSO | 5.0 m/s | False
+WX183 | W3 | MAE | Wind Speed MAE max W3 TSO | 5.0 m/s | False
+WX184 | W3 | MAE | Wind Speed MAE min W3 | 5.0 m/s | True
+WX185 | W3 | MAE | Wind Speed MAE mean W3 | 5.0 m/s | True
+WX186 | W3 | MAE | Wind Speed MAE max W3 | 5.0 m/s | True
+WX187 | W3 | RMSE | Wind Speed RMSE min W3 TSO | 5.0 m/s | False
+WX188 | W3 | RMSE | Wind Speed RMSE mean W3 TSO | 5.0 m/s | False
+WX189 | W3 | RMSE | Wind Speed RMSE max W3 TSO | 5.0 m/s | False
+WX190 | W3 | RMSE | Wind Speed RMSE min W3 | 5.0 m/s | True
+WX191 | W3 | RMSE | Wind Speed RMSE mean W3 | 5.0 m/s | True
+WX192 | W3 | RMSE | Wind Speed RMSE max W3 | 5.0 m/s | True
+WX193 | W3 | Bias | Wind Speed Bias min W3 TSO | 5.0 m/s | False
+WX194 | W3 | Bias | Wind Speed Bias mean W3 TSO | 5.0 m/s | False
+WX195 | W3 | Bias | Wind Speed Bias max W3 TSO | 5.0 m/s | False
+WX196 | W3 | Bias | Wind Speed Bias min W3 | 5.0 m/s | True
+WX197 | W3 | Bias | Wind Speed Bias mean W3 | 5.0 m/s | True
+WX198 | W3 | Bias | Wind Speed Bias max W3 | 5.0 m/s | True
+WX199 | W4 | MAE | Wind Speed MAE min W4 TSO | 5.0 m/s | False
+WX200 | W4 | MAE | Wind Speed MAE mean W4 TSO | 5.0 m/s | False
+WX201 | W4 | MAE | Wind Speed MAE max W4 TSO | 5.0 m/s | False
+WX202 | W4 | MAE | Wind Speed MAE min W4 | 5.0 m/s | True
+WX203 | W4 | MAE | Wind Speed MAE mean W4 | 5.0 m/s | True
+WX204 | W4 | MAE | Wind Speed MAE max W4 | 5.0 m/s | True
+WX205 | W4 | RMSE | Wind Speed RMSE min W4 TSO | 5.0 m/s | False
+WX206 | W4 | RMSE | Wind Speed RMSE mean W4 TSO | 5.0 m/s | False
+WX207 | W4 | RMSE | Wind Speed RMSE max W4 TSO | 5.0 m/s | False
+WX208 | W4 | RMSE | Wind Speed RMSE min W4 | 5.0 m/s | True
+WX209 | W4 | RMSE | Wind Speed RMSE mean W4 | 5.0 m/s | True
+WX210 | W4 | RMSE | Wind Speed RMSE max W4 | 5.0 m/s | True
+WX211 | W4 | Bias | Wind Speed Bias min W4 TSO | 5.0 m/s | False
+WX212 | W4 | Bias | Wind Speed Bias mean W4 TSO | 5.0 m/s | False
+WX213 | W4 | Bias | Wind Speed Bias max W4 TSO | 5.0 m/s | False
+WX214 | W4 | Bias | Wind Speed Bias min W4 | 5.0 m/s | True
+WX215 | W4 | Bias | Wind Speed Bias mean W4 | 5.0 m/s | True
+WX216 | W4 | Bias | Wind Speed Bias max W4 | 5.0 m/s | True
+
+#### Wind Direction
+
+**Short IDs**: See Table
+**KPI**: Wind Direction circular Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+Each metric is calculated for each station for both model and observational dataset for a specified period. Then we apply summary statistics (*e.g.*, min, mean, Q3) across all available weather stations before applying the normalization.
+Implementation of metrics are `firebench.metrics.stats.circular_bias_deg`.
+Datasets are converted into `degree` for comparison.
+The normalization parameter $m$ sets which KPI value gives a Score of 50. It represents the difficulty of the benchmark.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Summary stats func | Name in Score Card | $m$ | trusted source only
+------|--------------|--------------------|-------------------------|---------|--------------------
+WX217 | W1 | circular bias | Wind Direction circular bias min W1 TSO | 45.0 degree | False
+WX218 | W1 | circular bias | Wind Direction circular bias mean W1 TSO | 45.0 degree | False
+WX219 | W1 | circular bias | Wind Direction circular bias max W1 TSO | 45.0 degree | False
+WX220 | W1 | circular bias | Wind Direction circular bias min W1 | 45.0 degree | True
+WX221 | W1 | circular bias | Wind Direction circular bias mean W1 | 45.0 degree | True
+WX222 | W1 | circular bias | Wind Direction circular bias max W1 | 45.0 degree | True
+WX223 | W2 | circular bias | Wind Direction circular bias min W2 TSO | 45.0 degree | False
+WX224 | W2 | circular bias | Wind Direction circular bias mean W2 TSO | 45.0 degree | False
+WX225 | W2 | circular bias | Wind Direction circular bias max W2 TSO | 45.0 degree | False
+WX226 | W2 | circular bias | Wind Direction circular bias min W2 | 45.0 degree | True
+WX227 | W2 | circular bias | Wind Direction circular bias mean W2 | 45.0 degree | True
+WX228 | W2 | circular bias | Wind Direction circular bias max W2 | 45.0 degree | True
+WX229 | W3 | circular bias | Wind Direction circular bias min W3 TSO | 45.0 degree | False
+WX230 | W3 | circular bias | Wind Direction circular bias mean W3 TSO | 45.0 degree | False
+WX231 | W3 | circular bias | Wind Direction circular bias max W3 TSO | 45.0 degree | False
+WX232 | W3 | circular bias | Wind Direction circular bias min W3 | 45.0 degree | True
+WX233 | W3 | circular bias | Wind Direction circular bias mean W3 | 45.0 degree | True
+WX234 | W3 | circular bias | Wind Direction circular bias max W3 | 45.0 degree | True
+WX235 | W4 | circular bias | Wind Direction circular bias min W4 TSO | 45.0 degree | False
+WX236 | W4 | circular bias | Wind Direction circular bias mean W4 TSO | 45.0 degree | False
+WX237 | W4 | circular bias | Wind Direction circular bias max W4 TSO | 45.0 degree | False
+WX238 | W4 | circular bias | Wind Direction circular bias min W4 | 45.0 degree | True
+WX239 | W4 | circular bias | Wind Direction circular bias mean W4 | 45.0 degree | True
+WX240 | W4 | circular bias | Wind Direction circular bias max W4 | 45.0 degree | True
+
+#### Fuel Moisture Content 10h
+
+**Short IDs**: See Table
+**KPI**: FMC 10h MAE/RMSE/Bias
+**Normalization**: Symmetric Exponential Open Normalization ($m$ value in Table)
+**Name in Score Card**: See Table
+Each metric is calculated for each station for both model and observational dataset for a specified period. Then we apply summary statistics (*e.g.*, min, mean, Q3) across all available weather stations before applying the normalization.
+Implementation of metrics are `firebench.metrics.stats.mae`, `firebench.metrics.stats.rmse`, `firebench.metrics.stats.bias`.
+Datasets are converted into `percent` for comparison.
+The normalization parameter $m$ sets which KPI value gives a Score of 50. It represents the difficulty of the benchmark.
+
+The following Table gives the correspondence between the benchmark ID and the study period:
+
+ID | Study period | Summary stats func | Name in Score Card | $m$ | trusted source only
+------|--------------|--------------------|-------------------------|---------|--------------------
+WX241 | W1 | MAE | FMC 10h MAE min W1 TSO | 5.0 percent | False
+WX242 | W1 | MAE | FMC 10h MAE mean W1 TSO | 5.0 percent | False
+WX243 | W1 | MAE | FMC 10h MAE max W1 TSO | 5.0 percent | False
+WX244 | W1 | MAE | FMC 10h MAE min W1 | 5.0 percent | True
+WX245 | W1 | MAE | FMC 10h MAE mean W1 | 5.0 percent | True
+WX246 | W1 | MAE | FMC 10h MAE max W1 | 5.0 percent | True
+WX247 | W1 | RMSE | FMC 10h RMSE min W1 TSO | 5.0 percent | False
+WX248 | W1 | RMSE | FMC 10h RMSE mean W1 TSO | 5.0 percent | False
+WX249 | W1 | RMSE | FMC 10h RMSE max W1 TSO | 5.0 percent | False
+WX250 | W1 | RMSE | FMC 10h RMSE min W1 | 5.0 percent | True
+WX251 | W1 | RMSE | FMC 10h RMSE mean W1 | 5.0 percent | True
+WX252 | W1 | RMSE | FMC 10h RMSE max W1 | 5.0 percent | True
+WX253 | W1 | Bias | FMC 10h Bias min W1 TSO | 5.0 percent | False
+WX254 | W1 | Bias | FMC 10h Bias mean W1 TSO | 5.0 percent | False
+WX255 | W1 | Bias | FMC 10h Bias max W1 TSO | 5.0 percent | False
+WX256 | W1 | Bias | FMC 10h Bias min W1 | 5.0 percent | True
+WX257 | W1 | Bias | FMC 10h Bias mean W1 | 5.0 percent | True
+WX258 | W1 | Bias | FMC 10h Bias max W1 | 5.0 percent | True
+WX259 | W2 | MAE | FMC 10h MAE min W2 TSO | 5.0 percent | False
+WX260 | W2 | MAE | FMC 10h MAE mean W2 TSO | 5.0 percent | False
+WX261 | W2 | MAE | FMC 10h MAE max W2 TSO | 5.0 percent | False
+WX262 | W2 | MAE | FMC 10h MAE min W2 | 5.0 percent | True
+WX263 | W2 | MAE | FMC 10h MAE mean W2 | 5.0 percent | True
+WX264 | W2 | MAE | FMC 10h MAE max W2 | 5.0 percent | True
+WX265 | W2 | RMSE | FMC 10h RMSE min W2 TSO | 5.0 percent | False
+WX266 | W2 | RMSE | FMC 10h RMSE mean W2 TSO | 5.0 percent | False
+WX267 | W2 | RMSE | FMC 10h RMSE max W2 TSO | 5.0 percent | False
+WX268 | W2 | RMSE | FMC 10h RMSE min W2 | 5.0 percent | True
+WX269 | W2 | RMSE | FMC 10h RMSE mean W2 | 5.0 percent | True
+WX270 | W2 | RMSE | FMC 10h RMSE max W2 | 5.0 percent | True
+WX271 | W2 | Bias | FMC 10h Bias min W2 TSO | 5.0 percent | False
+WX272 | W2 | Bias | FMC 10h Bias mean W2 TSO | 5.0 percent | False
+WX273 | W2 | Bias | FMC 10h Bias max W2 TSO | 5.0 percent | False
+WX274 | W2 | Bias | FMC 10h Bias min W2 | 5.0 percent | True
+WX275 | W2 | Bias | FMC 10h Bias mean W2 | 5.0 percent | True
+WX276 | W2 | Bias | FMC 10h Bias max W2 | 5.0 percent | True
+WX277 | W3 | MAE | FMC 10h MAE min W3 TSO | 5.0 percent | False
+WX278 | W3 | MAE | FMC 10h MAE mean W3 TSO | 5.0 percent | False
+WX279 | W3 | MAE | FMC 10h MAE max W3 TSO | 5.0 percent | False
+WX280 | W3 | MAE | FMC 10h MAE min W3 | 5.0 percent | True
+WX281 | W3 | MAE | FMC 10h MAE mean W3 | 5.0 percent | True
+WX282 | W3 | MAE | FMC 10h MAE max W3 | 5.0 percent | True
+WX283 | W3 | RMSE | FMC 10h RMSE min W3 TSO | 5.0 percent | False
+WX284 | W3 | RMSE | FMC 10h RMSE mean W3 TSO | 5.0 percent | False
+WX285 | W3 | RMSE | FMC 10h RMSE max W3 TSO | 5.0 percent | False
+WX286 | W3 | RMSE | FMC 10h RMSE min W3 | 5.0 percent | True
+WX287 | W3 | RMSE | FMC 10h RMSE mean W3 | 5.0 percent | True
+WX288 | W3 | RMSE | FMC 10h RMSE max W3 | 5.0 percent | True
+WX289 | W3 | Bias | FMC 10h Bias min W3 TSO | 5.0 percent | False
+WX290 | W3 | Bias | FMC 10h Bias mean W3 TSO | 5.0 percent | False
+WX291 | W3 | Bias | FMC 10h Bias max W3 TSO | 5.0 percent | False
+WX292 | W3 | Bias | FMC 10h Bias min W3 | 5.0 percent | True
+WX293 | W3 | Bias | FMC 10h Bias mean W3 | 5.0 percent | True
+WX294 | W3 | Bias | FMC 10h Bias max W3 | 5.0 percent | True
+WX295 | W4 | MAE | FMC 10h MAE min W4 TSO | 5.0 percent | False
+WX296 | W4 | MAE | FMC 10h MAE mean W4 TSO | 5.0 percent | False
+WX297 | W4 | MAE | FMC 10h MAE max W4 TSO | 5.0 percent | False
+WX298 | W4 | MAE | FMC 10h MAE min W4 | 5.0 percent | True
+WX299 | W4 | MAE | FMC 10h MAE mean W4 | 5.0 percent | True
+WX300 | W4 | MAE | FMC 10h MAE max W4 | 5.0 percent | True
+WX301 | W4 | RMSE | FMC 10h RMSE min W4 TSO | 5.0 percent | False
+WX302 | W4 | RMSE | FMC 10h RMSE mean W4 TSO | 5.0 percent | False
+WX303 | W4 | RMSE | FMC 10h RMSE max W4 TSO | 5.0 percent | False
+WX304 | W4 | RMSE | FMC 10h RMSE min W4 | 5.0 percent | True
+WX305 | W4 | RMSE | FMC 10h RMSE mean W4 | 5.0 percent | True
+WX306 | W4 | RMSE | FMC 10h RMSE max W4 | 5.0 percent | True
+WX307 | W4 | Bias | FMC 10h Bias min W4 TSO | 5.0 percent | False
+WX308 | W4 | Bias | FMC 10h Bias mean W4 TSO | 5.0 percent | False
+WX309 | W4 | Bias | FMC 10h Bias max W4 TSO | 5.0 percent | False
+WX310 | W4 | Bias | FMC 10h Bias min W4 | 5.0 percent | True
+WX311 | W4 | Bias | FMC 10h Bias mean W4 | 5.0 percent | True
+WX312 | W4 | Bias | FMC 10h Bias max W4 | 5.0 percent | True
+
+## Requirements
+
+The following sections list the datasets' requirements to run the different benchmarks. When the benchmark script runs, each requirement is validated against the HDF5 file provided as input (from the model output/data the user wants to evaluate). If a requirement is met, each corresponding benchmark is run.
+Each requirement lists the required datasets/groups (as paths) and the mandatory attributes for each dataset/group.
+The current version of FireBench does not support more complex checks (e.g., array size and dtype).
+
+
+Requirement | Benchmarks
+---------------------- | -----------------
+R01 | BD01 to BD06
+R02 | SV01 to SV06
+R03 | FP01, FP05, FP09, FP13, FP17, FP21, FP25, FP29
+R04 | FP02, FP06, FP10, FP14, FP18, FP22, FP26, FP30
+R05 | FP03, FP07, FP11, FP15, FP19, FP23, FP27, FP31
+R06 | FP04, FP08, FP12, FP16, FP20, FP24, FP28, FP32
+R07 | CC01 to CC06
+R08 | WX001 to WX072
+R09 | WX073 to WX144
+R10 | WX145 to WX216
+R11 | WX217 to WX240
+R12 | WX241 to WX312
+
+### R01
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/points/building_damaged/building_damage` | units
+
+### R02
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/spatial_2d/Caldor_MTBS`| crs
+`/spatial_2d/Caldor_MTBS/fire_burn_severity`| units, _FillValue
+`/spatial_2d/Caldor_MTBS/position_lat`| units
+`/spatial_2d/Caldor_MTBS/position_lon`| units
+
+### R03
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/polygons/Caldor_2021-08-18T20:30-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-19T20:45-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-20T20:20-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-21T21:15-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-24T22:07-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-26T03:30-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-26T22:15-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-27T00:22-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-28T21:30-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-29T22:32-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-30T21:09-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-31T21:08-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-01T21:12-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-03T00:40-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-04T23:29-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-05T23:41-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-06T23:09-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-07T22:40-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-08T22:33-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-10T23:34-07:00`| rel_path, time
+
+Files (KML) at path defined in `rel_path` attributes must exist.
+
+### R04
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/polygons/Caldor_2021-08-20T20:20-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-21T21:15-07:00`| rel_path, time
+
+Files (KML) at path defined in `rel_path` attributes must exist.
+
+### R05
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/polygons/Caldor_2021-08-26T22:15-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-27T00:22-06:00`| rel_path, time
+`/polygons/Caldor_2021-08-28T21:30-06:00`| rel_path, time
+
+Files (KML) at path defined in `rel_path` attributes must exist.
+
+### R06
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/polygons/Caldor_2021-08-29T22:32-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-30T21:09-07:00`| rel_path, time
+`/polygons/Caldor_2021-08-31T21:08-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-01T21:12-07:00`| rel_path, time
+`/polygons/Caldor_2021-09-03T00:40-07:00`| rel_path, time
+
+Files (KML) at path defined in `rel_path` attributes must exist.
+
+### R07
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/spatial_2d/ravg_cc`| crs
+`/spatial_2d/ravg_cc/ravg_canopy_cover_loss`| units, _FillValue
+`/spatial_2d/ravg_cc/position_lat`| units
+`/spatial_2d/ravg_cc/position_lon`| units
+
+### R08
+Verify that the model and observational datasets contain the same weather station groups with the following datasets:
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/time_series/station_/time`| None
+`/time_series/station_/air_temperature`| None
+
+### R09
+Verify that the model and observational datasets contain the same weather station groups with the following datasets:
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/time_series/station_/time`| None
+`/time_series/station_/relative_humidity`| None
+
+### R10
+Verify that the model and observational datasets contain the same weather station groups with the following datasets:
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/time_series/station_/time`| None
+`/time_series/station_/wind_speed`| None
+
+### R11
+Verify that the model and observational datasets contain the same weather station groups with the following datasets:
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/time_series/station_/time`| None
+`/time_series/station_/wind_direction`| None
+
+### R12
+Verify that the model and observational datasets contain the same weather station groups with the following datasets:
+Mandatory group/dataset| Mandatory attributes
+---------------------- | --------------------
+`/time_series/station_/time`| None
+`/time_series/station_/fuel_moisture_content_10h`| None
+
+
+## Aggregation Schemes
+
+This section describes the weights used to aggregate KPI unit scores. More information about aggregation methods [here](../../metrics/score.md). If the aggregation scheme `0` is specified, then no aggregation is performed. Therefore, group scores and total scores are not computed.
+
+### Group definition
+
+All benchmarks have a default weight of 1 in each group. If custom weights are applied, refer to the custom weight Table.
+
+Weight precedence:
+- Default benchmark weight: 1
+- Group benchmark overrides: apply to all schemes unless overridden
+- Scheme benchmark overrides: apply only within that scheme and override everything else
+
+Group | Benchmark ID
+--------------------------- | ------------
+Building Damage | BD01 to BD06
+Burn Severity | SV01 to SV06
+Fire Perimeter W1 | FP01, FP05, FP09, FP13, FP17, FP21, FP25, FP29
+Fire Perimeter W2 | FP02, FP06, FP10, FP14, FP18, FP22, FP26, FP30
+Fire Perimeter W3 | FP03, FP07, FP11, FP15, FP19, FP23, FP27, FP31
+Fire Perimeter W4 | FP04, FP08, FP12, FP16, FP20, FP24, FP28, FP32
+Canopy Cover Loss | CC01 to CC06
+Air temperature W1 | WX001 to WX018
+Air temperature W2 | WX019 to WX036
+Air temperature W3 | WX037 to WX054
+Air temperature W4 | WX055 to WX072
+Relative humidity 10h W1 | WX073 to WX090
+Relative humidity 10h W2 | WX091 to WX108
+Relative humidity 10h W3 | WX109 to WX126
+Relative humidity 10h W4 | WX127 to WX144
+Wind speed W1 | WX145 to WX162
+Wind speed W2 | WX163 to WX180
+Wind speed W3 | WX181 to WX198
+Wind speed W4 | WX199 to WX216
+Wind direction W1 | WX217 to WX222
+Wind direction W2 | WX223 to WX228
+Wind direction W3 | WX229 to WX234
+Wind direction W4 | WX235 to WX240
+Fuel Moisture 10h W1 | WX241 to WX258
+Fuel Moisture 10h W2 | WX259 to WX276
+Fuel Moisture 10h W3 | WX277 to WX294
+Fuel Moisture 10h W4 | WX295 to WX312
+
+### Scheme A
+
+Scheme A contains all the groups with default weights. It can be used to evaluate complete model performance with balanced weighting.
+
+### Scheme B
+
+Scheme B contains only the building damage group. It is used to evaluate the model only on building damage benchmarks.
+
+Group | Group Weight
+---------------------- | ------------
+Building Damage | 1
+
+### Scheme CC
+
+Scheme CC contains only the canopy cover loss group. It is used to evaluate crown fire models.
+
+Group | Group Weight
+---------------------- | ------------
+Canopy Cover Loss | 1
+
+### Scheme FP
+
+Scheme FP contains only the fire perimeter groups. It is used to evaluate the model only on fire perimeter benchmarks for all of the study periods.
+
+Group | Group Weight
+---------------------- | ------------
+Fire Perimeter W1 | 1
+Fire Perimeter W2 | 1
+Fire Perimeter W3 | 1
+Fire Perimeter W4 | 1
+
+### Scheme short_all
+
+Scheme short_all contains all the groups except the groups relative to W1 study period. Therefore, the index i is in [2, 4].
+
+Group | Group Weight
+---------------------- | ------------
+Air Temp Wi | 1
+Building Damage | 1
+Burn Severity | 1
+Canopy Cover Loss | 1
+Fire Perimeter Wi | 1
+FMC 10h Wi | 1
+RH Wi | 1
+Wind Direction Wi | 1
+Wind Speed Wi | 1
+
+### Scheme S
+
+Scheme S contains only the burn severity group. It is used to evaluate the model only on building severity from MTBS benchmarks.
+
+Group | Group Weight
+---------------------- | ------------
+Burn Severity | 1
+
+### Scheme WXi
+
+Schemes WXi, for i in [1, 4], contains all the group related to weather stations for a specific study period (W1 to W4)
+
+Group | Group Weight
+---------------------- | ------------
+Air Temp Wi | 1
+FMC 10h Wi | 1
+RH Wi | 1
+Wind Direction Wi | 1
+Wind Speed Wi | 1
+
+### Scheme WX_short
+
+Scheme short_all contains all the groups except the groups relative to W1 study period and fire perimeter groups. Therefore, the index i is in [2, 4].
+
+Group | Group Weight
+---------------------- | ------------
+Air Temp Wi | 1
+Building Damage | 1
+Burn Severity | 1
+Canopy Cover Loss | 1
+FMC 10h Wi | 1
+RH Wi | 1
+Wind Direction Wi | 1
+Wind Speed Wi | 1
+
+## Notes
+
+- **Benchmark identifiers** consist of a *case ID* and a *short ID*, for example `FB001-BD01`. Throughout the documentation, the *short ID* alone (e.g. `BD01`) is used when the benchmark case is unambiguous, in order to improve readability. The *full identifier* (`FB001-BD01`) is used whenever the case context must be explicit, such as when comparing benchmarks across different cases.
+- Each file hash has been performed using `firebench.standardize.calculate_sha256`.
+- Collection of forecasts or reanalysis is authorized for the benchmark period (e.g., for fire perimeters) but has to be detailed in the model report attached to the Report sent back to the FireBench team for collection and validation of results.
+
+## Acknowledgment
+
+- We gratefully acknowledge [Synoptic](https://synopticdata.com) for granting permission to redistribute selected weather-station data as part of the FireBench benchmarking framework.
+- I would like to thank my colleague Muthu K. Selvaraj (WPI) for his help in this project.
\ No newline at end of file
diff --git a/docs/benchmarks/index.md b/docs/benchmarks/index.md
index d289158..98597af 100644
--- a/docs/benchmarks/index.md
+++ b/docs/benchmarks/index.md
@@ -1,21 +1,23 @@
# 6. Benchmarks list
This section groups the workflows, tests, and benchmarks proposed by FireBench.
-## Fire submodels
+## Events
```{toctree}
:maxdepth: 1
-fire_submodels/rate_of_spread/Anderson_2015_Validation/index.md
-fire_submodels/rate_of_spread/Computational_cost/index.md
-fire_submodels/rate_of_spread/Global_sensitivity_env/index.md
+California/01_Caldor.md
```
-## 2D Fire Spread models
-## 3D Coupled dynamics
+## Fire submodels
-## Fuel Moisture
+```{toctree}
+:maxdepth: 1
+
+fire_submodels/rate_of_spread/Anderson_2015_Validation/index.md
+fire_submodels/rate_of_spread/Computational_cost/index.md
+fire_submodels/rate_of_spread/Global_sensitivity_env/index.md
-## Risk/Danger
\ No newline at end of file
+```
\ No newline at end of file
diff --git a/docs/benchmarks_information/index.md b/docs/benchmarks_information/index.md
index da3d287..7898b59 100644
--- a/docs/benchmarks_information/index.md
+++ b/docs/benchmarks_information/index.md
@@ -1,246 +1,71 @@
# 3. Benchmarks information
-This section contains information on how to create a new benchmark during the call for benchmark period.
-
-
-## Benchmark proposal template
-
-The benchmark proposal can be a submitted as a PFF file, a markdown file, a LaTeX source file, or a shared Google Document.
-It is recommended to contain:
-
-```markdown
-# Benchmark Proposal: Title of the benchmark
-
-The title should be concise and descriptive.
-
-## Contributors
-List of contributors name(s), affiliation(s), and contact email(s) of the proposer(s). Optionally ORCID, GitHub handle, or project link
-
-## Tags
-Official `FireBench` tags (list available at the end of this page)
-It is recommended to add one *Metric type* tag, at least one *Model context* tag, and at least one *Application context* tag.
-These tags are important for referecing the proposed benchmark.
-Optional free tags or keywords are welcome.
-
-## Short description
-A 1-2 sentence overview of the benchmark goal, scope, and what is being tested.
-
-## Detailed description
-It should contain:
-- Scientific background and motiviation.
-- Description of the modeled process or scenario.
-- Relevance of the benchmark to real-world application or theoretical exploration.
-- Diagrams/schematics of the benchmark are welcome.
-
-## Data description
-- Input data:
- - Description of required input dataset (terrain, fuels, weather, etc.).
- - Indicate availability (Open source, proprietary with access restriction, not yet available). Open source is prefered.
- - Indicate if data is provided with the benchmark, if it can be access upon request for running the benchmark (under which conditions), and and if the data can be integrated within `FireBench` directly.
-- Expected output data:
- - Defined expected output fields and format
- - Ground truth availability (if applicable). Indicate if this data is provided with the benchmark or available upon request (under which conditions), and and if the data can be integrated within `FireBench` directly.
-
-## Initial conditions and configuration
-- Detailed description of the initial setup.
-- Simulation parameters or constant
-- Timeline or duration of the benchmark
-- Mesh properties
-
-## Metrics definition
-- Definition of primary metrics (RMSE, bias, runtime, etc.) and derived metrics (burned area agreement, time to ignition, statistical comparison of plumes, etc.)
-- Usage of existing `FireBench` post processing tools (or need for tools)
-- Units and interpretation.
-
-## Publication status
-- Is this benchmark:
- - linked to a publication (in review, published, preprint)?
- - embargoed until a specific date?
-- Citation to use (if applicable)
-
-## Licensing and Use Terms
-- License for any data or code provided
-- Attribution and reuse policy
-
-## Additional notes
-
-## Optional: Benchmark difficulty
-Optional indicator for difficulty to run this benchmark:
-- low: fast/approximate, educational or conceptual
-- medium: realistic inputs, moderate compute
-- high: high fidelity, coupled models, research grade
-```
-
-## Run a benchmark and submit your results
-This guide explains how to run an existing benchmark and submit your results to the `FireBench` community. The list of existing benchmarks is shared [here](https://docs.google.com/spreadsheets/d/1Ee2G6FgD-c-5fu-oPcsI3ApyQnPQvxZJwKqVOYqtj28/edit?usp=sharing).
-
-```markdown
-# FireBench Benchmark Execution Guidelines
-
-## 1. Before you start
-
-### Select a benchmark
-- Visit the submitted benchmarks registry
-- Choose a benchmark that:
- - Matches the capabilities of your model(s)
- - Has clear input data and metric definition
-
-### Review the Benchmark Page
-Read the benchmark:
-- Description and objectives
-- Input/output requirements
-- Metrics definition
-- Tags
-- Evaluation procedure
-- Data availability/licensing
-
-## 2. Prepare your Evaluation
-
-### Model setup
-Clearly document:
-- Your model(s) name(s), version (commit if available to share), and configuration
-- Any custom parameters, or simplification
-- Whether it is operational, experimental, ML-based, etc.
-
-### Input data processing
-- Follow the benchmark's instruction precisely
-- Document any necessary pre-processing (*e.g.* interpolation for resolution adjustment)
-- Avoid any optional processing
-- Confirm compatibility of coordinate system, units, formats
-
-### Output Requirements
-- Ensure your outputs match the required fields (document any special processing needed to obtain the requested outputs).
-- If uncertain, contact the benchmark proposer or the FireBench team
-
-## 3. Run the Benchmark
-- Run the simulation(s) as defined in the benchmark scenario.
-- Ensure reproducibility:
- - Fix seeds if stochastic components are used
- - Log software and hardward environment (*e.g.* CPU/GPU, OS)
- - Prefer containerization (*e.g.* Docker) is available
-
-## 4. Report Your Results
-Prepare a **benchmark evaluation report** using the following structure
-1. Title and benchmark ID
-Match the benchmark registry title.
-2. Contributors
-Name(s), affiliation(s), contact, ORCID
-3. Model description
-Type, versions, capabilities, known limitations, etc.
-4. Run configuration
-Inputs used, any modifications to benchmark setup, runtime
-5. Results
-Raw and unprocessed outputs, visuals (plot, contours, cross sections, etc.), and computed metrics (according to the benchmark definition).
-6. Interpretation
-Comment on model performance, strengths/weaknesses, unexpected behavior
-7. Reproducibility
-Link to code or container, software version, OS, runtime environment.
-
-Acceptable formats: PDF, markdown, or reStructuredText
-
-## 5. Submit Your Report
-Send your completed report alongside any important data voa one of the following:
-- Email: aurelien.costes@sjsu.edu
-```
-
-## List of tags
-
-### Metric type
-- 
-- 
-- 
-- 
-- 
-
-
-### Model context
-- 
-- 
-- 
-- 
-- 
-- 
-
-- 
-- 
-- 
-
-
-
-
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-- 
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+This section gives an overvies of the FireBench Benchmarking Process.
+
+1. **Collection of observational data**
+ FireBench collects and curates observational datasets related to fire across multiple scales, including:
+
+ * Large wildfire events
+ * Prescribed burns
+ * Laboratory-scale experiments
+
+2. **Diversity of observations**
+ Observational datasets may describe different aspects of fire-related phenomena, such as:
+
+ * Fire spread and progression
+ * Weather and atmospheric conditions
+ * Fuel properties
+ * Building damage and impacts
+
+3. **Standardization of observational data**
+ All observational datasets are standardized and stored in a **FireBench standard file format**.
+ This common format:
+
+ * Simplifies benchmarking operations
+ * Ensures consistency across datasets
+ * Centralizes heterogeneous observations under a single structure
+
+4. **Standardization of model outputs**
+ Evaluated model outputs are converted to the same standard file format using a **limited set of FireBench tools**.
+
+ * These tools ensure compatibility with the benchmarking framework
+ * Interested users should contact the FireBench team for access and guidance
+
+5. **Benchmark execution**
+ Once both:
+
+ * an observational standard file, and
+ * a model output standard file
+
+ are available, FireBench provides **benchmark scripts** (Python files) that:
+
+ * Run a predefined or custom set of benchmarks
+ * Compare model outputs against observations
+
+6. **Scorecard generation**
+ Each benchmark run produces a **scorecard** that summarizes evaluation results:
+
+ * Qualitative and quantitative performance indicators
+ * Delivered as both **JSON** (machine-readable) and **PDF** (human-readable) formats
+
+7. **Metrics and evaluation methodology**
+ The definitions of:
+
+ * Metrics
+ * Key Performance Indicators (KPIs)
+ * Normalization and aggregation functions
+
+ are described in detail in the documentation and are shared across all benchmarks for transparency and reproducibility.
+
+8. **Distribution of benchmarks**
+ Benchmark datasets and benchmark scripts are distributed via **Zenodo**.
+ Running the benchmarks requires the **FireBench Python library**.
+
+9. **Certification and authenticity (optional)**
+ At multiple stages of the process, the FireBench team can deliver a **certificate of authenticity** using hardware-based cryptographic methods.
+ These certificates can be used to:
+
+ * Authenticate datasets
+ * Certify benchmark executions
+ * Validate published benchmarking results
+
diff --git a/docs/changelog.md b/docs/changelog.md
index 2cae06e..0bf0533 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,9 +1,21 @@
-# 11. Changelog
+# 12. Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).
+## [0.8.0] - 2026 / 01 / 14
+### Added
+- 2021 Caldor case FB001 documentation and benchmarks (See Zenodo FireBench for release)
+- package `metrics`: contains kpi functions, metrics functions for perimeters, 1D datasets, confusion matrix.
+- package `standardize`: contains standardization functions for landfire, mtbs, ravg, synoptic, geotiff.
+- package `signing`: contains functions for certification (hardware encryption) and verification of certificates (`verify_certificate_in_dict`, `verify_certificates_in_h5`). Verification functions require `gpg` (not needed for benchmarking functions).
+- Public Key for certificates verification
+
+### Documentation
+- FireBench Standard file format
+- Add Key Performance Indicators, Metrics, Score and Normalization information
+
## [0.7.0] - 2025 / 08 / 09
### Added
- `anderson_2015_stats`: Plot statistics from the Anderson 2015 dataset.
diff --git a/docs/content.md b/docs/content.md
index a8f6dbf..b8ac72c 100644
--- a/docs/content.md
+++ b/docs/content.md
@@ -1,6 +1,7 @@
# 9. Content of the package
This page lists fire sub-models included in the package, the datasets and the tools.
+*This list is not up top date for 0.8+*.
## Fire submodels
### Fuel models
diff --git a/docs/dependencies.md b/docs/dependencies.md
index eaaebf4..8fa08d7 100644
--- a/docs/dependencies.md
+++ b/docs/dependencies.md
@@ -1,14 +1,18 @@
-# 12. Dependencies
+# 13. Dependencies
## Required
+- geopandas < 2.0
- h5py < 4.0
+- hdf5plugin >= 6.0
+- matplotlib > 3.8
- numpy < 3.0
- pint < 1.0
+- pyproj < 4.0
+- rasterio < 2.0
+- reportlab < 5.0
- SALib < 2.0
- scipy < 2.0
-- geopandas < 2.0
-- matplotlib > 3.8
## Optional
These dependencies are required for advanced use (testing, offline generation of documentation, etc.)
diff --git a/docs/index.md b/docs/index.md
index 7a69197..91d7ef9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,50 +5,8 @@
**FireBench** is an open-source Python library for the **systematic benchmarking and intercomparison of fire models**. As fire modeling becomes more sophisticated—spanning physics-based, empirical, and data-driven approaches—there remains a critical need for **standardized, transparent evaluation** of their capabilities.
-FireBench addresses this gap by providing a flexible framework to assess fire models across key dimensions:
-
-- 🔍 **Accuracy** — How precisely the model predicts fire front progression and plume behavior.
-- ⚙️ **Efficiency** — The computational cost required for simulations under standardized conditions.
-- 🎯 **Sensitivity** — How model outputs respond to variations in inputs; crucial for calibration and uncertainty analysis.
-- 📈 **Validity Domain** — The range of environmental and operational conditions where the model remains reliable.
-- 🔗 **Inter-Compatibility** — The ease of integration with other tools and workflows in fire or environmental modeling chains.
-
-FireBench supports a **dual evaluation strategy**:
-- **Intercomparison** of models under controlled scenarios, even in the absence of observational data.
-- **Benchmarking** against validation datasets where ground truth or reference outputs are available.
-
-All benchmark results are **archived in a dedicated database**, enabling reproducibility, transparency, and cumulative progress in fire modeling—for both scientific research and operational decision-making.
-
-## 🔥 Call for Benchmarks 2025
-
-We invite the community to contribute to the **FireBench Benchmarking Campaign 2025**. Researchers, engineers, and model developers are encouraged to propose new benchmarks that evaluate components or full workflows of fire models.
-
-Benchmarks may cover:
-- Specific fire sub-models (e.g., **rate of spread**, **plume dynamics**, **heat flux**, **terrain/wind interpolation**)
-- 2D or 3D fire dynamics
-- Use-case-driven scenarios (e.g., **WUI**, **risk assessment**, **fuel moisture effects**)
-
-### 📅 2025 Benchmarking Timeline
-
-| Phase | Deadline |
-|-------------------------------|----------------------|
-| 📥 Benchmark Proposal Submission | **July 31, 2025** |
-| 🔍 Benchmark Review & Feedback | **August 31, 2025** |
-| 🚀 Benchmark Execution Results | **November 30, 2025** |
-
-Accepted benchmarks will be included in the **FireBench Annual Report**, presented at the **AMS 2026 Annual Meeting**, and archived for reproducibility. Contributors may be credited as co-authors or acknowledged participants depending on their involvement.
-
----
-
-### 📄 How to Submit a Benchmark
-
-1. Review the [Benchmark Proposal Template](benchmarks_information/index.md) for formatting guidelines.
-2. Fill in your submission using the [Google Doc Template](https://docs.google.com/document/d/19RXwEnl81XxUfCWXOCUENFV-ZB4iz16faCDsJatddc8/edit?usp=sharing).
-3. View ongoing submissions in the [List of Submitted Benchmarks](https://docs.google.com/spreadsheets/d/1Ee2G6FgD-c-5fu-oPcsI3ApyQnPQvxZJwKqVOYqtj28/edit?usp=sharing).
-
-💡 If you're interested in **running** a benchmark (using one or more fire models), check out the [Benchmark Execution Guidelines](benchmarks_information/index.md) to learn how to evaluate and report results.
-
-We’re excited to see how the community will help shape the future of fire modeling!
+FireBench addresses this gap by providing a flexible framework to assess fire models performance using various datasets and metrics.
+See the list of benchmarks for more information about datasets, metrics and evaluation method.
## Installation
diff --git a/docs/metrics/index.md b/docs/metrics/index.md
index bca86eb..e0a2c67 100644
--- a/docs/metrics/index.md
+++ b/docs/metrics/index.md
@@ -1,64 +1,39 @@
-# 5. Metrics information
-This section describes the high-level metrics available in `FireBench`, organized by the type and structure of the observational data they support. These metrics are designed to evaluate model performance in realistic settings and are grouped into categories that reflect typical data sources (e.g., weather stations, satellite imagery, fire perimeters).
-Some metrics support observation uncertainty, and others are specifically designed for deterministic or ensemble simulations.
+# 5. Metrics and Scores information
+This section describes the high-level metrics available in `FireBench`, listed as `Key Performance Indicator` (KPI). Each KPI represents one, and only one, quantitative evaluation of performance.
+KPIs are based on metrics that correspond to the generalization of quantitative comparison of multiple datasets.
+The KPI value can be normalized and multiple KPIs can be aggregated to construct a score.
+
+We introduce the following definition:
+- Metric is a quantifiable measure used to evaluate the performance.
+- Key Performance Indicator (KPI) is derived from one or several metrics and describes one quantitative evaluation associated to specific variables (1 number out).
+- Score is a number between 0 and 100, with 100 being best performance, allowing for comparison and aggregation.
+- Normalization is the process to convert a KPI value (not necessarily bounded) to a Score (bounded between 0 and 100).
+- Aggregation is the weighted combination of multiple Scores to form one Score reflecting global performance. Aggregation is done at group of KPIs level (Group Score) and global level (Total Score)
+- Benchmark is the group KPI + Normalization.
+
+**More information about the components in the following pages**
+```{toctree}
+:maxdepth: 1
+
+score.md
+metrics.md
+kpis.md
+normalization.md
+```
+
+
+Figure 1 shows the relationship between the different quantitative components. Each KPI is form by using one or several metrics. The output from the KPI can be normalized using a normalization function to form a score.
+
+
+
+
+ Fig. 1
+
+ :
+
+ Relationship between Metrics, KPI, Normalization and Score
+
+
For implementation details, refer to the [API references](../api/index.rst).
-
A full list of metrics is also available on the [Content page](../content.md).
-
-## Single Point (0D, Time Series)
-
-These metrics apply to **0D signals**, *i.e.*, time series at a single spatial location. This is typical for **weather station data** or **virtual probes** in simulations.
-
-**Use these metrics when**:
-- You have observations at fixed points in space (e.g., 10-meter wind at a weather station)
-- You want to compute per-station RMSE, bias, correlation, etc.
-
-**List of metrics**
-- Bias
-- RMSE
-- NMSE with range normalization
-- NMSE with power normalization
-
-## Network of Probes
-
-Metrics in this category are designed to evaluate a **network of time series** across multiple locations, such as a set of weather stations.
-
-**Use these metrics when**:
-- You want to evaluate performance across a full observation network
-- You need to analyze spatial structure, coherence, or regional error statistics
-
-## Line or Polygon Observations (1D in Space, Sparse in Time)
-
-These metrics apply to **1D spatial data** that are available at discrete times, for example GIS polygons representing fire perimeters, or airborne measurements along a path
-
-**Use these metrics when**:
-- You want to compare the shape, location, or evolution of 1D features
-- You need to evaluate model accuracy along a known line or within a boundary
-
-**List of metrics**
-- Jaccard index (Intersection over Union)
-- Sorensen-Dice index
-
-## 2D Raster Data (Sparse in Time)
-
-Metrics in this group apply to **2D spatial data**, such as satellite imagery, available at discrete times.
-
-**Use these metrics when**:
-- You are comparing model outputs to gridded observations
-- You want to use spatial scores (e.g., FSS, SAL) or object-based comparison methods
-
-**List of metrics**
-- Jaccard index (Intersection over Union)
-- Sorensen-Dice index
-
-## 3D Sparse or Semi-Sparse Observations
-
-This category includes **3D datasets** that may be dense in two dimensions and sparse in the third (typically time). Examples include:
-
-* **Lidar scans** (e.g., vertical cross-sections of wind or aerosol)
-* **Radar volumes** or **profiling instruments**
-
-**Use these metrics when**:
-- Your data span two spatial dimensions (e.g., x-z or y-z) over time
-- You want to assess how well the model reproduces layered structures or vertical evolution
\ No newline at end of file
diff --git a/docs/metrics/kpis.md b/docs/metrics/kpis.md
new file mode 100644
index 0000000..0b03d94
--- /dev/null
+++ b/docs/metrics/kpis.md
@@ -0,0 +1,221 @@
+# Key Performance Indicator Definitions
+
+This section describes the Key Performance Indicators (KPI) used within FireBench benchmarks.
+KPIs are grouped by context. Output bounds are given as `worst` and `best` values.
+
+KPIs can be described as:
+- Template: generic definition of a KPI. It can be related to generic input variable(s) and have parameters.
+- Intance: the final form of a KPI that can be used in a benchmark. It has explicitly defined input variable(s) and no parameters.
+
+## Burn Severity
+
+### Binary High Severity Accuracy
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measure how accurately the model predicts which point are identified as high severity, based on binary (high severity / not high severity) observations.
+
+The measure of [accuracy](https://en.wikipedia.org/wiki/Accuracy_and_precision#In_classification) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Accuracy = \frac{TP + TN}{TP + TN + FP + FN},
+$$
+where $TP$ = True positive (high severity in both datasets); $FP$ = False positive (high severity only in model dataset); $TN$ = True negative (not high severity in both datasets); $FN$ = False negative (high severity only in observational datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_accurary` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary High Severity Precision
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how accurately the model predicts which cells are high severity, by evaluating the proportion of predicted high severity points that were actually high severity.
+
+The measure of [precision](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values#Definition) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Precision = \frac{TP}{TP + FP},
+$$
+where $TP$ = True positive (high severity in both datasets); $FP$ = False positive (high severity only in model dataset)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_precision` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary High Severity Recall
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how completely the model captures the cells with a high severity index, indicating the fraction of truly high severity cells that the model successfully identifies.
+
+The measure of [recall](https://en.wikipedia.org/wiki/Precision_and_recall) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Recall = \frac{TP}{TP + FN},
+$$
+where $TP$ = True positive (high severity in both datasets); $FN$ = False negative (high severity only in observational datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_recall_rate` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary High Severity Specificity
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how accurately the model identifies cells with another severity index than high, by quantifying the fraction of other indices correctly predicted as other (not high).
+
+The measure of [specificity](https://en.wikipedia.org/wiki/Sensitivity_and_specificity) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Specificity = \frac{TN}{TN + FP}
+$$
+where $FP$ = False positive (high severity only in model dataset); $TN$ = True negative (not high severity in both datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_specificity` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary High Severity Negative Predictive Value
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures the reliability of the model’s predictions for cells identified with another severuty index than high, indicating the proportion of points predicted index as other (not high) that were indeed observed as other.
+
+The measure of [Negative Predictive Value](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Negative Predictive Value = \frac{TN}{TN + FN}
+$$
+where $TN$ = True negative (not high severity in both datasets); $FN$ = False negative (high severity only in observational datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_negative_predicted_value` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary High Severity F1 Score
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Provides a balanced measure of model performance by combining precision and recall, capturing how well the model identifies high severity cells while limiting false alarms.
+
+The measure of [F1 Score](https://en.wikipedia.org/wiki/F-score) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+F1 Score = \frac{2 \times Precision \times Recall}{Precision + Recall}
+$$
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_f_score` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+## Structure Loss
+
+### Binary Structure Loss Accuracy
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measure how accurately the model predicts which structures are destroyed or not destroyed by the fire, based on binary (burned / not burned) observations.
+
+The measure of [accuracy](https://en.wikipedia.org/wiki/Accuracy_and_precision#In_classification) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Accuracy = \frac{TP + TN}{TP + TN + FP + FN},
+$$
+where $TP$ = True positive (buildings destroyed in both datasets); $FP$ = False positive (buildings destroyed only in model dataset); $TN$ = True negative (buildings not damaged in both datasets); $FN$ = False negative (buildings destroyed only in observational datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_accurary` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary Structure Loss Precision
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how accurately the model predicts which structures are destroyed, by evaluating the proportion of predicted-destroyed buildings that were actually destroyed.
+
+The measure of [precision](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values#Definition) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Precision = \frac{TP}{TP + FP},
+$$
+where $TP$ = True positive (buildings destroyed in both datasets); $FP$ = False positive (buildings destroyed only in model dataset).
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_precision` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+
+### Binary Structure Loss Recall
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how completely the model captures the buildings that were actually destroyed, indicating the fraction of truly destroyed structures that the model successfully identifies.
+
+The measure of [recall](https://en.wikipedia.org/wiki/Precision_and_recall) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Recall = \frac{TP}{TP + FN},
+$$
+where $TP$ = True positive (buildings destroyed in both datasets); $FN$ = False negative (buildings destroyed only in observational datasets)
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_recall_rate` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary Structure Loss Specificity
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures how accurately the model identifies buildings that survived, by quantifying the fraction of intact structures correctly predicted as not destroyed.
+
+The measure of [specificity](https://en.wikipedia.org/wiki/Sensitivity_and_specificity) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Specificity = \frac{TN}{TN + FP}
+$$
+where $FP$ = False positive (buildings destroyed only in model dataset); $TN$ = True negative (buildings not damaged in both datasets).
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_specificity` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary Structure Loss Negative Predictive Value
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Measures the reliability of the model’s predictions for surviving structures, indicating the proportion of predicted-intact buildings that were indeed not destroyed.
+
+The measure of [Negative Predictive Value](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+Negative Predictive Value = \frac{TN}{TN + FN}
+$$
+where $TN$ = True negative (buildings not damaged in both datasets); $FN$ = False negative (buildings destroyed only in observational datasets).
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_negative_predicted_value` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+### Binary Structure Loss F1 Score
+
+*Type*: Instance
+*Best*: 1
+*Worst*: 0
+
+Provides a balanced measure of model performance by combining precision and recall, capturing how well the model identifies destroyed buildings while limiting false alarms.
+
+The measure of [F1 Score](https://en.wikipedia.org/wiki/F-score) is based on the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from an observational dataset and a model output dataset.
+
+$$
+F1 Score = \frac{2 \times Precision \times Recall}{Precision + Recall}
+$$
+
+The implementation of this KPI is done using the `firebench.metrics.confusion_matrix.binary_cm` function and `firebench.metrics.confusion_matrix.binary_f_score` functions (see API documentation for implementation). If some data processing (e.g., for category aggregation) is required, this process is described at the case level.
+
+## Weather stations
+
+In Progress...
+
+
diff --git a/docs/metrics/metrics.md b/docs/metrics/metrics.md
new file mode 100644
index 0000000..8ea89f1
--- /dev/null
+++ b/docs/metrics/metrics.md
@@ -0,0 +1,157 @@
+# Metrics
+This section describes all the metrics used within FireBench benchmarks.
+
+## 1D metrics
+
+**Input:** Two 1D vectors of size $N$:
+
+- $x_i$: evaluated dataset
+- $y_i$: reference dataset
+
+### Mean
+
+**Description:** Average value of a 1D vector $x$.
+**Range:** Same as range of $x$.
+**Units:** Same as input units.
+**Formula:**
+
+$$
+\bar x = \frac{1}{N} \sum_{i=1}^N x_i
+$$
+
+### Bias
+
+**Description:** Difference between the mean of $x$ and the mean of $y$.
+**Range:** Same as range of input values.
+**Units:** Same as input units.
+**Formula:**
+
+$$
+B = \bar x - \bar y
+$$
+
+### Root Mean Square Error
+
+**Description:** Square root of the mean squared difference between (x) and (y), noted RMSE.
+**Range:** $[0, +\infty[$.
+**Units:** Same as input units.
+**Formula:**
+
+$$
+RMSE(x, y) = \sqrt{\frac{1}{N} \sum_{i=1}^N (x_i - y_i)^2}
+$$
+
+### Mean Absolute Error
+
+**Description:** Mean of the absolute difference between (x) and (y), noted MAE.
+**Range:** $[0, +\infty[$.
+**Units:** Same as input units.
+**Formula:**
+
+$$
+MAE(x, y) = \frac{1}{N} \sum_{i=1}^N |x_i - y_i |
+$$
+
+### Normalized MSE - power normalization
+
+**Description:** RMSE normalized by the range of the reference dataset.
+**Range:** $[0, +\infty)$.
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+NMSE_p = \frac{RMSE(x, y)}{\max(y) - \min(y)}
+$$
+
+### Normalized MSE – range normalization
+**Description:** Squared RMSE normalized by the product of mean values of the datasets.
+**Range:** $[0, +\infty)$ (undefined if $\bar x = 0$ or $\bar y = 0$).
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+NMSE_r = \frac{RMSE(x, y)^2}{\bar x \, \bar y}
+$$
+
+
+## Binary Confusion Matrix
+
+**Input:** Two 1D binary vectors (0 or 1) of size $N$:
+
+- $x_i$: evaluated dataset
+- $y_i$: reference dataset
+
+The following metrics are derived from the [Binary confusion matrix](https://en.wikipedia.org/wiki/Confusion_matrix#Table_of_confusion) generated from both dataset. The Binary confusion matrix is a 2x2 matrix containing:
+
+| | Reference = 1 | Reference = 0 |
+| ------------ | ------------- | ------------- |
+| **Eval = 1** | TP | FP |
+| **Eval = 0** | FN | TN |
+
+Where:
+- TP: True Positive
+- FP: False Positive
+- FN: False Negative
+- TN: True Negative
+
+### Accuracy
+
+**Description:** Fraction of correct predictions among all samples (see [accuracy](https://en.wikipedia.org/wiki/Accuracy_and_precision#In_classification)).
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+Accuracy = \frac{TP + TN}{TP + TN + FP + FN}
+$$
+
+### Precision
+**Description:** Fraction of predicted positives that are true positives (see [precision](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values#Definition)).
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+Precision = \frac{TP}{TP + FP},
+$$
+
+### Recall
+**Description:** Fraction of actual positives correctly identified (see [recall](https://en.wikipedia.org/wiki/Precision_and_recall)). Recall can also be named Sensitivity or True Positive Rate.
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+Recall = \frac{TP}{TP + FN},
+$$
+
+### Specificity
+**Description:** Fraction of actual negatives correctly identified (see [specificity](https://en.wikipedia.org/wiki/Sensitivity_and_specificity)). Recall can also be named True Negative Rate.
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+Specificity = \frac{TN}{TN + FP}
+$$
+
+### Negative Predictive Value
+**Description:** Fraction of predicted negatives that are true negatives (see [Negative Predictive Value](https://en.wikipedia.org/wiki/Positive_and_negative_predictive_values)).
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+Negative Predictive Value = \frac{TN}{TN + FN}
+$$
+
+### F1 Score
+**Description:** Harmonic mean of Precision and Recall (see [F1 Score](https://en.wikipedia.org/wiki/F-score)).
+**Range:** $[0, 1]$
+**Units:** Dimensionless.
+**Formula:**
+
+$$
+F1 Score = \frac{2 \times Precision \times Recall}{Precision + Recall}
+$$
+
diff --git a/docs/metrics/normalization.md b/docs/metrics/normalization.md
new file mode 100644
index 0000000..356b309
--- /dev/null
+++ b/docs/metrics/normalization.md
@@ -0,0 +1,86 @@
+# KPI Normalization
+
+This section describes several normalization schemes used to convert KPI values into a score in the range $[0, 100]$.
+Throughout, $x$ denotes the KPI value.
+
+## Linear Bounded Normalization
+
+For KPIs with a **bounded acceptable range** $[a, b]$, with $a < b$, the normalization function is defined as:
+
+$$
+\mathcal N(x, a, b) = 100 \, \left( \frac{x - a}{b - a} \right)
+$$
+
+Here,
+- $a$ corresponds to the **worst** score (0),
+- $b$ corresponds to the **best** score (100).
+
+## Linear Half-Open Normalization
+
+For KPIs that have a **minimum acceptable value** $a$ but no finite upper limit, i.e. values in $[a, +\infty[$, we define:
+
+$$
+\mathcal N(x, a, m) = 100 \, \max \left(0, 1 - \frac{x-a}{m-a} \right)
+$$
+
+where $m > a$ is a parameter specifying the value of $x$ at which the score reaches **0**.
+
+Here,
+- $a$ corresponds to the **best** score (100),
+- $m$ corresponds to the **worst** score (0).
+
+## Exponential Half-Open Normalization
+
+For KPIs with a minimum acceptable value $a$ and domain $[a, +\infty[$, we define an exponentially decaying score:
+
+$$
+\mathcal N(x, a, m) =100 \, \exp \left( - \frac{\ln 2 \, (x-a)}{m-a} \right).
+$$
+
+This formulation ensures:
+
+$$
+\mathcal N(a,a,m) = 100, \qquad
+\mathcal N(m,a,m) = 50.
+$$
+
+Thus, $m$ is the KPI value at which the score is exactly **50**.
+Here,
+- $a$ corresponds to the **best** possible score (100),
+- $m$ corresponds to the value at which the score has decreased to **50**,
+- scores decay smoothly toward 0 as $x \to +\infty$.
+
+## Symmetric Linear Open Normalization
+
+For KPIs that have a symmetry around 0 but no finite limit, i.e. values in $[-\infty, +\infty[$, we define:
+
+$$
+\mathcal N(x, m) = 100 \, \max \left(0, 1 - \frac{|x|}{m} \right)
+$$
+
+where $m > a$ is a parameter specifying the value of $x$ at which the score reaches **0**.
+
+Here,
+- $a$ corresponds to the **best** score (100),
+- $m$ corresponds to the **worst** score (0).
+
+## Symmetric Exponential Open Normalization
+
+For KPIs that have a symmetry around 0 but no finite limit, i.e. values in $[-\infty, +\infty[$, we define:
+
+$$
+\mathcal N(x, m) =100 \, \exp \left( - \frac{\ln 2 \, |x|}{m} \right).
+$$
+
+This formulation ensures:
+
+$$
+\mathcal N(0,m) = 100, \qquad
+\mathcal N(\pm m,m) = 50.
+$$
+
+Thus, $m$ is the KPI value at which the score is exactly **50**.
+Here,
+- $0$ corresponds to the **best** possible score (100),
+- $m$ corresponds to the value at which the score has decreased to **50**,
+- scores decay smoothly toward 0 as $x \to \pm\infty$.
\ No newline at end of file
diff --git a/docs/metrics/score.md b/docs/metrics/score.md
new file mode 100644
index 0000000..517a779
--- /dev/null
+++ b/docs/metrics/score.md
@@ -0,0 +1,70 @@
+# Scores
+
+This section details the processes used to construct a unique score for each benchmark case from the set of KPIs contained in the benchmark case.
+A `Score` is defined as a real number, with 4 significant digit, between 0.000 (worst) and 100.0 (best).
+Scores are derived from KPI values and allow the comparison of models and benchmark results.
+As a KPI value is not necessarily a number that is compliant with the score definition, a `normalization` process is required to convert a KPI value to a score, called `Unit Score`.
+
+$$
+KPI \overset{Normalization}{\longmapsto} Unit Score
+$$
+
+The different normalization functions available are described in Section `KPI Normalization`.
+
+Each KPI is transformed into a `Unit Score`, corresponding to one, and only one, KPI.
+To further simplify the interpretation the result of multiple benchmarks, these Unit Scores can be aggregated to form `Group Scores`. They represent the overall performance accross multiple indicators, generally evaluating the model for similar physical processes or on the same data.
+The `Total Score` is the aggregation of all group scores into one, and only one, score, representing the overall performance of the model for the studied case.
+
+Figure 1 shows an example of normalization of each KPI for the case *FB001*. Each KPI is normalized into a Unit Score. Then Unit Scores are aggregated into two Group Scores representing the overall performance for *Building Damaged* benchmarks and *Burn Severity* benchmarks. Finally, both Group Scores are aggregated to form the Total Score.
+
+
+
+ Fig. 1
+
+ :
+
+ Diagram of Scores construction from KPIs using two categories of KPI (BD: Building Damaged, SV: Burn Severity).
+
+
+
+The aggregation can be performed using multiple aggregation schemes. The simplest scheme is to aggregate score using a mean function. This gives the same weight to each KPI in the Total Score. We can also develop more complex aggregation schemes to give more weight to certain benchmarks/KPIs. Therefore, for each benchmarking case (collection of dataset and KPIs), we can define multiple aggregation schemes to evaluate different classes of models. Each aggregation scheme will be noted using a letter. For example `FB001-A`, `FB001-B`.
+
+
+Figure 2 shows exmaple of KPI values and their correspond range between brackets. The KPI FB001-BD01 has a value of 0.34763 and a range of [0, 1] (FB001-BD01 can represent a binary confusion matrix index), whereas the KPI FB001-SV03 has a value of 3.489 and a range of [0, $+\infty$[ (FB001-SV03 can represent an absolute bias). All KPI with a limited range of values are normaized using the linear normalization function (see next Section). The KPI FB001-SV03 is normalized using the linear semi bounded normization function with a parameter $M=5$, which means that if the KPI value is above 5, the score will be 0.
+Then, Unit Scores are aggregated using uniform weights (represented by the green numbers above aggregation lines) to form Group Scores.
+Finally, a weighted aggregation is performed to calculate the Total Score of the case, giving an double importance to benchmarks related to **Building Damaged*.
+The list of aggregation scheme and their weights are explicitely defined in the case documentation.
+
+
+
+
+ Fig. 2
+
+ :
+
+ Example of Scores construction from KPIs using two categories of KPI.
+
+
+
+Figure 3 displays an `Score Card` that is a table representing the data showed in Figure 2. This type of score card can become the standard of presentation for case results. The first row shows the total score and contains the case that has been run (FB001), the aggregation scheme used (B, defined in the case documentation), the model name, and the total score calculated in Figure 2.
+The rest of the table is organized as:
+- one group row that describes the name of the group and the associated score. A keyword **Group** is added to emphasis the row.
+- All the benchmark scores related to the group are displayed after. The name of the benchmark is added as a reference. Here the case id (FB001) is omitted for clarity as it is already displayed in the first row.
+
+
+
+
+
+ Fig. 3
+
+ :
+
+ Example of Score card layout
+
+
+
+```{note}
+Note: The example here above are not related to the real case FB001 (Caldor Fire) and all KPI names and
+values are given as examples.
+```
+
diff --git a/docs/namespace.md b/docs/namespace.md
index 8d46257..99044e5 100644
--- a/docs/namespace.md
+++ b/docs/namespace.md
@@ -14,9 +14,18 @@ The Standard Variable Namespace `svn` is accessed in workflows using `from fireb
- `AIR_TEMPERATURE`: Air temperature [K]
- `ALPHA`: Alpha
- `BETA`: Beta
+- `BUILDING_RATIO_FIRE_RESISTANT`: Ratio of fire resistant building [-]
+- `BUILDING_RATIO_STRUCTURE_WOOD_BARE`: Ratio of buildings made with bare wood materials [-]
+- `BUILDING_RATIO_STRUCTURE_WOOD_MORTAR`: Ratio of buildings made with mortar [-]
+- `BUILDING_LENGTH_SEPARATION`: Buildings seperation length [m]
+- `BUILDING_LENGTH_SIDE`: Buildings side length [m]
- `CANOPY_DENSITY_BULK`: Canopy bulk density [kg m-3]
+- `CANOPY_HEIGHT`: Canopy height [m]
- `CANOPY_HEIGHT_BOTTOM`: Canopy height bottom [m]
- `CANOPY_HEIGHT_TOP`: Canopy height top [m]
+- `DIRECTION`: Direction [-]
+- `FIRE_ARRIVAL_TIME`: Fire arrival time [s]
+- `FIRE_BURN_SEVERITY`: Fire burn severity [-]
- `FUEL_CLASS`: Fuel class (int)
- `FUEL_CHAPARRAL_FLAG`: Fuel chaparral flag (int)
- `FUEL_COVER`: Fuel cover [%]
@@ -65,10 +74,27 @@ The Standard Variable Namespace `svn` is accessed in workflows using `from fireb
- `FUEL_WIND_HEIGHT`: Fuel wind height [m]
- `FUEL_WIND_REDUCTION_FACTOR`: Fuel wind reduction factor [-]
- `IGNITION_LENGTH`: Ignition length [m]
+- `LATITUDE`: Latitude [deg]
- `LENGTH`: Length [m]
+- `LONGITUDE`: Longitude [deg]
+- `MAGNITUDE`: Magnitude
+- `NORMAL_SPREAD_DIR_X`: Fire front normal vector component in x direction [-]
+- `NORMAL_SPREAD_DIR_Y`: Fire front normal vector component in y direction [-]
- `RATE_OF_SPREAD`: Rate of spread [m s-1]
+- `RAVG_CANOPY_COVER_LOSS`: Rapid Assessment of Vegetation Condition After Wildfire canopy cover loss [%]
+- `RAVG_COMPOSITE_BURN_INDEX_SEVERITY`: Rapid Assessment of Vegetation Condition After Wildfire composite burn index severity [-]
+- `RAVG_LIVE_BASAL_AREA_LOSS`: Rapid Assessment of Vegetation Condition After Wildfire live basal area loss [%]
- `RELATIVE_HUMIDITY`: Relative Humidity [m s-1]
- `SLOPE_ANGLE`: Slope angle [deg]
+- `SOLAR_RADIATION`: Solar radiation [W m-2]
- `TEMPERATURE`: Temperature [K]
- `TIME`: Time [s]
+- `WIND_DIRECTION`: Wind direction [deg]
+- `WIND_GUST`: Wind gust [m s-1]
- `WIND_SPEED`: Wind [m s-1]
+- `WIND_SPEED_U`: Wind speed in x direction [m s-1]
+- `WIND_SPEED_V`: Wind speed in y direction [m s-1]
+- `WIND_SPEED_W`: Wind speed in z direction [m s-1]
+- `X`: x coordinate [m]
+- `Y`: y coordinate [m]
+- `Z`: z coordinate [m]
diff --git a/docs/standard_format.md b/docs/standard_format.md
index 23537b9..8d79bba 100644
--- a/docs/standard_format.md
+++ b/docs/standard_format.md
@@ -1,8 +1,8 @@
# 4. Standard FireBench file format
-- **Version**: 0.1
-- **Status**: Draft
-- **Last update**: 2025-08-08
+- **Version**: 1.0
+- **Status**: PreRelease
+- **Last update**: 2026-01-02
This document defines the I/O format standard for benchmark datasets used in the `FireBench` benchmarking framework. The standard is based on the [HDF5 file format](https://www.hdfgroup.org/solutions/hdf5/) (`.h5`) and describes the structure, expected groups, metadata, and conventions.
@@ -12,14 +12,16 @@ Each .h5 file must adhere to the following structure:
```
/ (root)
-├── probes/ (point-based time series)
-├── 1D_raster/ (1D gridded spatial data + time)
-├── 2D_raster/ (2D gridded spatial data + time)
-├── 3D_raster/ (3D gridded spatial data + time)
+├── points/ (0D datasets)
+├── time_series/ (point-based time series)
+├── spatial_1d/ (1D gridded spatial data + time)
+├── spatial_2d/ (2D gridded spatial data + time)
+├── spatial_3d/ (3D gridded spatial data + time)
├── unstructured/ (unstructured spatial data + time)
-├── polygons/ (geopolygones)
+├── polygons/ (geopolygons)
├── fuel_models/ (fuel model classification or parameters)
├── miscellaneous/ (non-standard or project-specific data)
+├── certificates/ (FireBench certificates)
```
All groups are optional unless otherwise specified in a benchmark case specification.
@@ -29,26 +31,41 @@ The `/metadata` group is not defined in this version of the standard, as all met
The HDF5 file must contain the following root-level attributes:
-Attributs | Type | Description
+Attributes | Type | Description
:--------- | :----: | :-----------
`FireBench_io_version` | str | Version of the I/O standard used
`created_on` | str | ISO 8601 date-time of file creation
-`created_by` | str | Creator identifier (name, email, etc)
+`created_by` | str | Creator identifier (name, affiliation). `created_by` is a `;`-separated list; whitespace around entries should be ignored; entries must not contain `;`.
Suggested additional attributes:
-Attributs | Type | Description
+Attributes | Type | Description
:--------- | :----: | :-----------
`benchmark_id` | str | Unique ID of the benchmark scenario
`model_name` | str | Name of the model producing the data
`model_version` | str | Version of the model
`description` | str | Short description of the dataset
`project_name` | str | Short description of the project
-`license` | str | License or terms of use
-`data_source` | str | Source of the data if applicable
+`license` | str | License or terms of use (or specified at group/dataset level). SPDX identifier when possible (*e.g.*, CC-BY-4.0), otherwise a URL.
+`data_source` | str | Source of the data if applicable.
No `/metadata` group is required; prefer file-level attributes. The `/metadata` namespace is reserved for future versions.
+## Compression
+
+Compression of datasets is done using Zstandard. It is included in the python library `hdf5plugin` so no external dependency is needed. Compression level can go from 1 (low compression, faster) to 22 (highest compression, slower). Zstandard has been chosen for its better I/O and better compression performance than more classic gzip compression.
+As most benchmarking processes are not time sensitive, the recommended compression level is **20**.
+
+## Units
+**No units is implicitly assumed.**
+Units are described as strings that are compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology. The default unit registry (*i.e.* the list of acceptable units) can be found [here](https://github.com/hgrecco/pint/blob/master/pint/default_en.txt).
+
+Units must be specified:
+1. For attributes, by adding a new attribute with the suffix `_units` added to the associated attribute name.
+For example, the attribute `position_lat` in a group will have an associated attribute `position_lat_units` containing `"degree"`. Only numeric attributes representing physical quantities should use the `*_units` suffix. Do not add `_units` for identifiers, names, CRS, hashes, *etc.*.
+
+2. For datasets, using an attribute `units`. For example, a dataset `air_temperature` will have an attribute `units` containing `"K"`.
+
## Time format
### Absolute time variable
@@ -58,20 +75,28 @@ All datetime variables must follow the **ISO 8601** standard:
YYYY-MM-DDTHH:MM:SS±HH:MM
```
Examples:
-- **2025-07-30T15:45:00+00:00** which correspond to July 30th 2025 at 15h45:00s UTC.
-- **1995-03-27T12:00+01:00** is acceptable is seconds are irrelevant.
+- **2025-07-30T15:45:00+00:00** which corresponds to July 30, 2025 at 15h 45min 00s UTC.
+- **1995-03-27T12:00+01:00** is acceptable if seconds are irrelevant.
+
+Using this encoding, the dataset `time` is an array of ISO 8601 strings (UTC offset included).
### Relative time variable
-If time is expressed relative to a reference point (*e.g.* "time since ignition"), the dataset must include an attribute:
+If time is expressed relative to a reference point (*e.g.* "time since ignition"), the dataset/group **must** include the attributes:
```
time_origin = "YYYY-MM-DDTHH:MM:SS±HH:MM"
+time_units = "min"
```
-This attribute must follow the ISO 8601 format.
+The attribute `time_origin` must follow the ISO 8601 format.
+The attribute `time_units` must be compliant with [Pint](https://pint.readthedocs.io/en/stable/) standard. The default unit registry (*i.e.* the list of acceptable units) can be found [here](https://github.com/hgrecco/pint/blob/master/pint/default_en.txt).
+
+Using this encoding, the dataset `time` is numeric (float/int) with required attributes `time_origin` (ISO) and `time_units` (Pint).
## Spatial Information Convention
Spatial position must be defined using one and only one of the following representations. Each representation comes with a required set of datasets or attributes. The group or dataset containing the position data must follow the conventions below.
+For geographic grids, coordinates should be stored as *position_lat/position_lon* (and optionally *position_alt*). For projected grids, use *position_x/position_y* with a CRS (if applicable).
+Position fields may be stored as datasets or attributes. If varying across samples/time, they must be datasets; if constant for the group, they should be attributes.
### Geographic coordinates
@@ -102,9 +127,9 @@ Use when position is defined relative to a known geographic origin.
- `position_origin_lat`: latitude of origin
- `position_origin_lon`: longitude of origin
- `position_origin_alt`: altitude of origin
-- `position_rel_x`: x coordinate relative to origin
-- `position_rel_y`: y coordinate relative to origin
-- `position_rel_z`: z coordinate relative to origin
+- `position_x`: x coordinate relative to origin
+- `position_y`: y coordinate relative to origin
+- `position_z`: z coordinate relative to origin
**Coordinate Reference System (CRS)**
- The group containing these fields must have an attribute `crs` identifying the CRS (*e.g.*, "EPSG:4326")
@@ -112,15 +137,17 @@ Use when position is defined relative to a known geographic origin.
### Cross-Section with geographic reference point
Use when data is aligned along a 2D cross-section that does not follow cardinal (lat/lon/alt) directions.
+Vectors are unitless direction vectors in the same coordinate basis as the origin CRS.
+They do not need to be normalized, but must be non-colinear.
**Required fields**
- `position_origin_lat`: latitude of origin
- `position_origin_lon`: longitude of origin
- `position_origin_alt`: altitude of origin
- `position_plane_vector_1`: components of the first vector of the cross section plane (`x_cs` direction). Components are given as (x, y, z).
-- `position_plane_vector_2`: components of the second vector of the cross section plane (`y_cs` direction). Components are given as (x, y, z). The second vector must not be colinear to the first vector.
-- `position_rel_x_cs`: x_cs coordinate relative to origin
-- `position_rel_y_cs`: y_cs coordinate relative to origin
+- `position_plane_vector_2`: components of the second vector of the cross section plane (`y_cs` direction). Components are given as (x, y, z).
+- `position_x_cs`: x_cs coordinate relative to origin
+- `position_y_cs`: y_cs coordinate relative to origin
**Coordinate Reference System (CRS)**
- The group containing these fields must have an attribute `crs` identifying the CRS (*e.g.*, "EPSG:4326")
@@ -129,6 +156,8 @@ Use when data is aligned along a 2D cross-section that does not follow cardinal
### Cross-Section with cartesian reference point
Use when data is aligned along a 2D cross-section that does not follow cardinal (x/y/z) directions.
+Vectors are unitless direction vectors in the same coordinate basis as the origin CRS.
+They do not need to be normalized, but must be non-colinear.
**Required fields**
- `position_origin_x`: x coordinate of origin
@@ -136,8 +165,8 @@ Use when data is aligned along a 2D cross-section that does not follow cardinal
- `position_origin_z`: z coordinate of origin
- `position_plane_vector_1`: components of the first vector of the cross section plane (`x_cs` direction). Components are given as (x, y, z).
- `position_plane_vector_2`: components of the second vector of the cross section plane (`y_cs` direction). Components are given as (x, y, z). The second vector must not be colinear to the first vector.
-- `position_rel_x_cs`: x_cs coordinate relative to origin
-- `position_rel_y_cs`: y_cs coordinate relative to origin
+- `position_x_cs`: x_cs coordinate relative to origin
+- `position_y_cs`: y_cs coordinate relative to origin
**Coordinate Reference System (CRS)**
- The group containing these fields must have an attribute `crs` identifying the CRS (*e.g.*, "EPSG:4326")
@@ -151,106 +180,114 @@ Use when describing direction and distance from a known observation origin (*e.g
- `position_origin_lon`: longitude of origin
- `position_origin_alt`: altitude of origin
- `position_r`: radial distance from the origin
-- `position_theta`: polar angle (from z-axis)
-- `position_phi`: azimuthal angle (from x-axis)
+- `position_theta`: polar angle from z-axis
+- `position_phi`: azimuthal angle from x-axis
+
+Units attributes must be set for each field.
**Coordinate Reference System (CRS)**
- The group containing these fields must have an attribute `crs` identifying the CRS (*e.g.*, "EPSG:4326")
-### Units
-No units is implicitely assumed.
-Units must be specified within the group containing the spacial information (attributes and/or datasets).
-Units are described as string that are compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology. The default unit registry (*i.e.* the list of acceptable units) can be found [here](https://github.com/hgrecco/pint/blob/master/pint/default_en.txt).
-Units can be specified per field by adding the suffix `_units` to the field (*e.g.* `position_lat_units` will attach a unit to the attribute/dataset `position_lat`). Units can be specified by group of fields by adding the suffix `_units` to the group of fields name (*e.g.* `position_units` will attach a unit to the attribute/dataset `position_x`, `position_y` and `position_z`).
-If a field has its own `_units` attribute, that overrides any group‑wide unit
-
-The possible units fields are the following:
-- `position_alt_units`
-- `position_lat_units`
-- `position_lon_units`
-- `position_origin_x_units`
-- `position_origin_y_units`
-- `position_origin_z_units`
-- `position_origin_lat_units`
-- `position_origin_lon_units`
-- `position_origin_alt_units`
-- `position_origin_units`
-- `position_phi_units`
-- `position_r_units`
-- `position_rel_x_units`
-- `position_rel_y_units`
-- `position_rel_z_units`
-- `position_rel_units`
-- `position_theta_units`
-- `position_units`
-- `position_x_units`
-- `position_y_units`
-- `position_z_units`
-
+## Recommended array dimension
+- If a dataset depends on time, time is the first dimension.
+- For gridded data, recommended order:
+ - 1D: (time, z) or (time, x)
+ - 2D: (time, y, x) or (time, z, x) for cross-sections
+ - 3D: (time, z, y, x)
## Group definition
-### Probes
-- Contains time series data from specific points in space called probes, for example, weather stations (RAWS) or local sensors.
+### Points
+- Contains point-based datasets (single points or collections of points)
+- Datasets must be grouped at the lowest common level that minimizes data duplication.
+- Each group containing data should be named after the probe location or ID (e.g. probe_01).
+- Each dataset must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
+- In the following example, the array dimensions can be:
+ - data (position_lat, position_lon, building_damage) -> ($N$)
+```
+/ (root)
+├── points/ (0D datasets)
+│ ├── building_damage (group containing the main dataset)
+│ │ ├── position_lat (latitude of data point)
+│ │ ├── position_lon (longitude of data point)
+│ │ ├── building_damage (building status index)
+```
+
+### Time Series
+- Contains time series data from specific points in space, for example, weather stations (RAWS) or local sensors.
- Datasets must be grouped at the lowest common level that minimizes data duplication. Variables sharing the same time coordinate are placed in the same data group (*e.g.*, a sensor group). Multiple data groups that share the same spatial location are further grouped together in a location group (*e.g.*, a weather station).
- Each group containing data should be named after the probe location or ID (e.g. probe_01).
-- Each dataset (temperature, wind_speed, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
-- The time coordinate dataset must be a dataset named `time`.
-- Each time coordinate dataset must follow the global time convention (see Time format).
-- Location of the probes must be defined as attributes following a spatial description convention.
+- Each dataset (temperature, wind_speed, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
+- Identification information for weather stations (ID, MNET ID, provider, name) should be included as attributes if the information is accessible.
+- Sensor height must be included at dataset level (*e.g.* temperature, wind_speed) as an attribute `sensor_height`, along with `sensor_height_units` specifying the unit of the sensor height. The source of the sensor height information must be included in an attribute `sensor_height_source`.
+- Location of the dataset must be defined as attributes following a spatial description convention.
- If geographic coordinates are used, a CRS must be included.
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
- In the following example, the array dimensions can be:
- time -> ($N_t$)
- data (temperature, wind_speed, *etc.*) -> ($N_t$)
```
/ (root)
-├── probes/ (point-based time series)
-│ ├── weather_station_1 (group all sensors from weather station 1)
-│ │ ├── sensor_1 (group all data from sensor_1)
-│ │ │ ├── time (time dataset)
-│ │ │ ├── temperature (temperature data from sensor_1 dataset)
-│ │ ├── sensor_2 (group all data from sensor_2)
-│ │ │ ├── time (time dataset)
-│ │ │ ├── wind_speed (wind speed from sensor_2 dataset)
-│ │ │ ├── wind_direction (wind direction from sensor_2 dataset)
+├── time_series/ (point-based time series)
+│ ├── station_1 (group all sensors from weather station 1)
+│ │ ├── time (time dataset)
+│ │ ├── temperature (temperature data)
+│ │ ├── wind_speed (wind speed)
+│ │ ├── wind_direction (wind direction)
│ ├── sensor_3 (group all data from sensor_3)
│ │ ├── time (time dataset)
-│ │ ├── wind_u (U wind data from sensor_3 dataset)
-│ │ ├── wind_v (V wind data from sensor_3 dataset)
-│ │ ├── wind_w (W wind data from sensor_3 dataset)
+│ │ ├── wind_u (U wind data from sensor_3)
+│ │ ├── wind_v (V wind data from sensor_3)
+│ │ ├── wind_w (W wind data from sensor_3)
```
-### 1D raster
+### Spatial 1D
- Contains time series data from a dataset associated with one-dimensional spatial data.
- Datasets must be grouped at the lowest common level that minimizes data duplication. Variables sharing the same time coordinate and the same spatial coordinate are placed in the same data group.
-- The spatial coordinate dataset (z in the example) must follow a spatial description convention for a one-dimensional dataset. The spatial coordinate can be fixed in time or change in time.
+- The spatial coordinate dataset (*z* in the example) must follow a spatial description convention for a one-dimensional dataset. The spatial coordinate can be fixed in time or change in time.
- If geographic coordinates are used, a CRS must be included.
-- Each dataset (wind_speed, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
+- Each dataset (wind_speed, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
- The coordinate arrays may be 1D, or time-dependent 1D, depending on the grid type (regular, curvilinear, moving).
- In the following example, the array dimensions can be:
- time -> ($N_t$)
- z -> ($N_z$) or ($N_t$, $N_z$) for time varying z coordinate
- data (wind_speed, wind_direction, *etc.*) -> ($N_t$, $N_z$)
+- Coordinate datasets must be either static or time-dependent, and must be broadcast-compatible with dependent variables
```
/ (root)
-├── 1D_raster/ (1D gridded spatial data + time)
+├── spatial_1d/ (1D gridded spatial data + time)
│ ├── wind_profiler_1 (group all data from the wind profiler)
│ │ ├── time (time dataset)
-│ │ ├── z (vertical spatial coordinate for profile)
+│ │ ├── position_z (vertical spatial coordinate for profile)
│ │ ├── wind_speed (wind profiler data)
│ │ ├── wind_direction (wind profiler data)
```
-### 2D raster
-- Contains time series data from a dataset associated with two-dimensional spatial data. "2D raster" in this standard means any two spatial dimensions, whether horizontal, vertical, or arbitrary section, and that coordinate naming (x, y, z) will follow the Spatial Information Convention.
+### Spatial 2D
+- Contains time series data from a dataset associated with two-dimensional spatial data. It means any two spatial dimensions, whether horizontal, vertical, or arbitrary section, and that coordinate naming (x, y, z) will follow the Spatial Information Convention.
- Datasets must be grouped at the lowest common level that minimizes data duplication. Variables sharing the same time coordinate and the same spatial coordinate are placed in the same data group. For example, `fire_arrival_time` and `rate_of_spread` share the same x, y, and time coordinates, so they are stored in the same group.
-- The spatial coordinate dataset (x, y in the example) must follow a spatial description convention for a two-dimensional dataset. The spatial coordinate can be fixed in time or change in time.
+- The spatial coordinate dataset (*x*, *y* in the example) must follow a spatial description convention for a two-dimensional dataset. The spatial coordinate can be fixed in time or change in time.
- If geographic coordinates are used, a CRS must be included.
-- Each dataset (rate_of_spread, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
+- Each dataset (rate_of_spread, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
- The coordinate arrays may be 1D, 2D, or time-dependent 2D, depending on the grid type (regular, curvilinear, moving).
- In the following example, the array dimensions can be:
- time -> ($N_t$)
@@ -260,31 +297,36 @@ The possible units fields are the following:
- x of wrfoutput_cs_1 group -> ($N_x$) or ($N_z$, $N_x$) or ($N_t$, $N_z$, $N_x$) or ($N_t$, $N_x$)
- z -> ($N_z$) or ($N_z$, $N_x$) or ($N_t$, $N_z$, $N_x$) or ($N_t$, $N_z$)
- data of wrfoutput_cs_1 group(wind_u, *etc.*) -> ($N_t$, $N_z$, $N_x$)
+- Coordinate datasets must be either static or time-dependent, and must be broadcast-compatible with dependent variables
```
/ (root)
-├── 2D_raster/ (2D gridded spatial data + time)
+├── spatial_2d/ (2D gridded spatial data + time)
│ ├── wrfoutput_1 (group outputs from a WRF-SFIRE simulation for surface x-y plane)
│ │ ├── time (time dataset)
-│ │ ├── x (x spatial coordinate)
-│ │ ├── y (y spatial coordinate)
+│ │ ├── position_x (x spatial coordinate)
+│ │ ├── position_y (y spatial coordinate)
│ │ ├── fire_arrival_time (fire arrival time output from WRF-SFIRE simulation)
│ │ ├── rate_of_spread (rate of spread output from WRF-SFIRE simulation)
│ ├── wrfoutput_cs_1 (group outputs from a WRF-SFIRE simulation for a x-z cross section)
│ │ ├── time (time dataset)
-│ │ ├── x (x spatial coordinate)
-│ │ ├── z (z spatial coordinate)
+│ │ ├── position_x (x spatial coordinate)
+│ │ ├── position_z (z spatial coordinate)
│ │ ├── wind_u (zonal wind output from WRF-SFIRE simulation)
│ │ ├── wind_w (vertical wind output from WRF-SFIRE simulation)
```
-### 3D raster
+### Spatial 3D
- Contains time series data from a dataset associated with three-dimensional spatial data.
- Datasets must be grouped at the lowest common level that minimizes data duplication. Variables sharing the same time coordinate and the same spatial coordinate are placed in the same data group. For example, `wind_u` and `wind_v` share the same x, y, z, and time coordinates, so they are stored in the same group.
- The spatial coordinate dataset (x, y, z in the example) must follow a spatial description convention for a three-dimensional dataset. The spatial coordinate can be fixed in time or change in time.
- If geographic coordinates are used, a CRS must be included.
-- Each dataset (temperature, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
+- Each dataset (temperature, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
- The coordinate arrays may be 1D, 3D, or time-dependent 3D, depending on the grid type (regular, curvilinear, moving).
- In the following example, the array dimensions can be:
- time -> ($N_t$)
@@ -292,40 +334,72 @@ The possible units fields are the following:
- y -> ($N_y$) or ($N_z$, $N_y$, $N_x$) or ($N_t$, $N_z$, $N_y$, $N_x$) or ($N_t$, $N_y$)
- z -> ($N_z$) or ($N_z$, $N_y$, $N_x$) or ($N_t$, $N_z$, $N_y$, $N_x$) or ($N_t$, $N_z$)
- data (temperature, wind_u, *etc.*) -> ($N_t$, $N_z$, $N_y$, $N_x$)
+- Coordinate datasets must be either static or time-dependent, and must be broadcast-compatible with dependent variables
```
/ (root)
-├── 3D_raster/ (3D gridded spatial data + time)
+├── spatial_3d/ (3D gridded spatial data + time)
│ ├── wrfoutput_1 (group outputs from a WRF-SFIRE simulation)
│ │ ├── time (time dataset)
-│ │ ├── x (x spatial coordinate)
-│ │ ├── y (y spatial coordinate)
-│ │ ├── z (z spatial coordinate)
+│ │ ├── position_x (x spatial coordinate)
+│ │ ├── position_y (y spatial coordinate)
+│ │ ├── position_z (z spatial coordinate)
│ │ ├── wind_u (U wind output from WRF-SFIRE simulation)
│ │ ├── wind_v (V wind output from WRF-SFIRE simulation)
│ │ ├── wind_w (W wind output from WRF-SFIRE simulation)
│ │ ├── temperature (temperature output from WRF-SFIRE simulation)
```
+### polygons
+- As HDF5 is not a file format that is practical to use for vectorized dataset, the polygons are stored using the `KML` file format.
+- The HDF5 file contains the necessary metadata to point to the KML file containing the polygons dataset in a group registered in the `/polygons` main group.
+- Each group contains a reference to one and only one KML file.
+- Each KML reference corresponds to a single logical polygon layer (*e.g.*, a perimeter at a timestamp).
+- The mandatory attributes are the following
+ - `rel_path` (str): relative path to the KML file (relative to the HDF5 file directory)
+ - `file_size_bytes` (int): KML file size in bytes (*e.g.*, using `os.path.getsize`)
+ - `sha256` (str): hash of the KML file using `firebench.tools.calculate_sha256`
+- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+
+In the following example, we have a standard file `dataset.h5`, containing one polygons dataset. We also have a directory `kml` containing one KML file `polygons_2022_07_14.kml`.
+In the HDF5 file, the group `/polygons/fire_perimeter_2022_07_14` has the attribute `rel_path="kml/polygons_2022_07_14.kml"`.
+```
+dataset.h5
+/ (root)
+├── polygons/ (geopolygons)
+│ ├── fire_perimeter_2022_07_14 (group containing kml metadata)
+
+kml/polygons_2022_07_14.kml
+```
+**Note**: This part of the standard is in an early stage and intentionally allows some flexibility to accommodate diverse geopolygons data types. The structure and required fields may evolve in future versions based on user feedback and practical experience.
+
+
### unstructured
-- Contains data with unstructured spatial coordinates (*i.e* not associated with a regular grid). It includes trajectories, or unstructured meshes.
+- Contains data with unstructured spatial coordinates (*i.e.* not associated with a regular grid). It includes trajectories, or unstructured meshes.
- Datasets must be grouped at the lowest common level that minimizes data duplication. Variables sharing the same time coordinate and the same spatial coordinate are placed in the same data group.
- All spatial coordinates must follow the Spatial Information Convention, including CRS where applicable.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
-- Each dataset (temperature, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
+- Each dataset (temperature, wind_u, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
- The following example proposes a structure for a particle trajectories dataset, an output of a model using an unstructured mesh, and a dataset containing building positions and information about buildings.
+- Coordinate datasets must be either static or time-dependent, and must be broadcast-compatible with dependent variables
```
/ (root)
├── unstructured/ (unstructured spatial data + time)
│ ├── ptcl_trajectories_1 (group data from a particle trajectory model)
│ │ ├── time
-│ │ ├── x
-│ │ ├── y
-│ │ ├── z
+│ │ ├── position_x
+│ │ ├── position_y
+│ │ ├── position_z
│ ├── unstructured_mesh_1 (group data from a model using a unstructured mesh)
│ │ ├── time
-│ │ ├── position_nodes (Nnodes x3)
+│ │ ├── position_x (position of node on the x axis)
+│ │ ├── position_y (position of node on the y axis)
+│ │ ├── position_z (position of node on the z axis)
│ │ ├── connectivity (Nelements x Nvertices)
│ │ ├── temperature
│ │ ├── wind_u
@@ -336,36 +410,15 @@ The possible units fields are the following:
**Note**: This part of the standard is in an early stage and intentionally allows significant flexibility to accommodate diverse unstructured data types. The structure and required fields may evolve in future versions based on user feedback and practical experience.
-### polygons
-- Contains data stored as polygons with an explicit coordinate reference system (CRS), such as those derived from .kml or shapefiles.
-- All spatial coordinates must follow the Spatial Information Convention, including a required `crs` attribute at the group level. Optional attributes or datasets for holes/multipolygons can be added.
-- Each polygon is stored as a separate dataset within a group. This dataset contains the polygon geometry (list of vertices) and has its own attributes for time, CRS, and other metadata. Multipolygons (island, holes) can be stored in the same dataset as long as they share the same attributes.
-- Polygons that have a specific time stamp must contain an attribute `time` following the time format convention (each polygon dataset has its own time attribute).
-- Per-polygon attributes (e.g., building type, perimeter source) should be stored as attributes at the lowest common level. Group attributes are considered common to all datasets contained in the group. If information is specific to a polygon, it should be stored as a dataset attribute.
-- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
-- Each dataset (fire perimeter, buildings, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
-- Polygons are stored as (Nvertices, 2) or (Nvertices, 3) arrays following a Spatial Information Convention.
-
-```
-/ (root)
-├── polygons/ (geopolygones)
-│ ├── fire_perimeters (group containing fire perimeter polygons and related metadata)
-│ │ ├── perimeter_1 (polygons describing the perimeter at time 1)
-│ │ ├── perimeter_2 (polygons describing the perimeter at time 2)
-│ │ ├── perimeter_3 (polygons describing the perimeter at time 3)
-│ ├── buildings_info_1 (group data from a building dataset)
-│ │ ├── position_structure
-│ │ ├── roof_type
-```
-**Note**: This part of the standard is in an early stage and intentionally allows significant flexibility to accommodate diverse geopolygons data types. The structure and required fields may evolve in future versions based on user feedback and practical experience.
-
-
### fuel_models
- Contains data from a Fuel Model (Anderson/Albini, Scott and Burgan).
- Datasets must be grouped per fuel model. Fuel model extensions (new properties for an existing fuel model) must be added separately and be named with the suffix `_extension_*`.
-- Each fuel property (fuel load, fuel height, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint library](https://pint.readthedocs.io/en/stable/) terminology.
-- Each fuel property dataset must contain the attributes `long_name` describing the property, `unit`, and `type` describing the variable type in the numpy array (*e.g.* float64, object, int32). String variables will be using the object type.
+- Each fuel property (fuel load, fuel height, *etc.*) must be named using the [Standard Variable Namespace](./namespace.md). If the name of the variable is not present, use a variable name as descriptive as possible and open a pull request to add the variable name to the Standard Variable Namespace. Units must be defined as an attribute `units` compatible with [Pint](https://pint.readthedocs.io/en/stable/) terminology.
+- Each fuel property dataset must contain the attributes `long_name` describing the property, and `units`. Strings must be stored as UTF-8 variable-length strings.
- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
- The number of fuel categories contained in a fuel model must be specified by the attribute `nb_fuel_cat` of the fuel model group.
- In the following example, the array dimensions must share one dimension size defined by the attribute `nb_fuel_cat` of `Anderson13` and `WUDAPT10` groups. The size of the first dimension of all category-dependent datasets must match `nb_fuel_cat`. For example the dataset for a fuel parameter can have the shape ($N$) or ($N$, $N_2$) if $N$ is the number of fuel categories (`nb_fuel_cat`) and $N_2$ a parameter specific dimension (*e.g.*, size classes, depth layers).
@@ -390,4 +443,18 @@ The possible units fields are the following:
- Spatial and temporal metadata following the relevant conventions in this standard, if applicable.
- Naming of datasets should remain descriptive and avoid collisions with reserved names in the standard.
- Use of `/miscellaneous` should be temporary whenever possible; data types that become common should be proposed for inclusion in future versions of the standard.
-- The structure of `/miscellaneous` is unconstrained, but good practice is to group related datasets together to improve clarity.
\ No newline at end of file
+- The structure of `/miscellaneous` is unconstrained, but good practice is to group related datasets together to improve clarity.
+- The time coordinate dataset must be a dataset named `time`, and must use only one time encoding (absolute or relative); do not mix string and numeric (see Time format).
+- Users are encouraged to add an attribute `description` to groups and datasets for information/context about the data.
+- If missing values exist, the dataset must either:
+ - use `NaN` (float types) or
+ - define `_FillValue` attribute (any dtype) and ensure missing entries equal `_FillValue`. If `_FillValue` is present, it must match the dataset dtype.
+- `time`, `position_*`, `connectivity`, `crs`, `units`, `_FillValue` are reserved with their standard meanings.
+
+### Metadata
+
+- If `/metadata` is present, it must contain only datasets (no nested groups) and each dataset must have a `description` attribute.
+
+### Certificates
+
+- Reserved namespace for FireBench certicitates. See certification documentation.
\ No newline at end of file
diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md
index aae13cf..c3511e4 100644
--- a/docs/tutorials/index.md
+++ b/docs/tutorials/index.md
@@ -7,5 +7,4 @@ This section provides some tutorials to guide the use of the Firebench package.
change_fuel_model_ros.md
new_ros_model.md
-standard_file_probes.md
```
diff --git a/docs/tutorials/standard_file_probes.md b/docs/tutorials/standard_file_probes.md
deleted file mode 100644
index 5903b6a..0000000
--- a/docs/tutorials/standard_file_probes.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Standard I/O file format: Probes dataset
-
-This guide gives examples of the stucture of the standard IO file format for probes datasets.
-
-## Local sensor
-You want to store data from a sonic anemometer R.M. Young 81000. The dataset contains the wind speed for each cardinal direction. You know the relative location of the sensor relative to a reference point. The time series are given as seconds after a reference time.
-
-The structure of the HDF5 file is the following:
-### File level attributes
-
-See [Standard file format description](../standard_format.md) for mandatory file level attributes.
-
-### Groups and dataset
-```
-/
-├── probes
-│ └── Sonic_1
-│ ├── time (1D dataset)
-│ ├── wind_speed_u (1D dataset)
-│ ├── wind_speed_v (1D dataset)
-│ └── wind_speed_w (1D dataset)
-```
-### Group: `/probes/Sonic_1`
-**Attributes**
-Attribute | Type | Description
---------- | ---- | -----------
-`time_origin` | str | ISO 8601 date-time for the origin of time series
-`position_origin_lat` | float | Reference position latitude
-`position_origin_lon` | float | Reference position longitude
-`position_origin_alt` | float | Reference position altitude
-`position_rel_x` | float | Relative position in x direction (West-East)
-`position_rel_y` | float | Relative position in y direction (South-North)
-`position_rel_z` | float | Relative elevation
-`position_rel_units` | str | units of relative position
-`sensor_type` | str | Name of the sensor
-
-### Group: `/probes/Sonic_1/time`
-**Attributes**
-Attribute | Type | Description
---------- | ---- | -----------
-`units` | str | pint compatible unit
-
-### Group: `/probes/Sonic_1/wind_speed_u`
-**Attributes**
-Attribute | Type | Description
---------- | ---- | -----------
-`units` | str | pint compatible unit
-
-### Group: `/probes/Sonic_1/wind_speed_v`
-**Attributes**
-Attribute | Type | Description
---------- | ---- | -----------
-`units` | str | pint compatible unit
-
-### Group: `/probes/Sonic_1/wind_speed_w`
-**Attributes**
-Attribute | Type | Description
---------- | ---- | -----------
-`units` | str | pint compatible unit
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 9509484..bd461a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "firebench"
-version = "0.7.0"
+version = "0.8.0a"
authors = [
{name = "Aurélien Costes", email = "aurelien.costes31@gmail.com"},
]
@@ -13,13 +13,17 @@ maintainers = [
]
description = "FireBench is a Python library designed for the systematic benchmarking and inter-comparison of fire models."
dependencies = [
+ "geopandas < 2.0",
+ "h5py < 4.0",
+ "hdf5plugin >= 6.0",
+ "matplotlib > 3.8",
"numpy < 3.0",
"pint < 1.0",
- "scipy < 2.0",
+ "pyproj < 4.0",
+ "rasterio < 2.0",
+ "reportlab < 5.0",
"SALib < 2.0",
- "h5py < 4.0",
- "geopandas < 2.0",
- "matplotlib > 3.8",
+ "scipy < 2.0",
]
readme = "README.md"
license = {file = "LICENSE"}
@@ -41,13 +45,17 @@ classifiers = [
]
[project.optional-dependencies]
dev = [
+ "bandit",
+ "black",
+ "pylint",
"pytest",
"pytest-cov",
"pytest-mock",
- "bandit",
- "pylint",
- "black",
"shapely",
]
[tool.hatch.envs.default]
-features = ["dev"]
\ No newline at end of file
+features = ["dev"]
+[tool.setuptools]
+include-package-data = true
+[tool.setuptools.package-data]
+firebench_adapter_wrf_sfire = ["resources/*.json"]
\ No newline at end of file
diff --git a/src/firebench/__init__.py b/src/firebench/__init__.py
index 5eab604..4e10b38 100644
--- a/src/firebench/__init__.py
+++ b/src/firebench/__init__.py
@@ -1,6 +1,14 @@
-from . import ros_models, tools, wind_interpolation, stats, metrics, sensors
+from importlib.metadata import version, PackageNotFoundError
+from . import ros_models, tools, wind_interpolation, stats, metrics, sensors, standardize, signing
from .tools.logging_config import logger
from .tools.namespace import StandardVariableNames as svn
from .tools.units import ureg
Quantity = ureg.Quantity
+
+try:
+ __version__ = version("firebench")
+except PackageNotFoundError:
+ __version__ = "unknown"
+
+__all__ = ["__version__"]
diff --git a/src/firebench/metrics/__init__.py b/src/firebench/metrics/__init__.py
index ef78b26..1db21b3 100644
--- a/src/firebench/metrics/__init__.py
+++ b/src/firebench/metrics/__init__.py
@@ -1,2 +1,10 @@
-from . import perimeter
-from . import stats
+from . import perimeter, stats, mtbs, confusion_matrix
+from .kpi_normalization import (
+ kpi_norm_bounded_linear,
+ kpi_norm_half_open_linear,
+ kpi_norm_half_open_exponential,
+ kpi_norm_symmetric_open_linear,
+ kpi_norm_symmetric_open_exponential,
+)
+from .table import save_as_table
+from .tools import CTXKey, ctx_get_or_compute
diff --git a/src/firebench/metrics/confusion_matrix/__init__.py b/src/firebench/metrics/confusion_matrix/__init__.py
new file mode 100644
index 0000000..e0f9c6a
--- /dev/null
+++ b/src/firebench/metrics/confusion_matrix/__init__.py
@@ -0,0 +1,10 @@
+from .utils import binary_cm
+from .binary_performance import (
+ binary_accuracy,
+ binary_f_score,
+ binary_false_positive_rate,
+ binary_negative_predicted_value,
+ binary_precision,
+ binary_recall_rate,
+ binary_specificity,
+)
diff --git a/src/firebench/metrics/confusion_matrix/binary_performance.py b/src/firebench/metrics/confusion_matrix/binary_performance.py
new file mode 100644
index 0000000..801bf54
--- /dev/null
+++ b/src/firebench/metrics/confusion_matrix/binary_performance.py
@@ -0,0 +1,222 @@
+import numpy as np
+
+
+def binary_accuracy(bcm: np.ndarray):
+ """
+ Compute the binary classification accuracy from a 2x2 confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ where:
+ - TN: True Negatives
+ - FP: False Positives
+ - FN: False Negatives
+ - TP: True Positives
+
+ Accuracy is defined as the proportion of correct predictions out of all
+ predictions:
+
+ accuracy = (TP + TN) / (TP + TN + FP + FN)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+
+ Returns
+ -------
+ float
+ The accuracy value, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ return (tp + tn) / (tp + tn + fp + fn)
+
+
+def binary_precision(bcm: np.ndarray):
+ """
+ Compute the precision from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ where:
+ - TN: True Negatives
+ - FP: False Positives
+ - FN: False Negatives
+ - TP: True Positives
+
+ Precision (also called Positive Predictive Value, PPV) measures the proportion
+ of positive predictions that are correct:
+
+ precision = TP / (TP + FP)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The precision value, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ if tp + fp == 0:
+ return 0
+ return tp / (tp + fp)
+
+
+def binary_false_positive_rate(bcm: np.ndarray):
+ """
+ Compute the false positive rate (FPR) from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ False Positive Rate measures how often negative samples are incorrectly
+ classified as positive:
+
+ FPR = FP / (FP + TN)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The false positive rate, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ if fp + tn == 0:
+ return 0
+ return fp / (fp + tn)
+
+
+def binary_negative_predicted_value(bcm: np.ndarray):
+ """
+ Compute the negative predictive value (NPV) from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ Negative Predictive Value measures the proportion of predicted negatives
+ that are actually negative:
+
+ NPV = TN / (TN + FN)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The negative predictive value, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ if fn + tn == 0:
+ return 0
+ return tn / (fn + tn)
+
+
+def binary_recall_rate(bcm: np.ndarray):
+ """
+ Compute the recall rate (true positive rate, TPR) from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ Recall, also called sensitivity, measures how many actual positives are
+ correctly identified:
+
+ recall = TP / (TP + FN)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The recall value, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ if tp + fn == 0:
+ return 0
+ return tp / (tp + fn)
+
+
+def binary_specificity(bcm: np.ndarray):
+ """
+ Compute the specificity (true negative rate, TNR) from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ Specificity measures the proportion of actual negatives that are correctly
+ identified:
+
+ specificity = TN / (TN + FP)
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The specificity value, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ tn, fp, fn, tp = bcm.ravel()
+ if tn + fp == 0:
+ return 0
+ return tn / (tn + fp)
+
+
+def binary_f_score(bcm: np.ndarray):
+ """
+ Compute the F1-score from a 2x2 binary confusion matrix.
+
+ This function expects a confusion matrix of the form:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ The F1-score is the harmonic mean of precision and recall:
+
+ F1 = 2 * (precision * recall) / (precision + recall)
+
+ It provides a single metric that balances false positives and false negatives.
+
+ Parameters
+ ----------
+ bcm : numpy.ndarray
+ A 2x2 array representing the binary confusion matrix.
+
+ Returns
+ -------
+ float
+ The F1-score, ranging from 0.0 to 1.0.
+ """ # pylint: disable=line-too-long
+ pr = binary_precision(bcm)
+ rc = binary_recall_rate(bcm)
+ if pr + rc == 0:
+ return 0
+ return 2.0 * (pr * rc) / (pr + rc)
diff --git a/src/firebench/metrics/confusion_matrix/utils.py b/src/firebench/metrics/confusion_matrix/utils.py
new file mode 100644
index 0000000..b37acca
--- /dev/null
+++ b/src/firebench/metrics/confusion_matrix/utils.py
@@ -0,0 +1,51 @@
+import numpy as np
+
+
+def binary_cm(y_true, y_pred):
+ """
+ Compute the binary confusion matrix for two boolean-compatible arrays.
+
+ This function compares predicted binary values against ground-truth binary values
+ and computes the 2x2 confusion matrix in the conventional layout:
+
+ [[TN, FP],
+ [FN, TP]]
+
+ Both inputs must be broadcastable to the same shape and must contain values that
+ can be interpreted as booleans (e.g., {0,1}, {False,True}, or arrays castable to bool).
+
+ The logic is as follows:
+ - True Negative (TN): elements where both `y_true` and `y_pred` are False.
+ - False Positive (FP): elements where `y_true` is False but `y_pred` is True.
+ - False Negative (FN): elements where `y_true` is True but `y_pred` is False.
+ - True Positive (TP): elements where both `y_true` and `y_pred` are True.
+
+ Parameters
+ ----------
+ y_true : array_like
+ Ground-truth binary values. Must be castable to a boolean NumPy array.
+ y_pred : array_like
+ Predicted binary values. Must be castable to a boolean NumPy array.
+
+ Returns
+ -------
+ numpy.ndarray
+ A 2x2 NumPy array of integers arranged as:
+ [[TN, FP],
+ [FN, TP]]
+
+ Examples
+ --------
+ >>> binary_confusion_matrix_matrix([0, 1, 1, 0], [0, 1, 0, 0])
+ array([[2, 0],
+ [1, 1]])
+ """ # pylint: disable=line-too-long
+ y_true = np.asarray(y_true).astype(bool)
+ y_pred = np.asarray(y_pred).astype(bool)
+
+ tp = np.sum(y_true & y_pred)
+ tn = np.sum(~y_true & ~y_pred)
+ fp = np.sum(~y_true & y_pred)
+ fn = np.sum(y_true & ~y_pred)
+
+ return np.array([[tn, fp], [fn, tp]])
diff --git a/src/firebench/metrics/kpi_normalization.py b/src/firebench/metrics/kpi_normalization.py
new file mode 100644
index 0000000..e68ada3
--- /dev/null
+++ b/src/firebench/metrics/kpi_normalization.py
@@ -0,0 +1,190 @@
+from math import exp, log, isclose
+
+
+def kpi_norm_bounded_linear(x, a, b, rtol=1e-12, atol=1e-15):
+ """
+ Linearly normalize a KPI that has a fully bounded acceptable range [a, b].
+
+ This function maps:
+ - x = a -> score = 0
+ - x = b -> score = 100
+ with a strictly linear transformation.
+
+ Use this normalization when the KPI is known to lie within a finite closed interval.
+
+ Parameters
+ ----------
+ x : float
+ KPI value to normalize.
+ a : float
+ Lower bound of the acceptable range (score = 0).
+ b : float
+ Upper bound of the acceptable range (score = 100). Must satisfy a < b.
+
+ Returns
+ -------
+ float
+ Normalized score in the range with 0 at `a` and 100 at `b`.
+
+ Raises
+ ------
+ ValueError
+ If `x < a` or if `x > b`.
+ """
+ if x < a and not isclose(x, a, rel_tol=rtol, abs_tol=atol):
+ raise ValueError(f"KPI value {x} smaller than lower limit {a}")
+ if x > b and not isclose(x, b, rel_tol=rtol, abs_tol=atol):
+ raise ValueError(f"KPI value {x} greater than upper limit {b}")
+ return 100.0 * (x - a) / (b - a)
+
+
+def kpi_norm_half_open_linear(x, a, m, rtol=1e-12, atol=1e-15):
+ """
+ Linearly normalize a KPI defined on the half-open interval [a, \infty).
+
+ This function applies a linear decay starting from:
+ - x = a -> score = 100
+ - x = m -> score = 0
+ Values above `m` yield a score of 0 through clipping.
+
+ Use this normalization when the KPI has a minimum acceptable value `a`
+ but no finite upper bound, and when the degradation beyond `a` can be
+ meaningfully represented by a linear decline over the scale [a, m].
+
+ Parameters
+ ----------
+ x : float
+ KPI value to normalize. Must satisfy x >= a.
+ a : float
+ Minimum acceptable (or optimal) value at which the score is 100.
+ m : float
+ Threshold at which the score reaches 0. Must satisfy m > a.
+
+ Returns
+ -------
+ float
+ Normalized score in the range [0, 100], with linear decay from
+ 100 at `a` to 0 at `m`.
+
+ Raises
+ ------
+ ValueError
+ If `x < a` or if `m <= a`.
+ """
+ if x < a and not isclose(x, a, rel_tol=rtol, abs_tol=atol):
+ raise ValueError(f"KPI value {x} smaller than lower limit {a}")
+ if m <= a:
+ raise ValueError(f"Parameter m ({m}) smaller than lower limit a ({a})")
+ return 100.0 * max(0, 1 - (x - a) / (m - a))
+
+
+def kpi_norm_half_open_exponential(x, a, m, rtol=1e-12, atol=1e-15):
+ """
+ Exponentially normalize a KPI defined on the half-open interval [a, \infty).
+
+ This function applies a smooth exponential decay such that:
+ - x = a -> score = 100
+ - x = m -> score = 50
+ - x -> \infty -> score -> 0
+ ensuring a monotonic and asymptotic decline.
+
+ Use this normalization when the KPI has a minimum acceptable value `a`
+ but no finite upper bound, and when deviations beyond `a` should be
+ penalized progressively rather than linearly. The parameter `m` defines
+ the characteristic decay scale at which performance is reduced by half.
+
+ Parameters
+ ----------
+ x : float
+ KPI value to normalize. Must satisfy x >= a.
+ a : float
+ Minimum acceptable (or optimal) value at which the score is 100.
+ m : float
+ Value at which the score reaches 50. Must satisfy m > a.
+
+ Returns
+ -------
+ float
+ Normalized score in the range (0, 100], decaying exponentially
+ from 100 at `a` toward 0 as `x` increases.
+
+ Raises
+ ------
+ ValueError
+ If `x < a` or if `m <= a`.
+ """
+ if x < a and not isclose(x, a, rel_tol=rtol, abs_tol=atol):
+ raise ValueError(f"KPI value {x} smaller than lower limit {a}")
+ if m <= a:
+ raise ValueError(f"Parameter m ({m}) smaller than lower limit a ({a})")
+ return 100.0 * exp(-log(2) * (x - a) / (m - a))
+
+
+def kpi_norm_symmetric_open_linear(x, m):
+ """
+ Linearly normalize a KPI defined on the open interval (-\infty, \infty).
+
+ This function applies a smooth exponential decay such that:
+ - x = 0 -> score = 100
+ - |x| >= m -> score = 0
+
+ Use this normalization when deviations should be penalized linearly.
+
+ Parameters
+ ----------
+ x : float
+ KPI value to normalize.
+ m : float
+ Value at which the score reaches 0. Must satisfy m > 0.
+
+ Returns
+ -------
+ float
+ Normalized score in the range (0, 100], dlinear decay from
+ 100 at `a` to 0 at `m`.
+
+ Raises
+ ------
+ ValueError
+ If `m <= 0`.
+ """
+ if m <= 0:
+ raise ValueError(f"Parameter m ({m}) smaller than lower limit 0")
+ return 100.0 * max(0, 1 - abs(x) / m)
+
+
+def kpi_norm_symmetric_open_exponential(x, m):
+ """
+ Exponentially normalize a KPI defined on the open interval (-\infty, \infty).
+
+ This function applies a smooth exponential decay such that:
+ - x = 0 -> score = 100
+ - x = +/- m -> score = 50
+ - x -> \infty -> score -> 0
+ ensuring a monotonic and asymptotic decline.
+
+ Use this normalization when deviations should be
+ penalized progressively rather than linearly. The parameter `m` defines
+ the characteristic decay scale at which performance is reduced by half.
+
+ Parameters
+ ----------
+ x : float
+ KPI value to normalize.
+ m : float
+ Value at which the score reaches 50. Must satisfy m > 0.
+
+ Returns
+ -------
+ float
+ Normalized score in the range (0, 100], decaying exponentially
+ from 100 at `0` toward 0 as `x` increases.
+
+ Raises
+ ------
+ ValueError
+ If `m <= 0`.
+ """
+ if m <= 0:
+ raise ValueError(f"Parameter m ({m}) smaller than lower limit 0")
+ return 100.0 * exp(-log(2) * abs(x) / m)
diff --git a/src/firebench/metrics/mtbs/__init__.py b/src/firebench/metrics/mtbs/__init__.py
new file mode 100644
index 0000000..1cc055c
--- /dev/null
+++ b/src/firebench/metrics/mtbs/__init__.py
@@ -0,0 +1 @@
+from .mtbs_analysis import global_accuracy
diff --git a/src/firebench/metrics/mtbs/mtbs_analysis.py b/src/firebench/metrics/mtbs/mtbs_analysis.py
new file mode 100644
index 0000000..300feb9
--- /dev/null
+++ b/src/firebench/metrics/mtbs/mtbs_analysis.py
@@ -0,0 +1,210 @@
+import h5py
+import matplotlib.pyplot as plt
+import numpy as np
+from pyproj import CRS, Transformer
+from matplotlib.colors import BoundaryNorm, ListedColormap, Normalize
+from ...tools.namespace import StandardVariableNames as svn
+from ...tools.utils import FIGSIZE_DEFAULT
+
+COLORS_MTBS = [
+ (1, 1, 1, 0), # 0: no data
+ (0, 0.391, 0, 1), # 1: unburnt to low
+ (0.498, 1, 0.831, 1), # 2: low
+ (1, 1, 0, 1), # 3: moderate
+ (1, 0, 0, 1), # 4: high
+ (0.498, 1, 0.0039, 1), # 5: increased greenness
+]
+
+LABELS_MTBS = [
+ "0: no data",
+ "1: unburnt to low",
+ "2: low",
+ "3: moderate",
+ "4: high",
+ "5: increased greenness",
+]
+
+
+def global_accuracy(
+ filepath_eval: str,
+ mtbs_group_path_eval: str,
+ filepath_ref: str,
+ mtbs_group_path_ref: str,
+ figure_name: str = "mtbs_global_accuracy.png",
+ fig_dpi: int = 150,
+ ignore_greenness: bool = True,
+):
+ """
+ Run the MTBS global accuracy analysis between two datasets.
+
+ The evaluated dataset and the reference dataset are located (respectively) in
+ filepath_1/mtbs_group_path_1 (filepath_2/mtbs_group_path_2).
+
+ The mtbs group must contain the following datasets:
+ - fire_burn_severity
+ - latitude
+ - longitude
+ and the following attributes:
+ - crs
+ """
+ # Import evaluated dataset
+ with h5py.File(filepath_eval, "r") as h5:
+ grp = h5[mtbs_group_path_eval]
+ lat_eval = grp[svn.LATITUDE.value][:, :]
+ lon_eval = grp[svn.LONGITUDE.value][:, :]
+ mtbs_eval = grp[svn.FIRE_BURN_SEVERITY.value][:, :]
+ crs_eval = CRS(grp.attrs["crs"])
+
+ # Import reference dataset
+ with h5py.File(filepath_ref, "r") as h5:
+ grp = h5[mtbs_group_path_ref]
+ lat_ref = grp[svn.LATITUDE.value][:, :]
+ lon_ref = grp[svn.LONGITUDE.value][:, :]
+ mtbs_ref = grp[svn.FIRE_BURN_SEVERITY.value][:, :]
+ crs_ref = CRS(grp.attrs["crs"])
+
+ transform_crs_eval_to_ref = Transformer.from_crs(crs_eval, crs_ref, always_xy=True)
+
+ # TODO: interpolation nearest
+
+ # process greenness
+
+ # compute confusion matrix
+ # Flatten in case your labels are 2D rasters
+ y_true = np.asarray(mtbs_ref).ravel()
+ y_pred = np.asarray(mtbs_eval).ravel()
+
+ # If you know your class list; otherwise infer:
+ classes = np.unique(np.concatenate([y_true, y_pred]))
+ K = classes.size
+
+ # Map labels to 0..K-1 (robust if classes aren’t consecutive)
+ to_idx = {c: i for i, c in enumerate(classes)}
+ ti = np.vectorize(to_idx.get)(y_true)
+ pi = np.vectorize(to_idx.get)(y_pred)
+
+ # bincount trick
+ cm = np.bincount(ti * K + pi, minlength=K * K).reshape(K, K)
+
+ # calculate score
+ M = 100 * cm / cm.sum(axis=1, keepdims=True).clip(min=1)
+ accuracy_per_class_bps = np.zeros(5)
+ accuracy_per_class_total = np.zeros(5)
+
+ for k in range(K - 1):
+ accuracy_per_class_bps[k] = (
+ cm[k + 1, k + 1] / np.sum(cm[k + 1, 1:]) if np.sum(cm[k + 1, 1:]) > 0 else 0.0
+ )
+ accuracy_per_class_total[k] = cm[k + 1, k + 1] / (np.sum(cm[k + 1, :]) + cm[0, k + 1])
+
+ print(accuracy_per_class_bps)
+ print(accuracy_per_class_total)
+
+ # or using add.at
+ cm = np.zeros((K, K), dtype=int)
+ np.add.at(cm, (ti, pi), 1)
+
+ fig, axes = plt.subplots(2, 2, figsize=(5, 6), constrained_layout=True)
+ ax1 = axes[0, 0]
+ ax2 = axes[0, 1]
+ ax3 = axes[1, 0]
+ ax4 = axes[1, 1]
+
+ # Build discrete colormap
+ cmap = ListedColormap(COLORS_MTBS)
+ bounds = np.arange(-0.5, 6.5, 1)
+ norm = BoundaryNorm(bounds, cmap.N)
+
+ # panel 2: Evaluated
+ im1 = ax1.pcolormesh(lon_eval, lat_eval, mtbs_eval, cmap=cmap, norm=norm, edgecolors="none")
+
+ # panel 2: Reference
+ im2 = ax2.pcolormesh(lon_ref, lat_ref, mtbs_ref, cmap=cmap, norm=norm, edgecolors="none")
+
+ # panel 3: Difference
+ im3 = ax3.pcolormesh(
+ lon_ref,
+ lat_ref,
+ mtbs_eval - mtbs_ref,
+ cmap="RdYlGn_r",
+ norm=Normalize(vmin=-5, vmax=5),
+ edgecolors="none",
+ )
+
+ # panel 4: confusion matrix
+ im4 = ax4.imshow(M, cmap="Blues", origin="upper", interpolation="nearest")
+ ax4.set_xlabel("Evaluated")
+ ax4.set_ylabel("Reference")
+ ax4.set_xticks(np.arange(K), labels=classes)
+ ax4.set_yticks(np.arange(K), labels=classes)
+
+ # Shared colorbar on top of the first row
+ cbar = fig.colorbar(
+ im1,
+ ax=axes[0, :], # span first row only
+ orientation="horizontal",
+ location="top",
+ fraction=0.08,
+ pad=0.05, # control size/spacing
+ ticks=range(6),
+ )
+ cbar.ax.set_xticklabels(LABELS_MTBS)
+ cbar.ax.tick_params(axis="x", rotation=30)
+ plt.setp(cbar.ax.get_xticklabels(), ha="left")
+ cbar.set_label("MTBS classes", fontsize=10)
+
+ cbar = fig.colorbar(
+ im3,
+ ax=ax3,
+ orientation="horizontal",
+ location="bottom",
+ ticks=[-5, -2, 0, 2, 5],
+ label="Difference [-]",
+ )
+
+ for i in range(K):
+ for j in range(K):
+ val = M[i, j]
+ ax4.text(j, i, f"{val:3.0f}", ha="center", va="center", color="black", fontsize=7)
+
+ ax1.text(0.02, 0.9, "Evaluated", fontsize=8, transform=ax1.transAxes)
+ ax2.text(0.02, 0.9, "Reference", fontsize=8, transform=ax2.transAxes)
+ ax4.text(1.03, 1.02, "BPS", fontsize=8, transform=ax4.transAxes)
+ ax4.text(1.20, 1.02, "total", fontsize=8, transform=ax4.transAxes)
+ for k in range(K - 1):
+ ax4.text(
+ 1.03, 0.73 - 0.165 * k, f"{accuracy_per_class_bps[k]:.2f}", fontsize=7, transform=ax4.transAxes
+ )
+ ax4.text(
+ 1.20,
+ 0.73 - 0.165 * k,
+ f"{accuracy_per_class_total[k]:.2f}",
+ fontsize=7,
+ transform=ax4.transAxes,
+ )
+ ax4.text(1.03, 0.01, "__________", fontsize=7, transform=ax4.transAxes)
+ ax4.text(1.03, -0.1, f"{np.mean(accuracy_per_class_bps):.2f}", fontsize=7, transform=ax4.transAxes)
+ ax4.text(1.20, -0.1, f"{np.mean(accuracy_per_class_total):.2f}", fontsize=7, transform=ax4.transAxes)
+
+ cbar = fig.colorbar(
+ im4,
+ ax=ax4,
+ orientation="horizontal",
+ location="bottom",
+ )
+ cbar.set_label("Fraction") # or "Proportion" if normalized
+
+ fig.set_constrained_layout_pads(w_pad=0.02, h_pad=0.02, hspace=0.0, wspace=0.0)
+ for ax in axes.ravel():
+ ax.tick_params(direction="in", top=True, right=True, which="both")
+
+ for ax in [ax1, ax2, ax3]:
+ ax.set_xlabel("longitude [deg]")
+ ax.set_ylabel("latitude [deg]")
+
+ if not figure_name.endswith(".png"):
+ figure_name += ".png"
+
+ fig.savefig(figure_name, dpi=fig_dpi)
+
+ return fig
diff --git a/src/firebench/metrics/perimeter/shape_index.py b/src/firebench/metrics/perimeter/shape_index.py
index 4a03a3d..993d121 100644
--- a/src/firebench/metrics/perimeter/shape_index.py
+++ b/src/firebench/metrics/perimeter/shape_index.py
@@ -66,7 +66,7 @@ def jaccard_polygon(polygon1: GeoDataFrame, polygon2: GeoDataFrame, projection:
union = gpd.overlay(polygon1, polygon2, how="union", keep_geom_type=False)
# Compute and return IoU
- return intersection.area.sum() / union.area.sum()
+ return max(0, min(1, intersection.area.sum() / union.area.sum()))
def jaccard_binary(mask1: np.ndarray, mask2: np.ndarray) -> float:
@@ -106,7 +106,7 @@ def jaccard_binary(mask1: np.ndarray, mask2: np.ndarray) -> float:
if union == 0:
return 1.0 if intersection == 0 else 0.0 # Edge case: both masks empty
- return intersection / union
+ return max(0, min(1, intersection / union))
def sorensen_dice_polygon(
@@ -165,7 +165,7 @@ def sorensen_dice_polygon(
intersection = gpd.overlay(polygon1, polygon2, how="intersection", keep_geom_type=False)
# Compute and return sorensen
- return 2 * intersection.area.sum() / (polygon1.area.sum() + polygon2.area.sum())
+ return max(0, min(1, 2 * intersection.area.sum() / (polygon1.area.sum() + polygon2.area.sum())))
def sorensen_dice_binary(mask1: np.ndarray, mask2: np.ndarray) -> float:
@@ -207,4 +207,4 @@ def sorensen_dice_binary(mask1: np.ndarray, mask2: np.ndarray) -> float:
if denom == 0:
return 1.0 if intersection == 0 else 0.0 # Both masks empty or inconsistent
- return 2 * intersection / denom
+ return max(0, min(1, 2 * intersection / denom))
diff --git a/src/firebench/metrics/stats/__init__.py b/src/firebench/metrics/stats/__init__.py
index 2dbb0f6..c6b25c4 100644
--- a/src/firebench/metrics/stats/__init__.py
+++ b/src/firebench/metrics/stats/__init__.py
@@ -1 +1 @@
-from .dataset_1D import rmse, nmse_power, nmse_range, bias
+from .dataset_1D import rmse, nmse_power, nmse_range, bias, mae, circular_bias_deg
diff --git a/src/firebench/metrics/stats/dataset_1D.py b/src/firebench/metrics/stats/dataset_1D.py
index 1d9e3b3..017365b 100644
--- a/src/firebench/metrics/stats/dataset_1D.py
+++ b/src/firebench/metrics/stats/dataset_1D.py
@@ -31,7 +31,7 @@ def rmse(x1: np.ndarray, x2: np.ndarray) -> float:
if x1.shape != x2.shape:
raise ValueError(f"Input shapes must match, got {x1.shape} and {x2.shape}.")
- return np.sqrt(np.nanmean((x1 - x2) ** 2))
+ return float(np.sqrt(np.nanmean((x1 - x2) ** 2)))
def nmse_range(x1: np.ndarray, x2: np.ndarray) -> float:
@@ -72,7 +72,7 @@ def nmse_range(x1: np.ndarray, x2: np.ndarray) -> float:
"Cannot normalize RMSE: denominator is zero (no range in reference). Use nmse_power instead."
)
- return rmse(x1, x2) / denom
+ return float(rmse(x1, x2) / denom)
def nmse_power(x1: np.ndarray, x2: np.ndarray) -> float:
@@ -113,7 +113,7 @@ def nmse_power(x1: np.ndarray, x2: np.ndarray) -> float:
if denom == 0:
raise ValueError("Cannot normalize MSE: denominator is zero. Use nmse_range instead.")
- return np.nanmean((x1 - x2) ** 2) / denom
+ return float(np.nanmean((x1 - x2) ** 2) / denom)
def bias(x1: np.ndarray, x2: np.ndarray) -> float:
@@ -144,4 +144,80 @@ def bias(x1: np.ndarray, x2: np.ndarray) -> float:
if x1.shape != x2.shape:
raise ValueError(f"Input shapes must match, got {x1.shape} and {x2.shape}.")
- return np.nanmean(x1) - np.nanmean(x2)
+ return float(np.nanmean(x1) - np.nanmean(x2))
+
+
+def mae(x1: np.ndarray, x2: np.ndarray) -> float:
+ """
+ Compute the Mean Absolute Error between two arrays, ignoring NaNs.
+
+ Parameters
+ ----------
+ x1 : np.ndarray
+ First input array (e.g. prediction)
+ x2 : np.ndarray
+ Second input array of the same shape as x1 (e.g. observations)
+
+ Returns
+ -------
+ float
+ The bias value between x1 and x2.
+
+ Raises
+ ------
+ ValueError
+ If the two input arrays do not have the same shape.
+
+ Notes
+ -----
+ MAE = E(|x1 - x2|)
+ """ # pylint: disable=line-too-long
+ if x1.shape != x2.shape:
+ raise ValueError(f"Input shapes must match, got {x1.shape} and {x2.shape}.")
+
+ return np.nanmean(np.abs(x1 - x2))
+
+
+def circular_bias_deg(x1: np.ndarray, x2: np.ndarray) -> float:
+ """
+ Compute the bias between two angular arrays in degrees (0-360),
+ accounting for circularity and ignoring NaNs.
+
+ Parameters
+ ----------
+ x1 : np.ndarray
+ First input array (e.g. prediction), in degrees [0, 360)
+ x2 : np.ndarray
+ Second input array (e.g. observations), in degrees [0, 360)
+
+ Returns
+ -------
+ float
+ Circular bias in degrees, in the range (-180, 180].
+
+ Raises
+ ------
+ ValueError
+ If the two input arrays do not have the same shape.
+ """
+ if x1.shape != x2.shape:
+ raise ValueError(f"Input shapes must match, got {x1.shape} and {x2.shape}.")
+
+ # Mask NaNs jointly
+ mask = np.isfinite(x1) & np.isfinite(x2)
+ if not np.any(mask):
+ return np.nan
+
+ # Convert to radians
+ theta1 = np.deg2rad(x1[mask])
+ theta2 = np.deg2rad(x2[mask])
+
+ # Circular means
+ mean1 = np.arctan2(np.mean(np.sin(theta1)), np.mean(np.cos(theta1)))
+ mean2 = np.arctan2(np.mean(np.sin(theta2)), np.mean(np.cos(theta2)))
+
+ # Difference, wrapped to (-pi, pi]
+ dtheta = mean1 - mean2
+ dtheta = (dtheta + np.pi) % (2 * np.pi) - np.pi
+
+ return float(np.rad2deg(dtheta))
diff --git a/src/firebench/metrics/table.py b/src/firebench/metrics/table.py
new file mode 100644
index 0000000..72306c7
--- /dev/null
+++ b/src/firebench/metrics/table.py
@@ -0,0 +1,214 @@
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.units import mm
+from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
+from pathlib import Path
+from ..tools import logger
+from ..signing import verify_certificate_in_dict, DEFAULT_VL, VERIFICATION_LEVEL_COLORS
+
+
+def _score_to_color(score):
+ """
+ Map a score from 0 to 100 to a color from red -> yellow -> green.
+ Output: hex string "#RRGGBB".
+ """
+ if score < 33.33:
+ return "#D6452A"
+
+ if score < 66.66:
+ return "#E8C441"
+
+ return "#6BAF5F"
+
+
+def save_as_table(filename: Path, data: dict, signed: bool, certificate_name: str):
+ logger.info("Save data dict as score card report pdf")
+ if filename.suffix.lower() != ".pdf":
+ filename = filename.with_suffix(".pdf")
+
+ COLOR_ROWS = [
+ "#f7d5cd",
+ "#fbebe8",
+ ]
+
+ # Default Verification lvl
+ verif_lvl = DEFAULT_VL
+
+ if signed:
+ # Check validity of signature
+ verif = verify_certificate_in_dict(data, certificate_name)
+ assert verif["valid"]
+ verif_lvl = data.get("verification_lvl", DEFAULT_VL)
+
+ # Get the number of row
+ nb_rows = 3 # header and footer
+ nb_bench = len(data["benchmarks"].keys())
+ score_card = data.get("score_card")
+ if score_card is None:
+ # No aggregation, no total score
+ nb_rows += nb_bench
+ else:
+ # get number of rows from schemes
+ for group_name, group_content in data["score_card"]["Scheme"].items():
+ nb_rows += len(group_content["benchmarks"]) + 1
+
+ # ------------------------------------------------------------------
+ # 1) Create PDF
+ # ------------------------------------------------------------------
+ doc = SimpleDocTemplate(
+ str(filename.resolve()),
+ pagesize=(165 * mm, nb_rows * 8 * mm),
+ leftMargin=0 * mm,
+ rightMargin=0 * mm,
+ topMargin=0 * mm,
+ bottomMargin=0 * mm,
+ )
+
+ text_table = []
+
+ # header
+ scheme_name = "0"
+ valid_scheme = False
+ if "score_card" in data:
+ scheme_name = data["score_card"]["aggregation_scheme_name"]
+ valid_scheme = True
+ text_table.append(
+ [
+ f"Total Score {data['case_id']} agg. {scheme_name}: {data['evaluated_model_name']}",
+ "",
+ f"{verif_lvl}",
+ f"{data['score_card']['Score Total']:.2f}",
+ ]
+ )
+ else:
+ # Aggregation scheme is 0 or invalid
+ scheme_name = "No agg"
+ text_table.append(
+ [
+ f"Total Score {data['case_id']} agg. {scheme_name}: {data['evaluated_model_name']}",
+ "",
+ f"{verif_lvl}",
+ f"Invalid",
+ ]
+ )
+ text_table.append(["Benchmark ID/Group Name", "KPI value", "Weight", "Score"])
+
+ # rows
+ group_rows = []
+ if valid_scheme:
+ for group_name, group_content in data["score_card"]["Scheme"].items():
+ # add group row
+ group_score = data["score_card"][f"Score {group_name}"]
+ group_rows.append(len(text_table))
+ text_table.append(
+ [f"Group: {group_name}", "", f"{group_content['weight']}", f"{group_score:.2f}"]
+ )
+ # add benchamrk rows
+ for bench_id, bench_weight in group_content["benchmarks"].items():
+ for key, item in data["benchmarks"][bench_id].items():
+ if key == "Score":
+ bench_score = item
+ kpi_name = [i for i in data["benchmarks"][bench_id].keys() if i != "Score"][0]
+ else:
+ # KPI
+ kpi_score = item
+ text_table.append(
+ [f"{kpi_name}", f"{kpi_score:.2e}", f"{bench_weight}", f"{bench_score:.2f}"]
+ )
+ else:
+ # Only print benchmarks
+ for bench_id in data["benchmarks"].keys():
+ for key, item in data["benchmarks"][bench_id].items():
+ if key == "Score":
+ bench_score = item
+ else:
+ kpi_score = item
+ text_table.append([f"{bench_id}", f"{kpi_score:.2e}", "None", f"{bench_score:.2f}"])
+
+ # footer
+ text_table.append(
+ [
+ f"FireBench version: {data['firebench_version']} case version: {data['case_version']}",
+ "",
+ "",
+ "",
+ ]
+ )
+
+ col_widths = [100 * mm, 20 * mm, 20 * mm, 20 * mm]
+
+ # ------------------------------------------------------------------
+ # 3) Table style with both merges
+ # ------------------------------------------------------------------
+ table_style = [
+ # === MERGE FIRST 2 COLUMNS OF FIRST ROW ===
+ ("SPAN", (0, 0), (1, 0)),
+ # === MERGE ALL 4 COLUMNS OF LAST ROW ===
+ ("SPAN", (0, nb_rows - 1), (3, nb_rows - 1)),
+ # Borders
+ ("BOX", (0, 0), (-1, -1), 0.75, colors.black),
+ ("INNERGRID", (0, 0), (-1, -1), 0.25, colors.grey),
+ # Background colors for clarity
+ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#c04f15")), # merged header row
+ ("BACKGROUND", (0, 1), (-1, 1), colors.HexColor("#e97132")), # merged header row
+ (
+ "BACKGROUND",
+ (0, -1),
+ (-1, -1),
+ colors.HexColor("#A79F9A"),
+ ), # merged footer row
+ # Alignment
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("ALIGN", (0, 0), (0, -1), "LEFT"),
+ ("ALIGN", (0, -1), (0, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ # Fonts
+ ("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("FONT", (2, 0), (3, 0), "Helvetica-Bold", 9),
+ ]
+
+ for i in range(nb_rows - 3):
+ table_style.append(
+ (
+ "BACKGROUND",
+ (0, i + 2),
+ (-1, i + 2),
+ colors.HexColor(COLOR_ROWS[i % len(COLOR_ROWS)]),
+ ), # merged header row
+ )
+ table_style.append(("ALIGN", (0, i + 1), (0, i + 1), "LEFT"))
+
+ if valid_scheme:
+ table_style.append(
+ (
+ "BACKGROUND",
+ (3, 0),
+ (3, 0),
+ colors.HexColor(_score_to_color(data["score_card"]["Score Total"])),
+ ), # merged header row
+ )
+ for i_row in group_rows:
+ table_style.append(
+ ("BACKGROUND", (0, i_row), (-1, i_row), colors.HexColor("#f2aa84")),
+ )
+ else:
+ text_table[0][3] = "INVALID"
+ table_style.append(
+ ("BACKGROUND", (3, 0), (3, 0), colors.HexColor("#ff0000")), # merged header row
+ )
+ # VL colors
+ table_style.append(
+ (
+ "BACKGROUND",
+ (2, 0),
+ (2, 0),
+ colors.HexColor(VERIFICATION_LEVEL_COLORS.get(verif_lvl, "#B03A2E")),
+ ), # merged header row
+ )
+
+ table = Table(text_table, colWidths=col_widths)
+ style = TableStyle(table_style)
+ table.setStyle(style)
+
+ doc.build([table])
diff --git a/src/firebench/metrics/tools.py b/src/firebench/metrics/tools.py
new file mode 100644
index 0000000..b619c2d
--- /dev/null
+++ b/src/firebench/metrics/tools.py
@@ -0,0 +1,39 @@
+from typing import Tuple, Any, Callable
+import numpy as np
+from ..tools.logging_config import logger
+
+CTXKey = Tuple[str, str, str]
+ComputeFn = Callable[..., Any]
+
+
+class CtxKeyError(KeyError):
+ """Raised when a context key is not declared in CTX_SPEC."""
+
+
+class CtxValueError(RuntimeError):
+ """Raised when a cached value is missing/invalid in a way that suggests misuse."""
+
+
+def ctx_get_or_compute(
+ ctx_spec: dict[CTXKey, str],
+ ctx: dict[CTXKey, Any],
+ key: CTXKey,
+ compute: ComputeFn,
+ *args: Any,
+ **kwargs: Any,
+) -> Any:
+ if key not in ctx_spec.keys():
+ raise CtxKeyError(
+ f"Context key {key!r} is not declared in CTX_SPEC. "
+ "Declare it in CTX_SPEC to make cache usage explicit."
+ )
+
+ if key in ctx:
+ logger.debug("CTXKey %s found in CTX dict", key)
+ return ctx[key]
+
+ logger.debug("Add field %s to CTX dict", key)
+ value = compute(*args, **kwargs)
+ ctx[key] = value
+
+ return value
diff --git a/src/firebench/resources/public_keys/firebench-prod-2026-01.asc b/src/firebench/resources/public_keys/firebench-prod-2026-01.asc
new file mode 100644
index 0000000..6587fa6
--- /dev/null
+++ b/src/firebench/resources/public_keys/firebench-prod-2026-01.asc
@@ -0,0 +1,17 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEaWC/khYJKwYBBAHaRw8BAQdA00wHg77+hESSfbV1crMCZ4WiN/a6rjBH8jzi
+E+IKCOq0IUZpcmVCZW5jaCA8c2lnbmluZ0BmaXJlYmVuY2guZGV2PoiTBBMWCgA7
+FiEERm+QypXeOf8BL7VxhcT4mw0OxrwFAmlgv5ICGwMFCwkIBwICIgIGFQoJCAsC
+BBYCAwECHgcCF4AACgkQhcT4mw0OxryyvgEAzeReFglhdqTBR6aTfZwtzaVqwewG
+BK1UfTDox+LZTDUA/0afl2OpszJEJI3hf1DynlooNvLW04EWgGVRnNX7aIUJuDME
+aWC/khYJKwYBBAHaRw8BAQdA2DKPORDxUIWS0H4rtOXAxCxKP+ApB9YLriDQe0p7
+wQSIeAQYFgoAIBYhBEZvkMqV3jn/AS+1cYXE+JsNDsa8BQJpYL+SAhsgAAoJEIXE
++JsNDsa8bqIA/3QbgH/M+pKBpTeAjdnGNr/FAKrkSXDlcv7YMReuPRjBAQC6RcVI
+VMzmMHa7xXOjUGZuBLwPBPP+yNvOjQbDu+tIDrg4BGlgv5ISCisGAQQBl1UBBQEB
+B0AMLDbSYNrEVXn/1dNtmbCRjuuBZndZXD0meA7eNGHvJwMBCAeIeAQYFgoAIBYh
+BEZvkMqV3jn/AS+1cYXE+JsNDsa8BQJpYL+SAhsMAAoJEIXE+JsNDsa8npoA+wRg
+XtFh6nrVw8l4wj7wOyYLtKBC7RrgKCz83xJx0IsyAP0QnYNPaHeDY2eu098LR6HJ
+9Fc4ETcT/sdU8LDcBktJAA==
+=Yz53
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/firebench/resources/wx_sensor_height_providers.json b/src/firebench/resources/wx_sensor_height_providers.json
new file mode 100644
index 0000000..6db26bc
--- /dev/null
+++ b/src/firebench/resources/wx_sensor_height_providers.json
@@ -0,0 +1,24 @@
+{
+ "California Irrigation Management Information System": {
+ "air_temp_set_1": "1.5",
+ "relative_humidity_set_1": "1.5",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0"
+ },
+ "Bureau of Land Management": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "National Weather Service": {
+ "air_temp_set_1": "2.0",
+ "relative_humidity_set_1": "2.0",
+ "wind_direction_set_1": "10.0",
+ "wind_gust_set_1": "10.0",
+ "wind_speed_set_1": "10.0"
+ }
+}
\ No newline at end of file
diff --git a/src/firebench/resources/wx_sensor_height_stations.json b/src/firebench/resources/wx_sensor_height_stations.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/src/firebench/resources/wx_sensor_height_stations.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/src/firebench/resources/wx_sensor_height_trusted_history.json b/src/firebench/resources/wx_sensor_height_trusted_history.json
new file mode 100644
index 0000000..3e22110
--- /dev/null
+++ b/src/firebench/resources/wx_sensor_height_trusted_history.json
@@ -0,0 +1,125 @@
+{
+ "BDMC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "CI013": {
+ "air_temp_set_1": "1.5",
+ "provider": "California Irrigation Management Information System",
+ "relative_humidity_set_1": "1.5",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0"
+ },
+ "CI227": {
+ "air_temp_set_1": "1.5",
+ "provider": "California Irrigation Management Information System",
+ "relative_humidity_set_1": "1.5",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0"
+ },
+ "CI228": {
+ "air_temp_set_1": "1.5",
+ "provider": "California Irrigation Management Information System",
+ "relative_humidity_set_1": "1.5",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0"
+ },
+ "CI246": {
+ "air_temp_set_1": "1.5",
+ "provider": "California Irrigation Management Information System",
+ "relative_humidity_set_1": "1.5",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0"
+ },
+ "COKC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "GZFC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "KPVF": {
+ "air_temp_set_1": "2.0",
+ "provider": "National Weather Service",
+ "relative_humidity_set_1": "2.0",
+ "wind_direction_set_1": "10.0",
+ "wind_speed_set_1": "10.0"
+ },
+ "KTVL": {
+ "air_temp_set_1": "2.0",
+ "provider": "National Weather Service",
+ "relative_humidity_set_1": "2.0",
+ "wind_direction_set_1": "10.0",
+ "wind_gust_set_1": "10.0",
+ "wind_speed_set_1": "10.0"
+ },
+ "MKEC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "OWNC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "RBXC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "RWBC1": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "6.1",
+ "wind_gust_set_1": "6.1",
+ "wind_speed_set_1": "6.1"
+ },
+ "TS445": {
+ "air_temp_set_1": "2.0",
+ "fuel_moisture_set_1": "0.3",
+ "provider": "Bureau of Land Management",
+ "relative_humidity_set_1": "2.0",
+ "solar_radiation_set_1": "2.0",
+ "wind_direction_set_1": "2.0",
+ "wind_gust_set_1": "2.0",
+ "wind_speed_set_1": "2.0"
+ }
+}
\ No newline at end of file
diff --git a/src/firebench/signing/__init__.py b/src/firebench/signing/__init__.py
new file mode 100644
index 0000000..3cc14c2
--- /dev/null
+++ b/src/firebench/signing/__init__.py
@@ -0,0 +1,11 @@
+from .benchmarks import (
+ write_case_results,
+ retrieve_h5_certificates,
+ certify_benchmark_run,
+ verify_certificate_in_dict,
+ add_certificate_to_dict,
+ DEFAULT_VL,
+ VERIFICATION_LEVEL_COLORS,
+)
+from .std_files import add_certificate_to_h5, verify_certificates_in_h5
+from .certificates import Certificates, KeyId
diff --git a/src/firebench/signing/benchmarks.py b/src/firebench/signing/benchmarks.py
new file mode 100644
index 0000000..f2f2222
--- /dev/null
+++ b/src/firebench/signing/benchmarks.py
@@ -0,0 +1,247 @@
+import hmac
+from enum import Enum
+import json
+import numpy as np
+from .std_files import verify_certificates_in_h5
+from .utils import (
+ _canonical_json_dumps,
+ _canonical_json_dumps,
+ canonical_json_bytes,
+ gpg_detached_sign_armor,
+ gpg_verify_detached_with_pubkey,
+ sha256_hex,
+ short_hex,
+ GPGNotAvailable,
+ SignatureInvalid,
+ SignatureVerificationError,
+)
+from .certificates import Certificates, get_public_key
+from ..standardize import current_datetime_iso8601
+
+
+class Verification_lvl(Enum):
+ Aplus = "VL-A+"
+ A = "VL-A"
+ B = "VL-B"
+ C = "VL-C"
+
+
+VERIFICATION_LEVEL_COLORS = {
+ Verification_lvl.C.value: "#B03A2E",
+ Verification_lvl.B.value: "#D4AC0D",
+ Verification_lvl.A.value: "#2E86C1",
+ Verification_lvl.Aplus.value: "#1E8449",
+}
+
+RULES = [
+ (
+ {
+ "fb-benchmark-run-internal",
+ "obs-fb-verified-obs-dataset",
+ "model-fb-model-run-internal",
+ "model-fb-verified-input-requirements",
+ },
+ Verification_lvl.Aplus.value,
+ ),
+ (
+ {"fb-benchmark-run-internal", "obs-fb-verified-obs-dataset", "model-fb-model-run-internal"},
+ Verification_lvl.A.value,
+ ),
+ ({"fb-benchmark-run-internal", "obs-fb-verified-obs-dataset"}, Verification_lvl.B.value),
+]
+
+DEFAULT_VL = Verification_lvl.C.value
+
+
+def retrieve_h5_certificates(obs_file_path, model_file_path):
+ certificates = {}
+ certificates["from_obs_std_file"] = verify_certificates_in_h5(obs_file_path)
+ certificates["from_model_std_file"] = verify_certificates_in_h5(model_file_path)
+ return certificates
+
+
+def write_case_results(path: str, output_dict: dict):
+ with open(path, "w") as f:
+ json.dump(output_dict, f, indent=4, sort_keys=True)
+
+
+def add_certificate_to_dict(
+ data: dict,
+ key_in_dict: str,
+ cert_name: str,
+ key_id: str,
+ signer: str,
+ spec: str = "fb-cert-v1",
+):
+ if key_in_dict not in data or not isinstance(data[key_in_dict], dict):
+ data[key_in_dict] = {}
+
+ signed_at = current_datetime_iso8601()
+
+ unsigned_data = dict(data)
+ unsigned_data.pop(key_in_dict, None)
+
+ subject_digest = sha256_hex(_canonical_json_dumps(unsigned_data))
+
+ payload = {
+ "v": 1,
+ "cert_name": cert_name,
+ "spec": spec,
+ "signed_at": signed_at,
+ "key_id": key_id,
+ "subject_digest_sha256": subject_digest,
+ }
+
+ payload_bytes = _canonical_json_dumps(payload)
+ payload_sha = sha256_hex(payload_bytes)
+ cert_id = short_hex(payload_sha, 32)
+
+ signature_armor = gpg_detached_sign_armor(payload_bytes, signer=signer)
+
+ # Insert certificate
+ data[key_in_dict] = {
+ "certificate_id": cert_id,
+ "payload": payload,
+ "signature_armor": signature_armor,
+ }
+
+ return data, cert_id
+
+
+def verify_certificate_in_dict(
+ data: dict,
+ key_in_dict: str,
+):
+ """
+ Verify the final certificate embedded in a dict.
+
+ The dict must contain:
+ - data[key_in_dict]["certificate_id"]
+ - data[key_in_dict]["payload"]
+ - data[key_in_dict]["signature_armor"]
+
+ Returns a dict with:
+ - valid: bool
+ - error: str | None
+ - payload: dict
+ - subject_digest_sha256: str
+ """
+ if key_in_dict not in data or not isinstance(data[key_in_dict], dict):
+ return {
+ "valid": False,
+ "error": "missing final certificate",
+ "payload": None,
+ "subject_digest_sha256": None,
+ }
+
+ cert = data[key_in_dict]
+
+ required_keys = {"certificate_id", "payload", "signature_armor"}
+ if not required_keys.issubset(cert):
+ return {
+ "valid": False,
+ "error": "certificate structure incomplete",
+ "payload": None,
+ "subject_digest_sha256": None,
+ }
+
+ cert_id = cert["certificate_id"]
+ payload = cert["payload"]
+ sig_txt = cert["signature_armor"]
+
+ # --- Compute subject digest (exclude certificates) ---
+ unsigned_data = dict(data)
+ unsigned_data.pop(key_in_dict, None)
+
+ subject_digest = sha256_hex(canonical_json_bytes(unsigned_data))
+
+ payload_bytes = canonical_json_bytes(payload)
+
+ ok = True
+ err = None
+
+ # --- Check certificate_id matches payload hash ---
+ payload_sha = sha256_hex(payload_bytes)
+ expected_id = short_hex(payload_sha, 32)
+
+ if expected_id != cert_id:
+ ok = False
+ err = f"certificate_id mismatch: expected {expected_id}, found {cert_id}"
+
+ # --- Check subject digest binding ---
+ if ok and payload.get("subject_digest_sha256") != subject_digest:
+ ok = False
+ err = "subject_digest mismatch: JSON content changed or wrong file"
+
+ # --- Verify signature ---
+ if ok:
+ try:
+ key_id = payload.get("key_id")
+ if not key_id:
+ raise SignatureVerificationError("missing key_id in payload")
+
+ public_key_armor = get_public_key(key_id)
+ gpg_verify_detached_with_pubkey(payload_bytes, sig_txt, public_key_armor)
+
+ except GPGNotAvailable as e:
+ ok = False
+ err = f"verification unavailable: {e}"
+ except SignatureInvalid as e:
+ ok = False
+ err = f"signature invalid: {e}"
+ except SignatureVerificationError as e:
+ ok = False
+ err = f"verification error: {e}"
+
+ return {
+ "cert_id": cert_id,
+ "valid": ok,
+ "error": err,
+ "payload": payload,
+ "subject_digest_sha256": subject_digest,
+ }
+
+
+def certify_benchmark_run(
+ data: dict,
+ key_id: str,
+ signer: str,
+ spec: str = "fb-cert-v1",
+):
+ # add the certificate of benchmark run
+ data, _ = add_certificate_to_dict(
+ data, "certificate", Certificates.FB_BENCHMARK_RUN_INTERNAL.value, key_id, signer, spec
+ )
+ verif = verify_certificate_in_dict(data, "certificate")
+ found = {"fb-benchmark-run-internal": verif["valid"]}
+
+ input_verif: dict = data.get("certificates_input", {})
+ from_model: dict = input_verif.get("from_model_std_file", {})
+ for key, value in from_model.items():
+ try:
+ found[f"model-{key}"] = value["valid"]
+ except KeyError:
+ raise KeyError(f"Invalid key in certificates_input/from_model_std_file")
+
+ from_obs: dict = input_verif.get("from_obs_std_file", {})
+ for key, value in from_obs.items():
+ try:
+ found[f"obs-{key}"] = value["valid"]
+ except KeyError:
+ raise KeyError(f"Invalid key in certificates_input/from_model_std_file")
+
+ data["verification_lvl"] = compute_verification_lvl(found)
+
+ data, _ = add_certificate_to_dict(
+ data, "certificate_verif_lvl", Certificates.FB_VERIFICATION_LVL.value, key_id, signer, spec
+ )
+
+ return data
+
+
+def compute_verification_lvl(present: set[str]) -> int:
+ for required, value in RULES:
+ if required.issubset(present):
+ print(value)
+ return value
+ return DEFAULT_VL
diff --git a/src/firebench/signing/certificates.py b/src/firebench/signing/certificates.py
new file mode 100644
index 0000000..5c6adef
--- /dev/null
+++ b/src/firebench/signing/certificates.py
@@ -0,0 +1,32 @@
+from enum import Enum
+from importlib.resources import files
+from .utils import PublicKeyImportError
+
+
+class Certificates(Enum):
+ FB_VERIFIED_INPUT_REQUIREMENTS = "fb-verified-input-requirements"
+ FB_VERIFIED_OBS_DATASET = "fb-verified-obs-dataset"
+ FB_BENCHMARK_RUN_INTERNAL = "fb-benchmark-run-internal"
+ FB_MODEL_RUN_INTERNAL = "fb-model-run-internal"
+ FB_VERIFICATION_LVL = "fb-verification-lvl"
+ FB_SCORE_CARD = "fb-score-card"
+
+
+class KeyId(Enum):
+ FB_PROD_2026_01 = "fb-prod-2026-01"
+
+
+_FB_PUBLIC_KEYS = {
+ KeyId.FB_PROD_2026_01.value: "firebench-prod-2026-01.asc",
+}
+_DEFAULT_KEY_PATH = "resources/public_keys"
+
+
+def get_public_key(key_name):
+ try:
+ path = files("firebench").joinpath(f"{_DEFAULT_KEY_PATH}/{_FB_PUBLIC_KEYS[key_name]}")
+ except KeyError:
+ raise PublicKeyImportError(f"Public Key import fail for key {key_name}")
+ with open(path, "r", encoding="utf-8") as f:
+ pubkey_armor = f.read()
+ return pubkey_armor
diff --git a/src/firebench/signing/std_files.py b/src/firebench/signing/std_files.py
new file mode 100644
index 0000000..7a61e3b
--- /dev/null
+++ b/src/firebench/signing/std_files.py
@@ -0,0 +1,246 @@
+import json
+import os
+import hashlib
+import subprocess
+import tempfile
+from datetime import datetime, timezone
+from typing import Iterable, Dict, Any, Tuple, Optional
+import hdf5plugin
+
+import h5py
+import numpy as np
+
+from .utils import (
+ _canonical_json_dumps,
+ _is_excluded,
+ canonical_json_bytes,
+ gpg_detached_sign_armor,
+ gpg_verify_detached_with_pubkey,
+ sha256_hex,
+ short_hex,
+ GPGNotAvailable,
+ SignatureInvalid,
+ SignatureVerificationError,
+)
+from ..standardize import current_datetime_iso8601, CERTIFICATES
+from .certificates import get_public_key
+
+EXCLUDE_PREFIXES_DEFAULT = ["/certificates"]
+
+
+def add_certificate_to_h5(
+ h5_path: str,
+ cert_name: str,
+ key_id: str,
+ signer: str,
+ spec: str = "fb-cert-v1",
+ exclude_prefixes: list[str] = EXCLUDE_PREFIXES_DEFAULT,
+ remove_previous_certificates: bool = False,
+):
+ """
+ Compute subject digest and add a signed certificate to /fb/certificates//...
+ Returns certificate_id.
+
+ mode: a = append => add a new certificate
+ w = write => delete previous certificates
+ """
+ signed_at = current_datetime_iso8601()
+ subject_digest = hdf5_subject_digest_sha256(h5_path, exclude_prefixes=exclude_prefixes)
+
+ payload = {
+ "v": 1,
+ "cert_name": cert_name,
+ "spec": spec,
+ "signed_at": signed_at,
+ "key_id": key_id,
+ "subject_digest_sha256": subject_digest,
+ }
+
+ payload_bytes = _canonical_json_dumps(payload)
+ payload_sha = sha256_hex(payload_bytes)
+ cert_id = short_hex(payload_sha, 32)
+
+ signature_armor = gpg_detached_sign_armor(payload_bytes, signer=signer)
+
+ with h5py.File(h5_path, "a") as f:
+ if remove_previous_certificates and CERTIFICATES in f.keys():
+ del f[f"/{CERTIFICATES}"]
+ base = f.require_group(f"/{CERTIFICATES}").require_group(cert_id)
+ # Store payload + signature as UTF-8 datasets
+ if "payload" in base:
+ del base["payload"]
+ if "signature" in base:
+ del base["signature"]
+ base.create_dataset("payload", data=np.bytes_(payload_bytes.decode("utf-8")))
+ base.create_dataset("signature", data=np.bytes_(signature_armor))
+
+ # Helpful attrs for indexing
+ base.attrs["cert_name"] = cert_name
+ base.attrs["spec"] = spec
+ base.attrs["signed_at"] = signed_at
+ base.attrs["key_id"] = key_id
+ base.attrs["subject_digest_sha256"] = subject_digest
+
+ return cert_id
+
+
+def verify_certificates_in_h5(
+ h5_path: str,
+ exclude_prefixes: list[str] = EXCLUDE_PREFIXES_DEFAULT,
+):
+ """
+ Verify all certificates under /certificates.
+ Returns dict cert_id -> info (payload fields, valid flag).
+ """
+ subject_digest = hdf5_subject_digest_sha256(h5_path, exclude_prefixes=exclude_prefixes)
+
+ results = {}
+ with h5py.File(h5_path, "r") as f:
+ if f"/{CERTIFICATES}" not in f:
+ return results
+
+ certs = f[f"/{CERTIFICATES}"]
+ for cert_id in sorted(certs.keys()):
+ grp = certs[cert_id]
+ payload_txt = (
+ grp["payload"][()].decode("utf-8")
+ if isinstance(grp["payload"][()], (bytes, np.bytes_))
+ else str(grp["payload"][()])
+ )
+ sig_txt = (
+ grp["signature"][()].decode("utf-8")
+ if isinstance(grp["signature"][()], (bytes, np.bytes_))
+ else str(grp["signature"][()])
+ )
+
+ public_key_armor = get_public_key(str(grp.attrs.get("key_id")))
+
+ payload = json.loads(payload_txt)
+ payload_bytes = canonical_json_bytes(payload)
+
+ # Check certificate_id matches payload hash prefix
+ payload_sha = sha256_hex(payload_bytes)
+ expected_id = short_hex(payload_sha, 32)
+
+ ok = True
+ err = None
+
+ if expected_id != cert_id:
+ ok = False
+ err = f"certificate_id mismatch: expected {expected_id}, found {cert_id}"
+
+ # Check subject digest binding
+ if ok and payload.get("subject_digest_sha256") != subject_digest:
+ ok = False
+ err = "subject_digest mismatch: HDF5 content changed (excluding certificates) or wrong file"
+
+ # Verify signature
+ if ok:
+ try:
+ gpg_verify_detached_with_pubkey(payload_bytes, sig_txt, public_key_armor)
+ except GPGNotAvailable as e:
+ ok = False
+ err = f"verification unavailable: {e}"
+ except SignatureInvalid as e:
+ ok = False
+ err = f"signature invalid: {e}"
+ except SignatureVerificationError as e:
+ ok = False
+ err = f"verification error: {e}"
+
+ results[payload["cert_name"]] = {
+ "cert_id": cert_id,
+ "valid": ok,
+ "error": err,
+ "payload": payload,
+ "subject_digest_sha256": subject_digest,
+ }
+
+ return results
+
+
+def hdf5_subject_digest_sha256(path: str, exclude_prefixes: list[str] = EXCLUDE_PREFIXES_DEFAULT) -> str:
+ """
+ Deterministic logical digest of an HDF5 file excluding some prefixes.
+ """
+ h = hashlib.sha256()
+ with h5py.File(path, "r") as f:
+ _hash_group(h, f["/"], exclude_prefixes)
+ return h.hexdigest()
+
+
+def _hash_attrs(h: hashlib._hashlib.HASH, attrs: h5py.AttributeManager):
+ # Deterministic ordering by attribute name
+ for k in sorted(attrs.keys()):
+ v = attrs[k]
+ h.update(b"ATTR\x00")
+ h.update(k.encode("utf-8"))
+ h.update(b"\x00")
+ # Normalize attribute value to bytes deterministically
+ if isinstance(v, bytes):
+ vb = v
+ elif isinstance(v, str):
+ vb = v.encode("utf-8")
+ elif np.isscalar(v):
+ vb = str(v).encode("utf-8")
+ else:
+ arr = np.array(v)
+ vb = arr.tobytes(order="C")
+ h.update(b"SHAPE\x00" + str(arr.shape).encode("utf-8") + b"\x00")
+ h.update(b"DTYPE\x00" + str(arr.dtype).encode("utf-8") + b"\x00")
+ h.update(vb)
+ h.update(b"\x00")
+
+
+def _hash_dataset(h: hashlib._hashlib.HASH, ds: h5py.Dataset):
+ h.update(b"DATASET\x00")
+ h.update(ds.name.encode("utf-8"))
+ h.update(b"\x00")
+ h.update(b"SHAPE\x00" + str(ds.shape).encode("utf-8") + b"\x00")
+ h.update(b"DTYPE\x00" + str(ds.dtype).encode("utf-8") + b"\x00")
+ _hash_attrs(h, ds.attrs)
+
+ # Hash data in chunks to avoid large memory usage
+ # For scalar datasets, ds[()] is fine
+ if ds.size == 0:
+ h.update(b"EMPTY\x00")
+ return
+
+ # For simple numeric datasets: chunk along first axis
+ if ds.ndim == 0:
+ data = ds[()]
+ h.update(np.array(data).tobytes(order="C"))
+ h.update(b"\x00")
+ return
+
+ # Chunk size: ~4MB of raw bytes
+ itemsize = ds.dtype.itemsize
+ # Avoid division by zero
+ row_bytes = int(np.prod(ds.shape[1:])) * itemsize if ds.ndim >= 2 else itemsize
+ rows_per_chunk = max(1, (4 * 1024 * 1024) // max(1, row_bytes))
+
+ for i0 in range(0, ds.shape[0], rows_per_chunk):
+ i1 = min(ds.shape[0], i0 + rows_per_chunk)
+ slab = ds[i0:i1, ...]
+ h.update(np.asarray(slab).tobytes(order="C"))
+ h.update(b"\x00")
+
+
+def _hash_group(h: hashlib._hashlib.HASH, grp: h5py.Group, exclude_prefixes: Iterable[str]) -> None:
+ h.update(b"GROUP\x00")
+ h.update(grp.name.encode("utf-8"))
+ h.update(b"\x00")
+ _hash_attrs(h, grp.attrs)
+
+ # Deterministic traversal by child name
+ for name in sorted(grp.keys()):
+ child = grp[name]
+ if _is_excluded(child.name, exclude_prefixes):
+ continue
+ if isinstance(child, h5py.Group):
+ _hash_group(h, child, exclude_prefixes)
+ elif isinstance(child, h5py.Dataset):
+ _hash_dataset(h, child)
+ else:
+ # Rare HDF5 types; include name only
+ h.update(b"OTHER\x00" + child.name.encode("utf-8") + b"\x00")
diff --git a/src/firebench/signing/utils.py b/src/firebench/signing/utils.py
new file mode 100644
index 0000000..c053a50
--- /dev/null
+++ b/src/firebench/signing/utils.py
@@ -0,0 +1,97 @@
+import os
+import json
+import subprocess
+import hashlib
+import tempfile
+
+
+class SignatureVerificationError(Exception):
+ """Base class for signature verification failures."""
+
+
+class GPGNotAvailable(SignatureVerificationError):
+ """GPG is not installed or not executable."""
+
+
+class SignatureInvalid(SignatureVerificationError):
+ """Signature verification failed."""
+
+
+class PublicKeyImportError(SignatureVerificationError):
+ """Public key could not be imported."""
+
+
+def _canonical_json_dumps(data: dict) -> bytes:
+ return json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
+
+
+def canonical_json_bytes(obj) -> bytes:
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+
+
+def sha256_hex(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def short_hex(hex_digest: str, n: int = 32) -> str:
+ return hex_digest[:n]
+
+
+def _is_excluded(path: str, exclude_prefixes: list[str]) -> bool:
+ return any(path == p or path.startswith(p + "/") for p in exclude_prefixes)
+
+
+def gpg_detached_sign_armor(message: bytes, signer: str) -> str:
+ """
+ Create an ASCII-armored detached signature over message bytes.
+ Requires interactive pinentry to work (set GPG_TTY).
+ """
+ p = subprocess.run(
+ ["gpg", "--batch", "--yes", "--armor", "--detach-sign", "-u", signer, "--output", "-", "--"],
+ input=message,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ if p.returncode != 0:
+ raise RuntimeError("GPG signing failed:\n" + p.stderr.decode("utf-8", errors="replace"))
+ return p.stdout.decode("utf-8")
+
+
+def gpg_verify_detached_with_pubkey(message: bytes, signature_armor: str, public_key_armor: str) -> None:
+ if not public_key_armor.strip():
+ raise PublicKeyImportError("Public key armor is required.")
+
+ try:
+ with tempfile.TemporaryDirectory() as gnupghome:
+ env = os.environ.copy()
+ env["GNUPGHOME"] = gnupghome
+
+ imp = subprocess.run(
+ ["gpg", "--batch", "--yes", "--import"],
+ input=public_key_armor.encode("utf-8"),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ check=False,
+ )
+ if imp.returncode != 0:
+ raise PublicKeyImportError(imp.stderr.decode("utf-8", errors="replace"))
+
+ sig_path = os.path.join(gnupghome, "sig.asc")
+ with open(sig_path, "w", encoding="utf-8") as f:
+ f.write(signature_armor)
+
+ ver = subprocess.run(
+ ["gpg", "--batch", "--verify", sig_path, "-"],
+ input=message,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ check=False,
+ )
+ if ver.returncode != 0:
+ raise SignatureInvalid(ver.stderr.decode("utf-8", errors="replace"))
+
+ except FileNotFoundError as e:
+ raise GPGNotAvailable("gpg is not installed or not on PATH") from e
diff --git a/src/firebench/standardize/__init__.py b/src/firebench/standardize/__init__.py
new file mode 100644
index 0000000..110a49c
--- /dev/null
+++ b/src/firebench/standardize/__init__.py
@@ -0,0 +1,43 @@
+from .mtbs import standardize_mtbs_from_geotiff
+from .tools import (
+ VERSION_STD,
+ import_tif_with_rect_box,
+ is_iso8601,
+ merge_authors,
+ read_numeric_attribute,
+ read_quantity_from_fb_attribute,
+ read_quantity_from_fb_dataset,
+ read_string_attribute,
+ validate_h5_requirement,
+ validate_h5_std,
+ validate_h5_weather_stations_structure,
+)
+from .time import (
+ current_datetime_iso8601,
+ datetime_to_iso8601,
+ sanitize_iso8601,
+)
+from .files import (
+ merge_std_files,
+ merge_two_std_files,
+ new_std_file,
+)
+from .landfire import standardize_landfire_from_geotiff
+from .ravg import (
+ standardize_ravg_ba_from_geotiff,
+ standardize_ravg_cbi_from_geotiff,
+ standardize_ravg_cc_from_geotiff,
+)
+from .std_file_info import (
+ POINTS,
+ TIME_SERIES,
+ SPATIAL_1D,
+ SPATIAL_2D,
+ SPATIAL_3D,
+ GEOPOLYGONS,
+ FUEL_MODELS,
+ MISCELLANEOUS,
+ CERTIFICATES,
+)
+from .synoptic import standardize_synoptic_raws_from_json
+from .synoptic_data import SH_TRUST_HIGHEST
diff --git a/src/firebench/standardize/files.py b/src/firebench/standardize/files.py
new file mode 100644
index 0000000..920f776
--- /dev/null
+++ b/src/firebench/standardize/files.py
@@ -0,0 +1,162 @@
+import h5py
+from ..tools.logging_config import logger
+from .time import current_datetime_iso8601
+from pathlib import Path
+from .tools import VERSION_STD, validate_h5_std, merge_authors, collect_conflicts, merge_trees, logger
+from pathlib import Path
+import shutil
+
+
+def new_std_file(filepath: str, authors: str, overwrite: bool = False) -> h5py.File:
+ """
+ Create a new file using FireBench standard.
+ Return the file object.
+
+ Notes
+ -----
+ Do not forget to close the file once edited. This function opens the h5 file but do not close it.
+ """
+ new_file_path = Path(filepath)
+ new_file_path.parent.mkdir(parents=True, exist_ok=True)
+ if new_file_path.exists():
+ if overwrite:
+ logger.info("file %s already exists and is being replaced.", filepath)
+ else:
+ logger.error(
+ "file %s already exists. Use `overwrite=True` to overwrite the existing file.", filepath
+ )
+ raise FileExistsError()
+
+ h5 = h5py.File(filepath, mode="w")
+ h5.attrs["FireBench_io_version"] = VERSION_STD
+ h5.attrs["created_on"] = current_datetime_iso8601(include_seconds=False)
+ h5.attrs["created_by"] = authors
+
+ return h5
+
+
+def merge_two_std_files(
+ filepath_1: str,
+ filepath_2: str,
+ filepath_target: str,
+ merged_description: str = "",
+ overwrite: bool = False,
+ compression_lvl: int = 3,
+):
+ """
+ Try to merge two std FireBench files
+
+ Check if both files are std, then check for any group/dataset/attribut conflict
+
+ Then merge the list of authors without duplicates. Keep order as much as possible (first authors from file1 then first author from file2 then second from file 1...)
+ """
+ logger.info(
+ "merge_two_std_files: merge %s with %s into %s with compression level %s",
+ filepath_1,
+ filepath_2,
+ filepath_target,
+ compression_lvl,
+ )
+ file1 = h5py.File(filepath_1, "r")
+ validate_h5_std(file1)
+ file2 = h5py.File(filepath_2, "r")
+ validate_h5_std(file2)
+
+ # Check for any conflicts
+ conflicts = collect_conflicts(file1, file2)
+ if conflicts:
+ logger.critical("Try to merge files but conflicts have been found.")
+ print(conflicts)
+ raise ValueError("Try to merge files but conflicts have been found.")
+
+ # Find both list of authors
+ merged_authors = merge_authors(file1.attrs["created_by"], file2.attrs["created_by"])
+
+ # Create the new file
+ merged_file = new_std_file(filepath_target, authors=merged_authors, overwrite=overwrite)
+
+ merge_trees(file1, file2, merged_file, compression_lvl=compression_lvl)
+
+ merged_file.attrs["description"] = merged_description
+
+ # fill the content of merged_file witht the content of both files
+
+ file1.close()
+ file2.close()
+ merged_file.close()
+ logger.info("Standard files merge successfull")
+
+
+def merge_std_files(
+ filespath: list[str],
+ filepath_target: str,
+ merged_description: str = "",
+ overwrite: bool = False,
+ compression_lvl: int = 3,
+):
+ if not filespath:
+ raise ValueError("filespath must contain at least one file")
+
+ input_paths = [Path(p) for p in filespath]
+ target_path = Path(filepath_target)
+
+ for p in input_paths:
+ if not p.is_file():
+ raise FileNotFoundError(f"Input file not found: {p}")
+
+ # Single file: just copy it to target (no merge needed)
+ if len(input_paths) == 1:
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(input_paths[0], target_path)
+ return
+
+ # Prepare two alternating temporary files
+ suffix = target_path.suffix
+ tmp1 = target_path.with_suffix(suffix + ".tmp1")
+ tmp2 = target_path.with_suffix(suffix + ".tmp2")
+
+ # Ensure parent directory exists for target and temp files
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+
+ current_path: Path = input_paths[0]
+
+ try:
+ for i, next_path in enumerate(input_paths[1:], start=1):
+ is_last = i == len(input_paths) - 1
+
+ if is_last:
+ out_path = target_path
+ compression_lvl_used = compression_lvl
+ overwrite_used = overwrite
+ else:
+ # Alternate between tmp1 and tmp2, ensuring out_path != current_path
+ if current_path == tmp1:
+ out_path = tmp2
+ elif current_path == tmp2:
+ out_path = tmp1
+ else:
+ # First time we merge, we can pick any temp file
+ out_path = tmp1
+ compression_lvl_used = 1 # minimal compression for tmp files
+ overwrite_used = True
+
+ merge_two_std_files(
+ filepath_1=str(current_path),
+ filepath_2=str(next_path),
+ filepath_target=str(out_path),
+ merged_description=merged_description,
+ overwrite=overwrite_used,
+ compression_lvl=compression_lvl_used,
+ )
+
+ current_path = out_path
+
+ finally:
+ # Clean up temporary files if they exist
+ for tmp in (tmp1, tmp2):
+ if tmp.exists() and tmp != target_path:
+ try:
+ tmp.unlink()
+ except OSError:
+ # If removal fails, we silently ignore; nothing critical.
+ pass
diff --git a/src/firebench/standardize/landfire.py b/src/firebench/standardize/landfire.py
new file mode 100644
index 0000000..675c321
--- /dev/null
+++ b/src/firebench/standardize/landfire.py
@@ -0,0 +1,91 @@
+from pathlib import Path
+
+import h5py
+import hdf5plugin
+import numpy as np
+import rasterio
+from pyproj import CRS, Transformer
+
+from ..tools import StandardVariableNames as svn
+from .tools import check_std_version, import_tif
+from ..tools.logging_config import logger
+from .std_file_info import SPATIAL_2D
+
+
+def standardize_landfire_from_geotiff(
+ geotiff_path: str,
+ h5file: h5py.File,
+ variable_name: str,
+ variable_units: str,
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ fill_value: float = None,
+ compression_lvl: int = 3,
+):
+ """
+ Convert a MTBS GeoTIFF to Firebench HDF5 standard file.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : str
+ Path to the MTBS GeoTIFF (ending with *_dnbr6.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. '2D_raster/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """ # pylint: disable=line-too-long
+ logger.debug("Standardize LANDFIRE dataset from file %s ", geotiff_path)
+ check_std_version(h5file)
+ lat, lon, landfire_data, crs, nodata = import_tif(geotiff_path, projection, invert_y)
+
+ if group_name is None:
+ group_name = Path(geotiff_path).stem
+
+ group_name = f"/{SPATIAL_2D}/{group_name}"
+ if group_name in h5file.keys():
+ if overwrite:
+ del h5file[group_name]
+ else:
+ logger.warning(
+ "group name %s already exists in file %s. Group not updated. Set `overwrite` to True to update the dataset.",
+ group_name,
+ geotiff_path,
+ )
+ return
+
+ g = h5file.create_group(group_name)
+ g.attrs["data_source"] = f"LANDFIRE {geotiff_path}"
+ g.attrs["crs"] = str(crs)
+
+ # Lat/Lon as 2-D arrays
+ dlat = g.create_dataset(
+ "position_lat", data=lat, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ dlat = g.create_dataset(
+ "position_lon", data=lon, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ ddata = g.create_dataset(
+ variable_name, data=landfire_data, dtype=np.uint16, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ ddata.attrs["units"] = variable_units
+ if fill_value is None:
+ ddata.attrs["_FillValue"] = nodata
+ else:
+ ddata.attrs["_FillValue"] = fill_value
diff --git a/src/firebench/standardize/mtbs.py b/src/firebench/standardize/mtbs.py
new file mode 100644
index 0000000..eb6b15c
--- /dev/null
+++ b/src/firebench/standardize/mtbs.py
@@ -0,0 +1,124 @@
+from pathlib import Path
+
+import h5py
+import hdf5plugin
+import numpy as np
+import rasterio
+from pyproj import CRS, Transformer
+
+from ..tools import StandardVariableNames as svn
+from .tools import check_std_version
+from ..tools.logging_config import logger
+from .std_file_info import SPATIAL_2D
+
+
+def standardize_mtbs_from_geotiff(
+ geotiff_path: str,
+ h5file: h5py.File,
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ compression_lvl: int = 3,
+):
+ """
+ Convert a MTBS GeoTIFF to Firebench HDF5 standard file.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : str
+ Path to the MTBS GeoTIFF (ending with *_dnbr6.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. 'SPATIAL_2D/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """ # pylint: disable=line-too-long
+ logger.debug("Standardize MTBS dataset from file %s ", geotiff_path)
+ check_std_version(h5file)
+ with rasterio.open(geotiff_path) as src:
+ data = src.read(1)
+ severity_raw = {"data": data, "transform": src.transform, "crs": src.crs, "nodata": src.nodata}
+ logger.info(f"Loaded {geotiff_path}: shape={data.shape}, CRS={src.crs}")
+
+ rows, cols = data.shape
+
+ # Build pixel center coordinates (projected)
+ # col indices (x-direction), row indices (y-direction)
+ jj = np.arange(cols)
+ ii = np.arange(rows)
+ # vectorized center coordinates from affine:
+ # x = a*col + b*row + c ; y = d*col + e*row + f
+ x = (
+ severity_raw["transform"].a * jj[None, :]
+ + severity_raw["transform"].b * ii[:, None]
+ + severity_raw["transform"].c
+ )
+ y = (
+ severity_raw["transform"].d * jj[None, :]
+ + severity_raw["transform"].e * ii[:, None]
+ + severity_raw["transform"].f
+ )
+
+ # Reproject to geographic lat/lon
+ if projection is None:
+ projection = severity_raw["crs"]
+ tgt_crs = CRS(projection)
+
+ # always_xy=True -> transformer expects/returns (x, y) = (lon, lat) ordering for geographic CRS
+ transformer = Transformer.from_crs(severity_raw["crs"], tgt_crs, always_xy=True)
+ lon, lat = transformer.transform(x, y) # lon, lat are 2-D arrays aligned with `data`
+
+ if invert_y:
+ lat = lat[::-1, :]
+ lon = lon[::-1, :]
+ severity_raw["data"] = severity_raw["data"][::-1, :]
+
+ if group_name is None:
+ group_name = Path(geotiff_path).stem
+
+ group_name = f"/{SPATIAL_2D}/{group_name}"
+ if group_name in h5file.keys():
+ if overwrite:
+ del h5file[group_name]
+ else:
+ logger.warning(
+ "group name %s already exists in file %s. Group not updated. Set `overwrite` to True to update the dataset.",
+ group_name,
+ geotiff_path,
+ )
+ return
+
+ g = h5file.create_group(group_name)
+ g.attrs["data_source"] = f"MTBS {geotiff_path}"
+ g.attrs["crs"] = str(tgt_crs)
+
+ # Lat/Lon as 2-D arrays
+ dlat = g.create_dataset(
+ "position_lat", data=lat, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ dlat = g.create_dataset(
+ "position_lon", data=lon, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ ddata = g.create_dataset(
+ svn.FIRE_BURN_SEVERITY.value,
+ data=severity_raw["data"],
+ dtype=np.uint8,
+ **hdf5plugin.Zstd(clevel=compression_lvl),
+ )
+ ddata.attrs["units"] = "dimensionless"
+ ddata.attrs["_FillValue"] = 0
diff --git a/src/firebench/standardize/ravg.py b/src/firebench/standardize/ravg.py
new file mode 100644
index 0000000..fb3e4a7
--- /dev/null
+++ b/src/firebench/standardize/ravg.py
@@ -0,0 +1,241 @@
+import h5py
+import hdf5plugin
+import numpy as np
+from .tools import check_std_version, import_tif_with_rect_box
+from ..tools.logging_config import logger
+from ..tools import StandardVariableNames as svn
+from pathlib import Path
+from .std_file_info import SPATIAL_2D
+
+
+def standardize_ravg_cc_from_geotiff(
+ geotiff_path: Path,
+ h5file: h5py.File,
+ lower_left_corner: tuple[float, float],
+ upper_right_corner: tuple[float, float],
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ compression_lvl: int = 3,
+):
+ """
+ Convert a RAVG GeoTIFF to FireBench HDF5 Standard for Canopy Cover Loss
+ Use CONUS tif file and define bounding box for data import.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : Path
+ Path to the RAVG Canopy Cover Loss GeoTIFF (ending with *_cc5.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. 'spatial_2d/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """
+ _standardize_ravg_from_geotiff(
+ geotiff_path,
+ h5file,
+ lower_left_corner,
+ upper_right_corner,
+ svn.RAVG_CANOPY_COVER_LOSS.value,
+ group_name,
+ projection,
+ overwrite,
+ invert_y,
+ compression_lvl,
+ fill_value=0,
+ )
+
+
+def standardize_ravg_cbi_from_geotiff(
+ geotiff_path: Path,
+ h5file: h5py.File,
+ lower_left_corner: tuple[float, float],
+ upper_right_corner: tuple[float, float],
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ compression_lvl: int = 3,
+):
+ """
+ Convert a RAVG GeoTIFF to FireBench HDF5 Standard for Composite Burn Index Severity
+ Use CONUS tif file and define bounding box for data import.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : Path
+ Path to the RAVG Composite Burn Index Severity GeoTIFF (ending with *_cbi4.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. 'spatial_2d/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """
+ _standardize_ravg_from_geotiff(
+ geotiff_path,
+ h5file,
+ lower_left_corner,
+ upper_right_corner,
+ svn.RAVG_COMPOSITE_BURN_INDEX_SEVERITY.value,
+ group_name,
+ projection,
+ overwrite,
+ invert_y,
+ compression_lvl,
+ fill_value=0,
+ )
+
+
+def standardize_ravg_ba_from_geotiff(
+ geotiff_path: Path,
+ h5file: h5py.File,
+ lower_left_corner: tuple[float, float],
+ upper_right_corner: tuple[float, float],
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ compression_lvl: int = 3,
+):
+ """
+ Convert a RAVG GeoTIFF to FireBench HDF5 Standard for Live Basal Area loss
+ Use CONUS tif file and define bounding box for data import.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : Path
+ Path to the RAVG Live Basal Area loss GeoTIFF (ending with *_ba7.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. 'spatial_2d/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """
+ _standardize_ravg_from_geotiff(
+ geotiff_path,
+ h5file,
+ lower_left_corner,
+ upper_right_corner,
+ svn.RAVG_LIVE_BASAL_AREA_LOSS.value,
+ group_name,
+ projection,
+ overwrite,
+ invert_y,
+ compression_lvl,
+ fill_value=0,
+ )
+
+
+def _standardize_ravg_from_geotiff(
+ geotiff_path: Path,
+ h5file: h5py.File,
+ lower_left_corner: tuple[float, float],
+ upper_right_corner: tuple[float, float],
+ ravg_variable: str,
+ group_name: str | None = None,
+ projection: str = None,
+ overwrite: bool = False,
+ invert_y: bool = False,
+ compression_lvl: int = 3,
+ fill_value=None,
+):
+ """
+ Convert a RAVG GeoTIFF to FireBench HDF5 Standard.
+ Use CONUS tif file and define bounding box for data import.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : Path
+ Path to the RAVG Composite Burn Index Severity GeoTIFF (ending with *_cc5.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. 'spatial_2d/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """
+ logger.debug("Standardize RAVG %s dataset from file %s ", ravg_variable, geotiff_path)
+ check_std_version(h5file)
+
+ lat, lon, ravg_data, crs, nodata = import_tif_with_rect_box(
+ geotiff_path, lower_left_corner, upper_right_corner, projection, invert_y
+ )
+
+ if group_name is None:
+ group_name = Path(geotiff_path).stem
+
+ group_name = f"/{SPATIAL_2D}/{group_name}"
+ if group_name in h5file.keys():
+ if overwrite:
+ del h5file[group_name]
+ else:
+ logger.warning(
+ "group name %s already exists in file %s. Group not updated. Set `overwrite` to True to update the dataset.",
+ group_name,
+ geotiff_path,
+ )
+ return
+
+ g = h5file.create_group(group_name)
+ g.attrs["data_source"] = f"RAVG {geotiff_path}"
+ g.attrs["crs"] = str(crs)
+
+ dlat = g.create_dataset(
+ "position_lat", data=lat, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ dlat = g.create_dataset(
+ "position_lon", data=lon, dtype=np.float64, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ dlat.attrs["units"] = "degrees"
+
+ ddata = g.create_dataset(
+ ravg_variable, data=ravg_data, dtype=np.uint8, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ ddata.attrs["units"] = "dimensionless"
+ if nodata is not None:
+ ddata.attrs["_FillValue"] = nodata
+ if fill_value is not None:
+ ddata.attrs["_FillValue"] = fill_value
diff --git a/src/firebench/standardize/std_file_info.py b/src/firebench/standardize/std_file_info.py
new file mode 100644
index 0000000..4f98ab4
--- /dev/null
+++ b/src/firebench/standardize/std_file_info.py
@@ -0,0 +1,11 @@
+# centralize high level structure for HDF5 standard file
+POINTS = "points"
+TIME_SERIES = "time_series"
+SPATIAL_1D = "spatial_1d"
+SPATIAL_2D = "spatial_2d"
+SPATIAL_3D = "spatial_3d"
+UNSTRUCTURED = "unstructured"
+GEOPOLYGONS = "polygons"
+FUEL_MODELS = "fuel_models"
+MISCELLANEOUS = "miscellaneous"
+CERTIFICATES = "certificates"
diff --git a/src/firebench/standardize/synoptic.py b/src/firebench/standardize/synoptic.py
new file mode 100644
index 0000000..ed67b86
--- /dev/null
+++ b/src/firebench/standardize/synoptic.py
@@ -0,0 +1,279 @@
+from pathlib import Path
+import numpy as np
+import hdf5plugin
+import h5py
+import json
+import pytz
+from datetime import datetime
+from ..tools import StandardVariableNames as svn
+from ..tools import logger, calculate_sha256
+from .std_file_info import TIME_SERIES
+from .time import datetime_to_iso8601
+from .synoptic_data import (
+ DEFAULT_SENSOR_HEIGHT_UNIT,
+ SH_TRUST_HIGHEST,
+ SH_TRUST_LVL,
+ VARIABLE_CONVERSION,
+ load_sensor_height_stations,
+ load_sensor_height_providers,
+ load_sensor_height_trusted_history,
+)
+
+
+def standardize_synoptic_raws_from_json(
+ json_path: Path,
+ h5file: h5py.File,
+ skip_stations: list[str] = [],
+ overwrite: bool = False,
+ fb_var_info: dict = VARIABLE_CONVERSION,
+ compression_lvl: int = 3,
+ export_trusted_history: bool = False,
+):
+ sha_source_file = calculate_sha256(json_path.resolve())
+ with open(json_path.resolve(), "r") as f:
+ data = json.load(f)
+
+ if TIME_SERIES in h5file["/"]:
+ probes = h5file[f"/{TIME_SERIES}"]
+ else:
+ probes = h5file.create_group(TIME_SERIES)
+
+ fb_sh_hist = load_sensor_height_trusted_history()
+ fb_sh_trusted_stations = load_sensor_height_stations()
+ fb_sh_providers = load_sensor_height_providers()
+
+ # for statistics
+ nb_fully_processed = 0
+ nb_partially_processed = 0
+ nb_skipped = 0
+ nb_var_from_data = 0
+ nb_var_from_stations = 0
+ nb_var_from_hist = 0
+ nb_var_from_provider = 0
+ nb_var_from_default = 0
+ if export_trusted_history:
+ trusted_stations_new = {}
+
+ for station_dict in data["STATION"]:
+ if station_dict["STID"] in skip_stations:
+ nb_skipped += 1
+ logger.info("Skipping station %s", station_dict["STID"])
+ continue
+
+ logger.info("Processing station %s", station_dict["STID"])
+
+ group_name = f"station_{station_dict['STID']}"
+ if group_name in h5file.keys():
+ if overwrite:
+ del h5file[group_name]
+ else:
+ logger.warning(
+ "station group name %s already exists in file %s. Group not updated. Set `overwrite` to True to update the dataset.",
+ group_name,
+ json_path,
+ )
+ return
+
+ new_station = probes.create_group(group_name)
+ new_station.attrs["name"] = station_dict["NAME"]
+ new_station.attrs["ID"] = int(station_dict["ID"])
+ new_station.attrs["mnet_id"] = int(station_dict["MNET_ID"])
+ new_station.attrs["state"] = station_dict["STATE"]
+ new_station.attrs["timezone"] = station_dict["TIMEZONE"]
+ new_station.attrs["position_lat"] = float(station_dict["LATITUDE"])
+ new_station.attrs["position_lon"] = float(station_dict["LONGITUDE"])
+ new_station.attrs["position_alt"] = float(station_dict["ELEVATION"])
+ new_station.attrs["position_lat_units"] = "degree"
+ new_station.attrs["position_lon_units"] = "degree"
+ new_station.attrs["position_alt_units"] = station_dict["UNITS"]["elevation"]
+ new_station.attrs["license"] = "/DATA_LICENSES/Synoptic.txt"
+ new_station.attrs["data_use_restrictions"] = "No commercial use allowed"
+ new_station.attrs["public_access_level"] = "Restricted"
+ new_station.attrs["redistribution_allowed"] = False
+ new_station.attrs["source_file_sha256"] = sha_source_file
+ try:
+ new_station.attrs["elevation_dem"] = float(station_dict["ELEV_DEM"])
+ new_station.attrs["elevation_dem_units"] = station_dict["UNITS"]["elevation"]
+ except:
+ logger.info("elevation_dem not found for station %s.", station_dict["STID"])
+ try:
+ provider = station_dict["PROVIDERS"][0]["name"]
+ except:
+ provider = None
+ logger.warning(
+ "No provider found for station %s. Limited import options.", station_dict["STID"]
+ )
+ new_station.attrs["providers"] = str(provider)
+
+ fully_processed = True
+ for var in station_dict["OBSERVATIONS"]:
+ if var == "date_time":
+ tz = pytz.timezone(station_dict["TIMEZONE"])
+ dts = [
+ tz.localize(datetime.strptime(t, "%Y%m%d%H%M%S"))
+ for t in station_dict["OBSERVATIONS"]["date_time"]
+ ]
+ dt0 = dts[0]
+ first_time_iso = datetime_to_iso8601(dt0, True)
+ rel_minutes = [(dt - dt0).total_seconds() / 60.0 for dt in dts]
+
+ time_ds = new_station.create_dataset(
+ svn.TIME.value, data=rel_minutes, **hdf5plugin.Zstd(clevel=compression_lvl)
+ )
+ time_ds.attrs["time_origin"] = first_time_iso
+ time_ds.attrs["time_units"] = "min"
+ else:
+ if var in fb_var_info:
+ logger.debug("Processing %s", var)
+
+ sensor_height = __get_sensor_height(station_dict["SENSOR_VARIABLES"], var)
+
+ if sensor_height is not None:
+ # Sensor heigth from metadata
+ __add_sh_to_group(
+ new_station,
+ station_dict["OBSERVATIONS"][var],
+ fb_var_info[var],
+ sensor_height,
+ DEFAULT_SENSOR_HEIGHT_UNIT,
+ "from_data",
+ SH_TRUST_HIGHEST,
+ compression_lvl,
+ )
+ nb_var_from_data += 1
+ if export_trusted_history:
+ try:
+ trusted_stations_new[f"{station_dict['STID']}"][var] = sensor_height
+ trusted_stations_new[f"{station_dict['STID']}"]["provider"] = str(provider)
+ except:
+ trusted_stations_new[f"{station_dict['STID']}"] = {}
+ trusted_stations_new[f"{station_dict['STID']}"][var] = sensor_height
+ trusted_stations_new[f"{station_dict['STID']}"]["provider"] = str(provider)
+ else:
+ logger.warning(
+ "Missing sensor height info for variable %s from station %s . Looking for values in FireBench databases.",
+ var,
+ station_dict["STID"],
+ )
+ # Sensor height from default, skip if None
+ fully_processed = False
+
+ # 1. Use Default value for sensor height form FireBench
+ sh_from_fb = fb_var_info[var]["default_sensor_height"]
+ sh_source = "firebench_default"
+ sh_source_trusted = 0
+ sh_info_found = False
+ nb_var_from_default += 1
+
+ # Try find sensor height in stations (highest trust)
+ try:
+ sh_from_fb = fb_sh_trusted_stations[f"{station_dict['STID']}"][var]
+ sh_source = "firebench_trusted_stations"
+ sh_source_trusted = SH_TRUST_HIGHEST
+ sh_info_found = True
+ nb_var_from_stations += 1
+ logger.debug("Sensor height value found in trusted stations database.")
+ except:
+ pass
+
+ if not sh_info_found:
+ # Try find sensor height in history (high trust)
+ try:
+ sh_from_fb = fb_sh_hist[f"{station_dict['STID']}"][var]
+ sh_source = "firebench_trusted_history"
+ sh_source_trusted = SH_TRUST_HIGHEST
+ sh_info_found = True
+ nb_var_from_hist += 1
+ logger.debug(
+ "Sensor height value found in history of trusted information database."
+ )
+ except:
+ pass
+
+ if not sh_info_found:
+ # Try find sensor height in providers (low trust)
+ try:
+ sh_from_fb = fb_sh_providers[provider][var]
+ sh_source = "firebench_providers_default"
+ sh_source_trusted = 1
+ sh_info_found = True
+ nb_var_from_provider += 1
+ logger.debug("Sensor height value found in providers database.")
+ except:
+ pass
+
+ if sh_info_found:
+ nb_var_from_default -= 1
+
+ __add_sh_to_group(
+ new_station,
+ station_dict["OBSERVATIONS"][var],
+ fb_var_info[var],
+ sh_from_fb,
+ DEFAULT_SENSOR_HEIGHT_UNIT,
+ sh_source,
+ sh_source_trusted,
+ compression_lvl,
+ )
+
+ else:
+ logger.warning(
+ "> Variable %s from station %s not processed. Add the variable to `variable_conversion` to process it.",
+ var,
+ station_dict["STID"],
+ )
+
+ if fully_processed:
+ nb_fully_processed += 1
+ else:
+ nb_partially_processed += 1
+
+ logger.info(
+ "Stats stations: %d fully processed, %d partially processed, %d skipped",
+ nb_fully_processed,
+ nb_partially_processed,
+ nb_skipped,
+ )
+ logger.info(
+ "Stats sensor height source: %d from json data, %d from trusted stations db, %d from trusted history db, %d from providers db, %d from FireBench default. %d trusted, %d untrusted.",
+ nb_var_from_data,
+ nb_var_from_stations,
+ nb_var_from_hist,
+ nb_var_from_provider,
+ nb_var_from_default,
+ nb_var_from_data + nb_var_from_stations + nb_var_from_hist,
+ nb_var_from_provider + nb_var_from_default,
+ )
+ if export_trusted_history:
+ with open("tmp_sh_history.json", "w") as f:
+ json.dump(trusted_stations_new, f, sort_keys=True, indent=4)
+
+
+def __get_sensor_height(sensor_variables: dict, variable: str):
+ for sensor_var in sensor_variables.values():
+ if variable in sensor_var:
+ return sensor_var[variable].get("position")
+ return None
+
+
+def __add_sh_to_group(
+ group: h5py.Group,
+ variable,
+ info_dict: dict,
+ sensor_height: float,
+ sensor_height_units: str,
+ sensor_height_source: str,
+ trusted_source: int,
+ compression_lvl: int,
+):
+ var_data = np.array(variable, dtype=info_dict["dtype"])
+ new_var = group.create_dataset(
+ info_dict["std_name"],
+ data=var_data,
+ **hdf5plugin.Zstd(clevel=compression_lvl),
+ )
+ new_var.attrs["units"] = info_dict["units"]
+ new_var.attrs["sensor_height_source_confidence_lvl"] = SH_TRUST_LVL[trusted_source]
+ new_var.attrs["sensor_height"] = sensor_height
+ new_var.attrs["sensor_height_units"] = sensor_height_units
+ new_var.attrs["sensor_height_source"] = sensor_height_source
diff --git a/src/firebench/standardize/synoptic_data.py b/src/firebench/standardize/synoptic_data.py
new file mode 100644
index 0000000..f712382
--- /dev/null
+++ b/src/firebench/standardize/synoptic_data.py
@@ -0,0 +1,76 @@
+from importlib.resources import files
+import numpy as np
+import json
+from ..tools import StandardVariableNames as svn
+
+DEFAULT_SENSOR_HEIGHT_UNIT = "m"
+
+SH_TRUST_HIGHEST = 2
+SH_TRUST_LVL = [
+ "0 - unknown (guessed or missing metadata)",
+ "1 - provider default (not verified)",
+ "2 - verified measurement",
+]
+
+VARIABLE_CONVERSION = {
+ "air_temp_set_1": {
+ "std_name": svn.AIR_TEMPERATURE.value,
+ "units": "degC",
+ "dtype": np.float64,
+ "default_sensor_height": 2,
+ },
+ "relative_humidity_set_1": {
+ "std_name": svn.RELATIVE_HUMIDITY.value,
+ "units": "percent",
+ "dtype": np.float64,
+ "default_sensor_height": 2,
+ },
+ "wind_direction_set_1": {
+ "std_name": svn.WIND_DIRECTION.value,
+ "units": "degree",
+ "dtype": np.float64,
+ "default_sensor_height": 10,
+ },
+ "wind_speed_set_1": {
+ "std_name": svn.WIND_SPEED.value,
+ "units": "m/s",
+ "dtype": np.float64,
+ "default_sensor_height": 10,
+ },
+ "wind_gust_set_1": {
+ "std_name": svn.WIND_GUST.value,
+ "units": "m/s",
+ "dtype": np.float64,
+ "default_sensor_height": 10,
+ },
+ "solar_radiation_set_1": {
+ "std_name": svn.SOLAR_RADIATION.value,
+ "units": "W/m^2",
+ "dtype": np.float64,
+ "default_sensor_height": 2,
+ },
+ "fuel_moisture_set_1": {
+ "std_name": svn.FUEL_MOISTURE_CONTENT_10H.value,
+ "units": "percent",
+ "dtype": np.float64,
+ "default_sensor_height": 0.3,
+ },
+}
+
+
+# station trusted sensor height information. Best source of information
+def load_sensor_height_stations() -> dict:
+ path = files("firebench").joinpath("resources/wx_sensor_height_stations.json")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+# station trusted history. Contains only trusted information from previous json parsing
+def load_sensor_height_trusted_history() -> dict:
+ path = files("firebench").joinpath("resources/wx_sensor_height_trusted_history.json")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+# Provider based information. Not reliable.
+def load_sensor_height_providers() -> dict:
+ path = files("firebench").joinpath("resources/wx_sensor_height_providers.json")
+ return json.loads(path.read_text(encoding="utf-8"))
diff --git a/src/firebench/tools/standard_file_utils.py b/src/firebench/standardize/time.py
similarity index 67%
rename from src/firebench/tools/standard_file_utils.py
rename to src/firebench/standardize/time.py
index 2317020..ddd92f2 100644
--- a/src/firebench/tools/standard_file_utils.py
+++ b/src/firebench/standardize/time.py
@@ -3,9 +3,7 @@
from datetime import datetime, tzinfo
from zoneinfo import ZoneInfo
from typing import Optional, Union
-import h5py
-from .logging_config import logger
-from .units import ureg
+from ..tools.logging_config import logger
_TZLike = Union[str, tzinfo]
@@ -116,50 +114,13 @@ def current_datetime_iso8601(
return datetime_to_iso8601(now_local, include_seconds, tz)
-def read_quantity_from_fb_dataset(dataset_path: str, file_object: h5py.File | h5py.Group | h5py.Dataset):
+def sanitize_iso8601(dt_str: str) -> str:
"""
- Read a dataset from an HDF5 file, group, or dataset node and return it as a Pint Quantity
- according to the FireBench I/O standard.
+ Sanitize an ISO8601 datetime string so it becomes XML/KML-safe.
- This function expects the dataset to comply with the FireBench standard I/O format
- (version >= 0.1), meaning it must define a string `units` attribute specifying the
- physical units of the stored values. The full dataset is loaded into memory and
- wrapped into a `pint.Quantity` using the global `ureg` registry.
-
- Parameters
- ----------
- dataset_path : str
- Path to the target dataset relative to `file_object`. For an `h5py.File`,
- this is the absolute or group-relative path (e.g., "/2D_raster/0001/temperature").
- file_object : h5py.File | h5py.Group | h5py.Dataset
- HDF5 file, group, or dataset object from which the dataset will be read.
- Must support item access via `__getitem__` and store datasets with `.attrs`.
-
- Returns
- -------
- pint.Quantity
- The dataset values loaded into memory, associated with the units taken from
- the dataset's `units` attribute.
-
- Raises
- ------
- KeyError
- If `dataset_path` does not exist in `file_object`.
- ValueError
- If the dataset has no `units` attribute or it is not a non-empty string.
-
- Notes
- -----
- - The function reads the **entire dataset** into memory; for very large datasets,
- consider reading subsets instead.
- - Compliant with FireBench I/O standard >= 0.1.
- """ # pylint: disable=line-too-long
- ds = file_object[dataset_path]
-
- units = ds.attrs.get("units", None)
- if not isinstance(units, str) or not units.strip():
- raise ValueError(
- f"Dataset '{dataset_path}' is missing a valid `units` attribute required by FireBench I/O standard."
- )
-
- return ureg.Quantity(ds[()], ds.attrs["units"])
+ Rules:
+ - Replace "-" and ":" with "_"
+ - Leave all digits and letters unchanged
+ - Keeps "T" and timezone offset structure preserved
+ """
+ return dt_str.replace("-", "_").replace(":", "_")
diff --git a/src/firebench/standardize/tools.py b/src/firebench/standardize/tools.py
new file mode 100644
index 0000000..d34db7d
--- /dev/null
+++ b/src/firebench/standardize/tools.py
@@ -0,0 +1,792 @@
+import h5py
+import hdf5plugin
+import re
+import fnmatch
+import numpy as np
+from pathlib import Path
+import rasterio
+from pyproj import CRS, Transformer
+from rasterio.windows import from_bounds
+from rasterio.warp import transform_bounds
+from ..tools.logging_config import logger
+from ..tools.units import ureg
+from .std_file_info import TIME_SERIES
+
+VERSION_STD = "0.2"
+
+VERSION_STD_COMPATIBILITY = {
+ "0.1": [],
+ "0.2": [],
+}
+
+VALIDATION_SCHEME_1 = ["0.1", "0.2"]
+
+
+ISO8601_REGEX = re.compile(
+ r"^\d{4}-\d{2}-\d{2}T" # Date + 'T'
+ r"\d{2}:\d{2}" # HH:MM (always present)
+ r"(?:\:\d{2}(?:\.\d+)?)?" # optional :SS[.ffffff]
+ r"(?:Z|[+-]\d{2}:\d{2})?$" # optional timezone
+)
+
+IGNORE_ATTRIBUTES = {
+ "/": {"created_on", "created_by", "FireBench_io_version", "description"},
+}
+
+
+def check_std_version(file: h5py.File):
+ """
+ Determine whether the standard version of a file should be updated to the latest.
+
+ This function inspects the `FireBench_io_version` attribute stored in the given HDF5 file.
+ It compares the file's version with the current standard version defined in `VERSION_STD`.
+ The return value indicates whether the caller should update the file's standard version.
+
+ The logic is as follows:
+ - If the attribute is missing, the file is treated as new and should be updated.
+ - If the attribute matches the current standard version, the file is already up to date and may be updated.
+ - If the attribute is in the compatibility list for the current version, the file is considered valid
+ but should not be updated; a warning is logged instead.
+ - If the attribute is not compatible with the current version, a `ValueError` is raised.
+
+ Parameters
+ ----------
+ file : h5py.File
+ An open HDF5 file object to be checked. The file is expected to potentially have a
+ `FireBench_io_version` attribute at the root.
+
+ Returns
+ -------
+ bool
+ True if the file should be updated to the latest standard version.
+ False if the file has a compatible version and should not be updated.
+
+ Raises
+ ------
+ ValueError
+ If the file contains a `FireBench_io_version` attribute that is incompatible with the
+ current standard version.
+ """ # pylint: disable=line-too-long
+ if "FireBench_io_version" not in file.attrs:
+ return True
+
+ file_version = file.attrs["FireBench_io_version"]
+
+ if file_version == VERSION_STD:
+ return True
+
+ if file_version in VERSION_STD_COMPATIBILITY[VERSION_STD]:
+ logger.warning(
+ "FireBench_io_version differs but is compatible: file=%s, current=%s", file_version, VERSION_STD
+ )
+ return False
+
+ raise ValueError(f"Standard version {file_version} not compatible with {VERSION_STD}")
+
+
+def is_iso8601(s: str) -> bool:
+ return bool(ISO8601_REGEX.match(s))
+
+
+def validate_h5_std(file: h5py.File):
+ """
+ Validate that the mandatory structure in the h5 file is compliant with the standard
+ """
+ if "FireBench_io_version" not in file.attrs:
+ raise ValueError(f"Attribute `FireBench_io_version` not found.")
+
+ if file.attrs["FireBench_io_version"] in VALIDATION_SCHEME_1:
+ # check creation date
+ if "created_on" not in file.attrs:
+ raise ValueError(f"Attribute `created_on` not found.")
+
+ if not is_iso8601(file.attrs["created_on"]):
+ raise ValueError(f"Attribute `created_on` not compliant with ISO8601.")
+
+ # check authors
+ if "created_by" not in file.attrs:
+ raise ValueError(f"Attribute `created_by` not found.")
+
+
+def validate_h5_requirement(file: h5py.File, required: dict[str, list[str]]):
+ """
+ Check if all datasets and associated attributs are present in the file.
+ Return False and the name of the first missing item if either the dataset or an attribute is missing
+ """
+ for dset_path, attrs in required.items():
+ if dset_path not in file:
+ return False, f"dataset `{dset_path}`"
+
+ dset = file[dset_path]
+
+ for attr_name in attrs:
+ if attr_name not in dset.attrs:
+ return False, f"attr `{attr_name}` of dataset `{dset_path}`"
+
+ # check that KML files exist
+ if "rel_path" in attrs:
+ if not Path(dset.attrs["rel_path"]).exists():
+ return False, f"file `{dset.attrs['rel_path']}` not found from `{dset_path}`"
+
+ return True, None
+
+
+def validate_h5_weather_stations_structure(
+ file_checked: h5py.File,
+ file_ref: h5py.File,
+ variable_checked: str,
+ station_grp_name_starts_with: str = "station",
+):
+ """
+ Check if all datasets and associated attributs are present in the file.
+ Return False and the name of the first missing item if either the dataset or an attribute is missing
+ """
+ ref_paths = []
+ for station in file_ref[f"{TIME_SERIES}"].keys():
+ if not station.startswith(station_grp_name_starts_with):
+ continue
+ # check if station contains variable
+ station_path = f"{TIME_SERIES}/{station}"
+ if variable_checked in map(str, file_ref[station_path].keys()):
+ ref_paths.append(f"{TIME_SERIES}/{station}/time")
+ ref_paths.append(f"{TIME_SERIES}/{station}/{variable_checked}")
+
+ for path in ref_paths:
+ if path not in file_checked:
+ return False, path
+
+ return True, None
+
+
+def read_quantity_from_fb_dataset(dataset_path: str, file_object: h5py.File | h5py.Group | h5py.Dataset):
+ """
+ Read a dataset from an HDF5 file, group, or dataset node and return it as a Pint Quantity
+ according to the FireBench I/O standard.
+
+ This function expects the dataset to comply with the FireBench standard I/O format
+ (version >= 0.1), meaning it must define a string `units` attribute specifying the
+ physical units of the stored values. The full dataset is loaded into memory and
+ wrapped into a `pint.Quantity` using the global `ureg` registry.
+
+ Parameters
+ ----------
+ dataset_path : str
+ Path to the target dataset relative to `file_object`. For an `h5py.File`,
+ this is the absolute or group-relative path (e.g., "/2D_raster/0001/temperature").
+ file_object : h5py.File | h5py.Group | h5py.Dataset
+ HDF5 file, group, or dataset object from which the dataset will be read.
+ Must support item access via `__getitem__` and store datasets with `.attrs`.
+
+ Returns
+ -------
+ pint.Quantity
+ The dataset values loaded into memory, associated with the units taken from
+ the dataset's `units` attribute.
+
+ Raises
+ ------
+ KeyError
+ If `dataset_path` does not exist in `file_object`.
+ ValueError
+ If the dataset has no `units` attribute or it is not a non-empty string.
+
+ Notes
+ -----
+ - The function reads the **entire dataset** into memory; for very large datasets,
+ consider reading subsets instead.
+ - Compliant with FireBench I/O standard >= 0.1.
+ """ # pylint: disable=line-too-long
+ ds = file_object[dataset_path]
+
+ units = ds.attrs.get("units", None)
+ if not isinstance(units, str) or not units.strip():
+ raise ValueError(
+ f"Dataset '{dataset_path}' is missing a valid `units` attribute required by FireBench I/O standard."
+ )
+
+ return ureg.Quantity(ds[()], ds.attrs["units"])
+
+
+def read_quantity_from_fb_attribute(
+ dataset_path: str,
+ attribute_name: str,
+ file_object: h5py.File | h5py.Group | h5py.Dataset,
+ dtype=float,
+):
+ ds = file_object[dataset_path]
+
+ value = read_numeric_attribute(ds, attribute_name, dataset_path=dataset_path, dtype=dtype)
+ units = read_string_attribute(
+ ds, f"{attribute_name}_units", dataset_path=dataset_path, strip=True, allow_empty=False
+ )
+
+ return ureg.Quantity(value, units)
+
+
+def read_numeric_attribute(
+ ds: h5py.Dataset | h5py.Group,
+ attribute_name: str,
+ dataset_path: str,
+ dtype=float,
+):
+ """
+ Read an attribute expected to represent a single numeric value.
+ Accepts: numbers, numpy scalars, strings, bytes. Rejects: missing, empty string.
+ """
+ raw = _decode_h5_scalar(_get_h5_attr(ds, attribute_name))
+
+ if raw is None:
+ raise ValueError(f"Dataset/Group '{dataset_path}' is missing the attribute '{attribute_name}'.")
+
+ if isinstance(raw, str) and raw.strip() == "":
+ raise ValueError(f"Dataset/Group '{dataset_path}' has an empty attribute '{attribute_name}'.")
+
+ try:
+ return dtype(raw)
+ except Exception as e:
+ raise ValueError(f"Cannot convert attribute '{attribute_name}'={raw!r} to {dtype.__name__}.") from e
+
+
+def read_string_attribute(
+ ds: h5py.Dataset | h5py.Group,
+ attribute_name: str,
+ dataset_path: str,
+ strip: bool = True,
+ allow_empty: bool = False,
+):
+ """
+ Read an attribute expected to be a string.
+ Accepts: str, bytes, numpy string scalars. Rejects: missing, non-str, empty (unless allow_empty).
+ """
+ raw = _decode_h5_scalar(_get_h5_attr(ds, attribute_name))
+
+ if raw is None:
+ raise ValueError(f"Dataset/Group '{dataset_path}' is missing the attribute '{attribute_name}'.")
+
+ if not isinstance(raw, str):
+ raise ValueError(
+ f"Dataset/Group '{dataset_path}' attribute '{attribute_name}' must be a string, got {type(raw).__name__}."
+ )
+
+ s = raw.strip() if strip else raw
+ if (not allow_empty) and s == "":
+ raise ValueError(f"Dataset/Group '{dataset_path}' has an empty attribute '{attribute_name}'.")
+
+ return s
+
+
+def merge_authors(authors_1: str, authors_2: str):
+ list_authors_1 = [a.strip() for a in authors_1.split(";") if a.strip()]
+ list_authors_2 = [a.strip() for a in authors_2.split(";") if a.strip()]
+ n1, n2 = len(list_authors_1), len(list_authors_2)
+ merged_authors: list[str] = []
+ seen = set()
+
+ max_len = max(n1, n2)
+ for i in range(max_len):
+ if i < n1:
+ a1 = list_authors_1[i]
+ if a1 and a1 not in seen:
+ merged_authors.append(a1)
+ seen.add(a1)
+ if i < n2:
+ a2 = list_authors_2[i]
+ if a2 and a2 not in seen:
+ merged_authors.append(a2)
+ seen.add(a2)
+
+ if not merged_authors:
+ return ""
+
+ return ";".join(merged_authors) + ";"
+
+
+def collect_conflicts(file1, file2, path: str = "/", conflicts: list | None = None):
+ """
+ Recursively collect conflicts between two HDF5 trees.
+
+ A *conflict* is defined as one of:
+ - Same path exists in both files but with different node types
+ (group vs dataset).
+ - Same dataset path exists in both files but has different
+ shape or dtype.
+ - Same attribute key exists in both objects but has different value.
+
+ Parameters
+ ----------
+ file1, file2 :
+ Open h5py.File or h5py.Group objects that share the same logical layout.
+ path : str, optional
+ Current absolute HDF5 path to compare (default is root "/").
+ conflicts : list, optional
+ List that will be extended with conflict dicts. If None, a new
+ list is created and returned.
+
+ Returns
+ -------
+ list
+ The list of collected conflict dicts. Each conflict has keys:
+ - "path" : str, the HDF5 path where the conflict occurs
+ - "kind" : str, one of {"node_type", "dataset_mismatch", "attr_mismatch"}
+ - "detail" : str, human-readable description
+ """
+ if conflicts is None:
+ conflicts = []
+
+ obj1 = file1[path]
+ obj2 = file2[path]
+
+ # Helper: compare attributes of two objects at same path
+ def _compare_attrs(o1, o2, obj_path: str):
+ ignore_set = IGNORE_ATTRIBUTES.get(obj_path, set())
+
+ keys1 = set(o1.attrs.keys()) - ignore_set
+ keys2 = set(o2.attrs.keys()) - ignore_set
+ common = keys1 & keys2
+
+ for key in common:
+ v1 = o1.attrs[key]
+ v2 = o2.attrs[key]
+
+ # Use np.array_equal to handle scalars, strings, arrays, etc.
+ if not np.array_equal(v1, v2):
+ conflicts.append(
+ {
+ "path": obj_path,
+ "kind": "attr_mismatch",
+ "detail": f"Attribute '{key}' differs: {v1!r} vs {v2!r}",
+ }
+ )
+
+ is_group1 = isinstance(obj1, h5py.Group)
+ is_group2 = isinstance(obj2, h5py.Group)
+ is_dset1 = isinstance(obj1, h5py.Dataset)
+ is_dset2 = isinstance(obj2, h5py.Dataset)
+
+ # 1) Different node types at the same path: group vs dataset
+ if (is_group1 and is_dset2) or (is_dset1 and is_group2):
+ conflicts.append(
+ {
+ "path": path,
+ "kind": "node_type",
+ "detail": f"Different node types at {path}: "
+ f"{type(obj1).__name__} vs {type(obj2).__name__}",
+ }
+ )
+ return conflicts
+
+ # 2) Both datasets: check shape/dtype + attributes
+ if is_dset1 and is_dset2:
+ if obj1.shape != obj2.shape or obj1.dtype != obj2.dtype:
+ conflicts.append(
+ {
+ "path": path,
+ "kind": "dataset_mismatch",
+ "detail": (
+ f"Dataset mismatch at {path}: "
+ f"shape/dtype {obj1.shape}, {obj1.dtype} vs "
+ f"{obj2.shape}, {obj2.dtype}"
+ ),
+ }
+ )
+
+ _compare_attrs(obj1, obj2, path)
+ return conflicts
+
+ # 3) Both groups: compare attributes + recurse on common children
+ if is_group1 and is_group2:
+ _compare_attrs(obj1, obj2, path)
+
+ keys1 = set(obj1.keys())
+ keys2 = set(obj2.keys())
+ common_keys = keys1 & keys2
+
+ # Only common children can conflict. Extra children are fine.
+ for name in common_keys:
+ if path == "/":
+ child_path = f"/{name}"
+ else:
+ child_path = f"{path.rstrip('/')}/{name}"
+
+ collect_conflicts(file1, file2, path=child_path, conflicts=conflicts)
+
+ return conflicts
+
+ # Should not really reach here, but in case of exotic types:
+ conflicts.append(
+ {
+ "path": path,
+ "kind": "node_type",
+ "detail": f"Unsupported node type combination at {path}: "
+ f"{type(obj1).__name__} vs {type(obj2).__name__}",
+ }
+ )
+ return conflicts
+
+
+def merge_trees(
+ file1: h5py.File,
+ file2: h5py.File,
+ merged_file: h5py.File,
+ compression_lvl: int = 3,
+) -> None:
+ """
+ Recursively fill `merged_file` with content from `file1` and `file2`.
+
+ Order:
+ ------
+ 1. Copy the full tree from file1 into merged_file.
+ 2. Merge the full tree from file2 into merged_file:
+ - If a path does not exist in merged_file, copy it.
+ - If a dataset already exists:
+ * If a conflict solver is provided for this path, use it to
+ combine existing and incoming data.
+ * Otherwise, keep the existing data (we assume they are compatible
+ because conflicts were checked earlier).
+ - Group attributes:
+ * Add attributes that don't exist yet in merged_file.
+ * Attributes listed in IGNORE_ATTRIBUTES are skipped.
+ """
+
+ # 1) copy everything from file1 (no conflicts, merged_file is new/empty)
+ _merge_from_source(
+ src=file1,
+ dst=merged_file,
+ path="/",
+ compression_lvl=compression_lvl,
+ )
+
+ # 2) merge from file2, now conflict_solver may apply
+ _merge_from_source(
+ src=file2,
+ dst=merged_file,
+ path="/",
+ compression_lvl=compression_lvl,
+ )
+
+
+def import_tif_with_rect_box(
+ geotiff_path: Path,
+ lower_left_corner: tuple[float, float],
+ upper_right_corner: tuple[float, float],
+ projection: str = None,
+ invert_y: bool = False,
+):
+ """
+ Import a subset of a GeoTIFF using a geographic rectangular bounding box.
+
+ The function loads only the portion of the input GeoTIFF that intersects a
+ user-defined rectangle in geographic coordinates (lat/lon). The bounding box
+ is reprojected into the source CRS, a raster window is extracted, and pixel-center
+ coordinates are computed. The result is returned in the target CRS (default: CRS of
+ the source dataset unless a different projection is specified).
+
+ Parameters
+ ----------
+ geotiff_path : Path
+ Path to the GeoTIFF file.
+ lower_left_corner : tuple[float, float]
+ Geographic coordinates of the lower-left corner of the selection box,
+ formatted as (latitude, longitude).
+ upper_right_corner : tuple[float, float]
+ Geographic coordinates of the upper-right corner of the selection box,
+ formatted as (latitude, longitude).
+ projection : str, optional
+ Target CRS for returned latitude/longitude arrays. If None, use the CRS
+ of the source GeoTIFF. Can be any pyproj-compatible CRS string (e.g. "EPSG:4326").
+ invert_y : bool, optional
+ If True, flip the data and latitude/longitude arrays along the vertical
+ axis (useful for image-style orientation).
+
+ Returns
+ -------
+ lat : np.ndarray
+ 2-D array of latitude coordinates for each pixel center in the selected region,
+ expressed in the target CRS.
+ lon : np.ndarray
+ 2-D array of longitude coordinates for each pixel center in the selected region,
+ expressed in the target CRS.
+ data_arr : np.ndarray
+ 2-D array containing the raster data extracted within the bounding box.
+
+ Notes
+ -----
+ - Only the requested rectangular region is read from disk, preventing the allocation
+ of extremely large full-domain coordinate grids.
+ - The function logs a summary of the loaded subset including its shape, CRS, and window.
+ - The selection box is provided in geographic coordinates and internally reprojected
+ to the raster's native projection.
+ """ # pylint: disable=line-too-long
+ with rasterio.open(geotiff_path.resolve()) as src:
+ if projection is None:
+ projection = src.crs
+ tgt_crs = CRS(projection)
+
+ lat_min, lon_min = lower_left_corner
+ lat_max, lon_max = upper_right_corner
+
+ lon_min, lon_max = sorted((lon_min, lon_max))
+ lat_min, lat_max = sorted((lat_min, lat_max))
+
+ bbox_src = transform_bounds(
+ tgt_crs,
+ src.crs,
+ lon_min,
+ lat_min,
+ lon_max,
+ lat_max,
+ densify_pts=21,
+ )
+ x_min_src, y_min_src, x_max_src, y_max_src = bbox_src
+
+ window = from_bounds(x_min_src, y_min_src, x_max_src, y_max_src, src.transform)
+
+ data = src.read(1, window=window)
+ transform_window = src.window_transform(window)
+
+ data_dict = {
+ "data": data,
+ "transform": transform_window,
+ "crs": src.crs,
+ "nodata": src.nodata,
+ }
+
+ logger.info(
+ "Loaded subset from %s: shape=%s, CRS=%s, window=%s",
+ geotiff_path,
+ data.shape,
+ src.crs,
+ window,
+ )
+
+ rows, cols = data_dict["data"].shape
+
+ jj = np.arange(cols)
+ ii = np.arange(rows)
+
+ # center coordinates in source CRS (projected)
+ T = data_dict["transform"]
+ x = T.a * jj[None, :] + T.b * ii[:, None] + T.c
+ y = T.d * jj[None, :] + T.e * ii[:, None] + T.f
+
+ tgt_crs = CRS(projection)
+ transformer = Transformer.from_crs(data_dict["crs"], tgt_crs, always_xy=True)
+ lon, lat = transformer.transform(x, y)
+
+ if invert_y:
+ lat = lat[::-1, :]
+ lon = lon[::-1, :]
+ data_dict["data"] = data_dict["data"][::-1, :]
+
+ return np.array(lat), np.array(lon), np.array(data_dict["data"]), tgt_crs, data_dict["nodata"]
+
+
+def import_tif(
+ geotiff_path: str,
+ projection: str = None,
+ invert_y: bool = False,
+):
+ """
+ Convert a MTBS GeoTIFF to Firebench HDF5 standard file.
+
+ Use source data projection as default. Can be reprojected by specifying the CRS in projection.
+
+ Parameters
+ ----------
+ geotiff_path : str
+ Path to the MTBS GeoTIFF (ending with *_dnbr6.tif).
+ h5file : h5py.File
+ target HDF5 file
+ group_name : str | None
+ HDF5 group path. If None, auto-derive from filename, e.g. '2D_raster/'.
+ overwrite: bool
+ Overwrite the group in the HDF5 file. Default: False
+ invert_y: bool
+ Invert y axis in data
+
+ Returns
+ -------
+ h5py.File
+ The actual HDF5 group written (with suffix if collision).
+ """ # pylint: disable=line-too-long
+ with rasterio.open(geotiff_path) as src:
+ data = src.read(1)
+ data_dict = {"data": data, "transform": src.transform, "crs": src.crs, "nodata": src.nodata}
+ logger.info(f"Loaded {geotiff_path}: shape={data.shape}, CRS={src.crs}")
+
+ rows, cols = data.shape
+ jj = np.arange(cols)
+ ii = np.arange(rows)
+ T = data_dict["transform"]
+ x = T.a * jj[None, :] + T.b * ii[:, None] + T.c
+ y = T.d * jj[None, :] + T.e * ii[:, None] + T.f
+
+ if projection is None:
+ projection = data_dict["crs"]
+ tgt_crs = CRS(projection)
+
+ # always_xy=True -> transformer expects/returns (x, y) = (lon, lat) ordering for geographic CRS
+ transformer = Transformer.from_crs(data_dict["crs"], tgt_crs, always_xy=True)
+ lon, lat = transformer.transform(x, y)
+
+ if invert_y:
+ lat = lat[::-1, :]
+ lon = lon[::-1, :]
+ data_dict["data"] = data_dict["data"][::-1, :]
+
+ return np.array(lat), np.array(lon), np.array(data_dict["data"]), tgt_crs, data_dict["nodata"]
+
+
+def copy_entire_object_zstd(
+ src_parent: h5py.Group,
+ dst_parent: h5py.Group,
+ name: str,
+ compression_lvl: int,
+) -> None:
+ """
+ Recursively copy `name` from src_parent to dst_parent.
+
+ Policy:
+ - Groups:
+ * If missing in dst -> create
+ * If already exists -> copy only new attrs via _copy_attributes
+ * Recurse into children
+ - Datasets:
+ * If missing -> create with Zstd(clevel=compression_lvl), load ONE dataset at a time
+ * If exists -> raise
+ - Links/other: skipped
+ """
+ src_obj = src_parent.get(name, getlink=False)
+ if src_obj is None:
+ return
+
+ if isinstance(src_obj, h5py.Dataset):
+ if name in dst_parent:
+ # Keep strict behavior by default
+ raise ValueError(
+ f"Conflict detected: dataset already exists at {dst_parent.name.rstrip('/')}/{name}"
+ )
+
+ dset = dst_parent.create_dataset(
+ name,
+ data=src_obj[...],
+ dtype=src_obj.dtype,
+ **hdf5plugin.Zstd(clevel=compression_lvl),
+ )
+ _copy_attributes(src_obj, dset, f"{dst_parent.name.rstrip('/')}/{name}")
+ return
+
+ if isinstance(src_obj, h5py.Group):
+ if name in dst_parent:
+ dst_obj = dst_parent[name]
+ if not isinstance(dst_obj, h5py.Group):
+ raise ValueError(
+ f"Conflict detected: expected group at {dst_parent.name.rstrip('/')}/{name}"
+ )
+ grp = dst_obj
+ else:
+ grp = dst_parent.create_group(name)
+
+ _copy_attributes(src_obj, grp, f"{dst_parent.name.rstrip('/')}/{name}")
+
+ # recurse
+ for child_name in src_obj.keys():
+ copy_entire_object_zstd(
+ src_parent=src_obj,
+ dst_parent=grp,
+ name=child_name,
+ compression_lvl=compression_lvl,
+ )
+ return
+
+ # SoftLink/ExternalLink/etc: skip
+ return
+
+
+def _merge_from_source(
+ src: h5py.Group,
+ dst: h5py.Group,
+ path: str,
+ compression_lvl: int,
+) -> None:
+ """
+ Recursive helper.
+
+ - Merges attributes at `path` via _copy_attributes (only new attrs).
+ - For children:
+ * if missing in dst: copy entire subtree with Zstd
+ * if exists:
+ - if both are groups: recurse (at any depth)
+ - otherwise: strict conflict (raise)
+ """
+ src_obj = src[path]
+ dst_obj = dst[path] # must exist for current node
+
+ # Copy/merge attributes for this object (group or dataset)
+ _copy_attributes(src_obj, dst_obj, path)
+
+ if isinstance(src_obj, h5py.Dataset):
+ return
+
+ if not isinstance(src_obj, h5py.Group):
+ return
+
+ for name in src_obj.keys():
+ child_path = f"/{name}" if path == "/" else f"{path.rstrip('/')}/{name}"
+
+ if name not in dst_obj:
+ copy_entire_object_zstd(
+ src_parent=src_obj,
+ dst_parent=dst_obj,
+ name=name,
+ compression_lvl=compression_lvl,
+ )
+ else:
+ src_child = src_obj[name]
+ dst_child = dst_obj[name]
+
+ if isinstance(src_child, h5py.Group) and isinstance(dst_child, h5py.Group):
+ _merge_from_source(
+ src=src,
+ dst=dst,
+ path=child_path,
+ compression_lvl=compression_lvl,
+ )
+ else:
+ logger.error("_merge_from_source: conflict at path %s for %s", path, name)
+ raise ValueError("Conflict detected. Merge stopped")
+
+
+def _copy_attributes(
+ src_obj: h5py.Dataset | h5py.Group, dst_obj: h5py.Dataset | h5py.Group, path: str
+) -> None:
+ """
+ Copy attributes from src_obj to dst_obj, skipping attributes that:
+ - are in IGNORE_ATTRIBUTES for this path, or
+ - already exist in dst_obj.
+ """
+ ignore_set = IGNORE_ATTRIBUTES.get(path, set())
+
+ for key, value in src_obj.attrs.items():
+ if key in ignore_set:
+ continue
+ if key in dst_obj.attrs:
+ # Keep existing attr value
+ continue
+ dst_obj.attrs[key] = value
+
+
+def _get_h5_attr(ds: h5py.Dataset | h5py.Group, name: str):
+ """Return raw HDF5 attribute value (or None if missing)."""
+ return ds.attrs.get(name, None)
+
+
+def _decode_h5_scalar(x):
+ """Decode bytes / numpy scalars to plain Python scalars; leave others untouched."""
+ if x is None:
+ return None
+ if isinstance(x, (bytes, np.bytes_)):
+ return x.decode()
+ if isinstance(x, np.generic): # e.g., np.float64, np.int64, np.str_
+ return x.item()
+ return x
diff --git a/src/firebench/tools/__init__.py b/src/firebench/tools/__init__.py
index 6dd3096..6dc2e5c 100644
--- a/src/firebench/tools/__init__.py
+++ b/src/firebench/tools/__init__.py
@@ -29,6 +29,7 @@
logger,
logging,
set_logging_level,
+ create_file_handler,
)
from .input_info import ParameterType
from .fuel_models_utils import (
@@ -47,8 +48,3 @@
calculate_sha256,
)
from .raster_to_perimeters import array_to_geopolygons
-from .standard_file_utils import (
- current_datetime_iso8601,
- datetime_to_iso8601,
- read_quantity_from_fb_dataset,
-)
diff --git a/src/firebench/tools/logging_config.py b/src/firebench/tools/logging_config.py
index f716966..39d01f9 100644
--- a/src/firebench/tools/logging_config.py
+++ b/src/firebench/tools/logging_config.py
@@ -3,11 +3,10 @@
# Create a custom logger
logger = logging.getLogger("firebench")
+logger.setLevel(logging.DEBUG)
# Create a stream handler by default
c_handler = logging.StreamHandler()
-
-# Set logging level for the stream handler
c_handler.setLevel(logging.WARNING)
# Create formatter and add it to the stream handler
@@ -17,9 +16,6 @@
# Add the stream handler to the logger
logger.addHandler(c_handler)
-# Set default logging level for the logger
-logger.setLevel(logging.WARNING)
-
# Prevent propagation to the root logger
logger.propagate = True
diff --git a/src/firebench/tools/namespace.py b/src/firebench/tools/namespace.py
index 32803f6..924dc86 100644
--- a/src/firebench/tools/namespace.py
+++ b/src/firebench/tools/namespace.py
@@ -20,10 +20,12 @@ class StandardVariableNames(Enum): # pragma: no cover
BUILDING_LENGTH_SEPARATION = "building_length_separation"
BUILDING_LENGTH_SIDE = "building_length_side"
CANOPY_DENSITY_BULK = "canopy_density_bulk"
+ CANOPY_HEIGHT = "canopy_height"
CANOPY_HEIGHT_BOTTOM = "canopy_height_bottom"
CANOPY_HEIGHT_TOP = "canopy_height_top"
DIRECTION = "direction"
FIRE_ARRIVAL_TIME = "fire_arrival_time"
+ FIRE_BURN_SEVERITY = "fire_burn_severity"
FUEL_CLASS = "fuel_class"
FUEL_CHAPARRAL_FLAG = "fuel_chaparral_flag"
FUEL_COVER = "fuel_cover"
@@ -79,11 +81,16 @@ class StandardVariableNames(Enum): # pragma: no cover
NORMAL_SPREAD_DIR_X = "normal_spread_dir_x"
NORMAL_SPREAD_DIR_Y = "normal_spread_dir_y"
RATE_OF_SPREAD = "rate_of_spread"
+ RAVG_CANOPY_COVER_LOSS = "ravg_canopy_cover_loss"
+ RAVG_COMPOSITE_BURN_INDEX_SEVERITY = "ravg_composite_burn_index_severity"
+ RAVG_LIVE_BASAL_AREA_LOSS = "ravg_live_basal_area_loss"
RELATIVE_HUMIDITY = "relative_humidity"
+ SOLAR_RADIATION = "solar_radiation"
SLOPE_ANGLE = "slope_angle"
TEMPERATURE = "temperature"
TIME = "time"
WIND_DIRECTION = "wind_direction"
+ WIND_GUST = "wind_gust"
WIND_SPEED = "wind_speed"
WIND_SPEED_U = "wind_speed_u"
WIND_SPEED_V = "wind_speed_v"
diff --git a/src/firebench/tools/utils.py b/src/firebench/tools/utils.py
index 2e13823..86f33ee 100644
--- a/src/firebench/tools/utils.py
+++ b/src/firebench/tools/utils.py
@@ -4,6 +4,8 @@
from pint import Quantity
from .logging_config import logger
+FIGSIZE_DEFAULT = (6, 6)
+
def is_scalar_quantity(x: any):
"""
diff --git a/tests/unit/test_standard_io_file_tools.py b/tests/unit/test_standard_io_file_tools.py
index f36b19a..0341b8f 100644
--- a/tests/unit/test_standard_io_file_tools.py
+++ b/tests/unit/test_standard_io_file_tools.py
@@ -7,14 +7,9 @@
import numpy as np
# Replace 'your_module' with the actual module name that defines _resolve_tz
-from firebench.tools.standard_file_utils import _resolve_tz
-from firebench.tools import (
- datetime_to_iso8601,
- current_datetime_iso8601,
- read_quantity_from_fb_dataset,
- get_firebench_data_directory,
-)
-
+from firebench.standardize.time import _resolve_tz, datetime_to_iso8601, current_datetime_iso8601
+from firebench.standardize.tools import read_quantity_from_fb_dataset
+from firebench.tools import get_firebench_data_directory
# _resolve_tz
# -----------
@@ -125,7 +120,7 @@ def now(cls):
def test_current_datetime_iso8601_local_with_seconds(monkeypatch):
- import firebench.tools.standard_file_utils as std_utils
+ import firebench.standardize.time as std_utils
monkeypatch.setattr(std_utils, "datetime", FakeDateTime)
@@ -134,7 +129,7 @@ def test_current_datetime_iso8601_local_with_seconds(monkeypatch):
def test_current_datetime_iso8601_local_without_seconds(monkeypatch):
- import firebench.tools.standard_file_utils as std_utils
+ import firebench.standardize.time as std_utils
monkeypatch.setattr(std_utils, "datetime", FakeDateTime)
@@ -143,7 +138,7 @@ def test_current_datetime_iso8601_local_without_seconds(monkeypatch):
def test_current_datetime_iso8601_with_utc(monkeypatch):
- import firebench.tools.standard_file_utils as std_utils
+ import firebench.standardize.time as std_utils
monkeypatch.setattr(std_utils, "datetime", FakeDateTime)
@@ -153,7 +148,7 @@ def test_current_datetime_iso8601_with_utc(monkeypatch):
def test_current_datetime_iso8601_with_zoneinfo_without_seconds(monkeypatch):
- import firebench.tools.standard_file_utils as std_utils
+ import firebench.standardize.time as std_utils
monkeypatch.setattr(std_utils, "datetime", FakeDateTime)
diff --git a/tests/unit/test_std_tools.py b/tests/unit/test_std_tools.py
new file mode 100644
index 0000000..eb659c8
--- /dev/null
+++ b/tests/unit/test_std_tools.py
@@ -0,0 +1,115 @@
+import pytest
+from firebench.standardize import merge_authors
+
+
+@pytest.mark.parametrize(
+ "created_by_1, created_by_2, expected",
+ [
+ # 1. Simple case: same length, no overlaps
+ # file1: alice, bob
+ # file2: carol, dan
+ # order: a1, a2, b1, b2
+ (
+ "alice;bob;",
+ "carol;dan;",
+ "alice;carol;bob;dan;",
+ ),
+ # 2. Different length, no overlaps (file1 longer)
+ # file1: alice, bob, charlie
+ # file2: dan, erin
+ # positions:
+ # i=0: alice, dan
+ # i=1: bob, erin
+ # i=2: charlie (only file1)
+ (
+ "alice;bob;charlie;",
+ "dan;erin;",
+ "alice;dan;bob;erin;charlie;",
+ ),
+ # 3. Different length, no overlaps (file2 longer)
+ # file1: alice, bob
+ # file2: carol, dan, erin
+ # positions:
+ # i=0: alice, carol
+ # i=1: bob, dan
+ # i=2: erin (only file2)
+ (
+ "alice;bob;",
+ "carol;dan;erin;",
+ "alice;carol;bob;dan;erin;",
+ ),
+ # 4. Overlap across lists
+ # file1: alice, bob
+ # file2: bob, carol
+ # positions:
+ # i=0: alice, bob -> alice, bob
+ # i=1: bob (already seen), carol -> carol
+ # merged: alice, bob, carol
+ (
+ "alice;bob;",
+ "bob;carol;",
+ "alice;bob;carol;",
+ ),
+ # 5. Duplicate within the same list + overlap
+ # file1: alice, alice, bob
+ # file2: carol, alice
+ # positions:
+ # i=0: alice, carol -> alice, carol
+ # i=1: alice (seen), alice (seen) -> no new author
+ # i=2: bob -> bob
+ # merged: alice, carol, bob
+ (
+ "alice;alice;bob;",
+ "carol;alice;",
+ "alice;carol;bob;",
+ ),
+ # 6. One side empty (no authors in file1)
+ # file1: ""
+ # file2: alice, bob
+ (
+ "",
+ "alice;bob;",
+ "alice;bob;",
+ ),
+ # 7. One side empty (no authors in file2)
+ # file1: alice, bob
+ # file2: ""
+ (
+ "alice;bob;",
+ "",
+ "alice;bob;",
+ ),
+ # 8. Both empty
+ (
+ "",
+ "",
+ "",
+ ),
+ # 9. Trailing semicolons with possible stray spaces
+ # Expect that your function strips whitespace around names.
+ # file1: " alice ", "bob"
+ # file2: "bob ", " carol"
+ # merged: alice, bob, carol (no duplicates, trimmed)
+ (
+ " alice ;bob ;",
+ "bob ; carol ;",
+ "alice;bob;carol;",
+ ),
+ # 10. Multiple overlaps and reordering
+ # file1: alice, bob, charlie, dave
+ # file2: bob, erin, charlie, frank
+ # positions:
+ # i=0: alice, bob -> alice, bob
+ # i=1: bob(seen), erin -> erin
+ # i=2: charlie, charlie -> charlie
+ # i=3: dave, frank -> dave, frank
+ # merged: alice, bob, erin, charlie, dave, frank
+ (
+ "alice;bob;charlie;dave;",
+ "bob;erin;charlie;frank;",
+ "alice;bob;erin;charlie;dave;frank;",
+ ),
+ ],
+)
+def test_merge_authors(created_by_1, created_by_2, expected):
+ assert merge_authors(created_by_1, created_by_2) == expected