From ec7a8a43159593d23e3c6cf1575824f262e76f27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:13:58 +0000 Subject: [PATCH 1/6] feat(taxonomy): enforce library conformance at parse time (#277) `LibrarySchema.taxonomy` was parsed but never validated: the taxonomy layer (`check_library_against_taxonomy`, `load_taxonomy`) existed but was only reachable from tests. `parse_yaml_library` now takes an optional `taxonomy` and checks every library declaring a `taxonomy` field against it. The caller resolves the taxonomy, so parsing stays independent of where taxonomy files live; `load_study` reads it from the new optional `input/taxonomy.yml`. `parse_yaml_hybrid_library` and `gems_runner.main.input_libs` thread the argument through. Breaking changes: - a library declaring `taxonomy` raises `ValueError` when parsed without a taxonomy to check against, or against one whose id differs from the declared one. Libraries with no `taxonomy` field are unaffected, even when their models carry a `taxonomy-category`. - `TaxonomyData` is renamed `TaxonomySchema`, matching the naming of the other YAML-backed pydantic models. No alias is kept. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016T83KCgRW8Ps3X3rA2PCtU --- AGENTS.md | 2 +- docs/CHANGELOG.md | 18 +++++ docs/getting-started.md | 2 +- docs/user-guide/inputs.md | 34 +++++++++ src/gems_craft/model/parsing.py | 47 +++++++++++- src/gems_craft/model/taxonomy.py | 4 +- src/gems_craft/study/folder.py | 11 ++- src/gems_craft_hybrid/model/parsing.py | 7 +- src/gems_runner/main/main.py | 7 +- .../e2e/functional/test_study_from_folder.py | 45 +++++++++++ .../lib_parsing/test_taxonomy_check.py | 75 ++++++++++++++++++- .../gems_craft_hybrid/test_hybrid_parsing.py | 46 ++++++++++++ 12 files changed, 282 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0beef928..669677fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ The codebase is split into three packages along a solver-dependency boundary: **`gems_craft/model/`** — Immutable model templates. - `Model`: defines component behavior (parameters, variables, constraints, ports) - `Library`: a collection of models, loaded from YAML -- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`; `check_library_against_taxonomy` enforces conformance. +- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`; `check_library_against_taxonomy` enforces conformance. It is called from `parse_yaml_library` for every library declaring a `taxonomy` field — the caller supplies the `Taxonomy` (`load_study` reads it from the optional `input/taxonomy.yml`), and parsing fails if none is supplied or its id differs from the declared one. **`gems_craft/expression/`** — Mathematical expression language and AST (structural/static analysis only — no numeric evaluation). - `ExpressionNode`: base frozen dataclass for all expression tree nodes diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80925bf7..3c63fa96 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,24 @@ All notable changes to GemsPy are documented here. what a heuristic reads/writes via `models[].heuristics` in `optim-config.yml`. +- **Taxonomy conformance is now enforced at parse time** - `parse_yaml_library` + accepts an optional `taxonomy` argument and runs + `check_library_against_taxonomy` on every library declaring a `taxonomy` + field, which until now was parsed but never validated. `load_study` reads the + taxonomy from the new optional `input/taxonomy.yml`; `parse_yaml_hybrid_library` + and `gems_runner.main.input_libs` thread the argument through. + +### Changed + +- **Breaking: a library declaring `taxonomy` no longer parses unchecked** - + `parse_yaml_library` raises `ValueError` when such a library is parsed without + a taxonomy to check it against, and when the supplied taxonomy's id differs + from the declared one. Libraries with no `taxonomy` field are unaffected, even + if their models carry a `taxonomy-category`. +- **Breaking: `TaxonomyData` renamed to `TaxonomySchema`** - aligns with the + `LibrarySchema` / `ModelSchema` / `PortTypeSchema` naming used for the other + YAML-backed pydantic models. No alias is kept. + ## [0.1.3] - 2026-07-24 ### Added diff --git a/docs/getting-started.md b/docs/getting-started.md index 7a5dd277..8bdca90a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ The first category of input files mentioned above comprises libraries of models. library: id: basic description: Basic library - taxonomy: basic_taxonomy_of_models # optional: id of the taxonomy this library conforms to + taxonomy: basic_taxonomy_of_models # optional: id of the taxonomy this library is checked against version: 1.0.0 # optional: free-form library version port-types: diff --git a/docs/user-guide/inputs.md b/docs/user-guide/inputs.md index f5101705..6b9c54ec 100644 --- a/docs/user-guide/inputs.md +++ b/docs/user-guide/inputs.md @@ -14,6 +14,7 @@ my_study/ ├── input/ │ ├── system.yml │ ├── optim-config.yml +│ ├── taxonomy.yml ← optional │ ├── model-libraries/ │ │ └── *.yml │ └── data-series/ @@ -34,6 +35,39 @@ from `input/data-series/modeler-scenariobuilder.dat` (if present). --- +## Taxonomy checking + +A library may declare the taxonomy it conforms to: + +~~~ yaml +library: + id: basic + taxonomy: basic_taxonomy_of_models +~~~ + +When it does, every model carrying a `taxonomy-category` is checked against the +matching category of that taxonomy: the model must expose all the variables, +parameters, ports, port-field-definitions, constraints, binding-constraints, +extra-outputs and properties the category requires (extra items are allowed). + +`load_study()` reads the taxonomy from `input/taxonomy.yml`. A library that +declares a `taxonomy` fails to load when that file is missing, or when its id +does not match the declared one. Libraries with no `taxonomy` field are never +checked, even if their models carry a `taxonomy-category`. + +Outside the directory layout, pass the taxonomy explicitly: + +~~~ python +from gems_craft.model.parsing import parse_yaml_library +from gems_craft.model.taxonomy import load_taxonomy + +taxonomy = load_taxonomy(Path("taxonomy.yml")) +with open("simple_library.yml") as lib_file: + library = parse_yaml_library(lib_file, taxonomy=taxonomy) +~~~ + +--- + ## File-by-file loading (programmatic) Use the lower-level functions when you want to load files individually or build diff --git a/src/gems_craft/model/parsing.py b/src/gems_craft/model/parsing.py index 22772641..a251a331 100644 --- a/src/gems_craft/model/parsing.py +++ b/src/gems_craft/model/parsing.py @@ -19,6 +19,11 @@ from gems_craft.utils import ModifiedBaseModel +if typing.TYPE_CHECKING: + # Imported for typing only: gems_craft.model.taxonomy depends on this module + # for LibrarySchema/ModelSchema, so importing it here would be circular. + from gems_craft.model.taxonomy import Taxonomy + class ParameterSchema(ModifiedBaseModel): id: str @@ -114,18 +119,52 @@ class LibrarySchema(ModifiedBaseModel): _L = TypeVar("_L", bound=LibrarySchema) +def _check_declared_taxonomy( + library: LibrarySchema, taxonomy: Optional["Taxonomy"] +) -> None: + """Check a library against the taxonomy it declares conformance to. + + Only called for libraries carrying a ``taxonomy`` field. The taxonomy itself is + resolved by the caller (see ``load_taxonomy``), so that parsing stays independent + of where taxonomy files live. + """ + # Deferred import: gems_craft.model.taxonomy imports this module. + from gems_craft.model.taxonomy import check_library_against_taxonomy + + if taxonomy is None: + raise ValueError( + f"Library '{library.id}' declares taxonomy '{library.taxonomy}' but no " + f"taxonomy was provided to check it against." + ) + if library.taxonomy != taxonomy.id: + raise ValueError( + f"Library '{library.id}' declares taxonomy '{library.taxonomy}' but was " + f"checked against taxonomy '{taxonomy.id}'." + ) + check_library_against_taxonomy(library, taxonomy) + + @overload -def parse_yaml_library(input: typing.TextIO) -> LibrarySchema: ... +def parse_yaml_library( + input: typing.TextIO, *, taxonomy: Optional["Taxonomy"] = None +) -> LibrarySchema: ... @overload -def parse_yaml_library(input: typing.TextIO, schema: Type[_L]) -> _L: ... def parse_yaml_library( - input: typing.TextIO, schema: Type[LibrarySchema] = LibrarySchema + input: typing.TextIO, schema: Type[_L], taxonomy: Optional["Taxonomy"] = None +) -> _L: ... +def parse_yaml_library( + input: typing.TextIO, + schema: Type[LibrarySchema] = LibrarySchema, + taxonomy: Optional["Taxonomy"] = None, ) -> LibrarySchema: tree = safe_load(input) try: - return schema.model_validate(tree["library"]) + library = schema.model_validate(tree["library"]) except ValidationError as e: raise ValueError(f"An error occurred during parsing: {e}") + if library.taxonomy is not None: + _check_declared_taxonomy(library, taxonomy) + return library def write_yaml_library(library: LibrarySchema, path: Path) -> None: diff --git a/src/gems_craft/model/taxonomy.py b/src/gems_craft/model/taxonomy.py index eba774b3..9734cdba 100644 --- a/src/gems_craft/model/taxonomy.py +++ b/src/gems_craft/model/taxonomy.py @@ -38,7 +38,7 @@ class TaxonomyCategory(ModifiedBaseModel): properties: List[TaxonomyItem] = Field(default_factory=list) -class TaxonomyData(ModifiedBaseModel): +class TaxonomySchema(ModifiedBaseModel): id: str description: str = "" categories: List[TaxonomyCategory] = Field(default_factory=list) @@ -56,7 +56,7 @@ def load_taxonomy(taxonomy_file: Path) -> Taxonomy: raw = yaml.safe_load(f) if "taxonomy" not in raw: raise ValueError(f"Missing 'taxonomy' key at root of {taxonomy_file}") - data = TaxonomyData.model_validate(raw["taxonomy"]) + data = TaxonomySchema.model_validate(raw["taxonomy"]) return Taxonomy( id=data.id, description=data.description, categories=data.categories ) diff --git a/src/gems_craft/study/folder.py b/src/gems_craft/study/folder.py index e006b8c9..79020138 100644 --- a/src/gems_craft/study/folder.py +++ b/src/gems_craft/study/folder.py @@ -5,6 +5,8 @@ - `input/system.yml`: A file describing the system to be simulated. - `input/model-libraries/`: A folder containing model library files in YAML format. - `input/data-series/`: A folder containing data series files. +- `input/taxonomy.yml` (optional): A taxonomy that libraries declaring a `taxonomy` + field are checked against. """ from pathlib import Path @@ -12,6 +14,7 @@ from gems_craft.model.model import Model from gems_craft.model.parsing import parse_yaml_library from gems_craft.model.resolve_library import resolve_library +from gems_craft.model.taxonomy import load_taxonomy from gems_craft.study.parsing import parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, @@ -28,7 +31,8 @@ def load_study(study_dir: Path) -> Study: This function reads the system definition, model libraries, and data series from the study directory, resolves them, and builds the simulation system - and database. + and database. If `input/taxonomy.yml` exists, every library declaring a + `taxonomy` is checked against it. Args: study_dir: The path to the study directory. @@ -39,11 +43,14 @@ def load_study(study_dir: Path) -> Study: system_file = study_dir / "input" / "system.yml" lib_folder = study_dir / "input" / "model-libraries" series_dir = study_dir / "input" / "data-series" + taxonomy_file = study_dir / "input" / "taxonomy.yml" + + taxonomy = load_taxonomy(taxonomy_file) if taxonomy_file.exists() else None input_libraries = [] for lib_file in lib_folder.glob("*.yml"): with lib_file.open() as lib: - input_libraries.append(parse_yaml_library(lib)) + input_libraries.append(parse_yaml_library(lib, taxonomy=taxonomy)) with system_file.open() as c: input_study = parse_yaml_system(c) diff --git a/src/gems_craft_hybrid/model/parsing.py b/src/gems_craft_hybrid/model/parsing.py index ddccd881..ee1c2eac 100644 --- a/src/gems_craft_hybrid/model/parsing.py +++ b/src/gems_craft_hybrid/model/parsing.py @@ -15,6 +15,7 @@ from pydantic import Field from gems_craft.model.parsing import LibrarySchema, PortTypeSchema, parse_yaml_library +from gems_craft.model.taxonomy import Taxonomy from gems_craft.utils import ModifiedBaseModel @@ -37,5 +38,7 @@ class HybridLibrarySchema(LibrarySchema): port_types: List[HybridPortTypeSchema] = Field(default_factory=list) # type: ignore[assignment] -def parse_yaml_hybrid_library(input: TextIO) -> HybridLibrarySchema: - return parse_yaml_library(input, HybridLibrarySchema) +def parse_yaml_hybrid_library( + input: TextIO, taxonomy: Optional[Taxonomy] = None +) -> HybridLibrarySchema: + return parse_yaml_library(input, HybridLibrarySchema, taxonomy) diff --git a/src/gems_runner/main/main.py b/src/gems_runner/main/main.py index a0d33721..93c6be3e 100644 --- a/src/gems_runner/main/main.py +++ b/src/gems_runner/main/main.py @@ -16,6 +16,7 @@ from gems_craft.model.library import Library from gems_craft.model.parsing import parse_yaml_library from gems_craft.model.resolve_library import resolve_library +from gems_craft.model.taxonomy import Taxonomy from gems_craft.optim_config.parsing import OptimConfig from gems_craft.study import Study from gems_craft.study.data import DataBase @@ -26,12 +27,14 @@ from gems_runner.study.runner import run_study -def input_libs(yaml_lib_paths: List[Path]) -> Dict[str, Library]: +def input_libs( + yaml_lib_paths: List[Path], taxonomy: Optional[Taxonomy] = None +) -> Dict[str, Library]: yaml_libraries = [] yaml_library_ids = set() for path in yaml_lib_paths: with path.open("r") as file: - yaml_lib = parse_yaml_library(file) + yaml_lib = parse_yaml_library(file, taxonomy=taxonomy) if yaml_lib.id in yaml_library_ids: raise ValueError(f"The identifier '{yaml_lib.id}' is defined twice") yaml_libraries.append(yaml_lib) diff --git a/tests/e2e/functional/test_study_from_folder.py b/tests/e2e/functional/test_study_from_folder.py index f28cc2f8..714eab4d 100644 --- a/tests/e2e/functional/test_study_from_folder.py +++ b/tests/e2e/functional/test_study_from_folder.py @@ -2,10 +2,14 @@ from pathlib import Path import pandas as pd +import pytest from gems_craft.study.folder import load_study from gems_runner.study.runner import run_study +LIB_FILE = Path("input") / "model-libraries" / "antares_historic.yml" +TAXONOMY_FILE = Path("input") / "taxonomy.yml" + def test_load_study(): study_dir = Path(__file__).parent / "studies" / "7_4" @@ -27,3 +31,44 @@ def test_run_study(tmp_path: Path) -> None: assert len(output_files) == 1 df = pd.read_csv(output_files[0]) assert "objective-value" in df["output"].values + + +def _study_declaring_taxonomy(tmp_path: Path) -> Path: + """Copy the 7_4 study, making one of its libraries declare a taxonomy.""" + study_dir = tmp_path / "7_4" + shutil.copytree(Path(__file__).parent / "studies" / "7_4", study_dir) + + lib_path = study_dir / LIB_FILE + lib_path.write_text( + lib_path.read_text().replace( + " id: antares-historic-weo", + " id: antares-historic-weo\n taxonomy: study_taxonomy", + 1, + ) + ) + return study_dir + + +def test_load_study_checks_libraries_against_study_taxonomy(tmp_path: Path) -> None: + study_dir = _study_declaring_taxonomy(tmp_path) + (study_dir / TAXONOMY_FILE).write_text( + "taxonomy:\n id: study_taxonomy\n categories:\n - id: production\n" + ) + + study = load_study(study_dir) # must not raise + assert len(study.system.components) == 12 + + +def test_load_study_raises_when_taxonomy_file_is_missing(tmp_path: Path) -> None: + study_dir = _study_declaring_taxonomy(tmp_path) + + with pytest.raises(ValueError, match="no taxonomy was provided"): + load_study(study_dir) + + +def test_load_study_raises_on_taxonomy_id_mismatch(tmp_path: Path) -> None: + study_dir = _study_declaring_taxonomy(tmp_path) + (study_dir / TAXONOMY_FILE).write_text("taxonomy:\n id: other_taxonomy\n") + + with pytest.raises(ValueError, match="other_taxonomy"): + load_study(study_dir) diff --git a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py index b573476f..88582555 100644 --- a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py +++ b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py @@ -12,6 +12,7 @@ import io from pathlib import Path +from typing import Optional import pytest @@ -33,8 +34,8 @@ def _make_category(cat_id: str, port_ids: list[str]) -> TaxonomyCategory: return TaxonomyCategory(id=cat_id, ports=[TaxonomyItem(id=p) for p in port_ids]) -def _parse_lib(yaml_content: str): - return parse_yaml_library(io.StringIO(yaml_content)) +def _parse_lib(yaml_content: str, taxonomy: Optional[Taxonomy] = None): + return parse_yaml_library(io.StringIO(yaml_content), taxonomy=taxonomy) # --- valid cases --- @@ -418,3 +419,73 @@ def test_model_exposing_all_required_fields_is_valid() -> None: - id: technology """) check_library_against_taxonomy(lib, taxonomy) # must not raise + + +# --- parse_yaml_library wiring --- + +_CONFORMING_LIB = """ +library: + id: mylib + taxonomy: test_taxonomy + port-types: + - id: flow + fields: + - id: flow + models: + - id: generator + taxonomy-category: production + ports: + - id: injection_port + type: flow +""" + +_VIOLATING_LIB = """ +library: + id: mylib + taxonomy: test_taxonomy + models: + - id: generator + taxonomy-category: production +""" + + +def test_parse_library_declaring_taxonomy_is_checked() -> None: + taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) + lib = _parse_lib(_CONFORMING_LIB, taxonomy) + assert lib.taxonomy == "test_taxonomy" + + +def test_parse_library_declaring_taxonomy_raises_on_violation() -> None: + taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) + with pytest.raises(ValueError, match="injection_port"): + _parse_lib(_VIOLATING_LIB, taxonomy) + + +def test_parse_library_declaring_taxonomy_without_argument_raises() -> None: + with pytest.raises(ValueError, match="no taxonomy was provided"): + _parse_lib(_CONFORMING_LIB) + + +def test_parse_library_with_mismatched_taxonomy_id_raises() -> None: + taxonomy = Taxonomy( + id="other_taxonomy", + categories=[_make_category("production", ["injection_port"])], + ) + with pytest.raises(ValueError, match="other_taxonomy"): + _parse_lib(_CONFORMING_LIB, taxonomy) + + +def test_parse_library_without_declared_taxonomy_is_not_checked() -> None: + """A model may carry a taxonomy-category without the library opting in.""" + taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) + lib = _parse_lib( + """ +library: + id: mylib + models: + - id: generator + taxonomy-category: production +""", + taxonomy, + ) + assert lib.taxonomy is None diff --git a/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py b/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py index 25be122f..892bc0b2 100644 --- a/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py +++ b/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py @@ -12,6 +12,7 @@ """Tests for gems_craft_hybrid parsing: HybridSystemSchema and HybridLibrarySchema.""" +import io from pathlib import Path from typing import Type, TypeVar @@ -22,6 +23,7 @@ parse_yaml_library, write_yaml_library, ) +from gems_craft.model.taxonomy import Taxonomy, TaxonomyCategory, TaxonomyItem from gems_craft.study.parsing import ( SystemSchema, parse_yaml_system, @@ -201,3 +203,47 @@ def test_load_hybrid_system_parses_thermal_capacity_connections() -> None: assert conn.thermal_component == ThermalComponentSchema( area="fr", cluster_id="nuclear1" ) + + +# --------------------------------------------------------------------------- +# Taxonomy check is threaded through the hybrid parser +# --------------------------------------------------------------------------- + + +_TAXONOMY_LIB = """ +library: + id: mylib + taxonomy: hybrid_taxonomy + port-types: + - id: flow + fields: + - id: flow + models: + - id: generator + taxonomy-category: production + ports: + - id: injection_port + type: flow +""" + + +def test_parse_hybrid_library_checks_declared_taxonomy() -> None: + taxonomy = Taxonomy( + id="hybrid_taxonomy", + categories=[ + TaxonomyCategory(id="production", ports=[TaxonomyItem(id="injection_port")]) + ], + ) + lib = parse_yaml_hybrid_library(io.StringIO(_TAXONOMY_LIB), taxonomy) + assert lib.taxonomy == "hybrid_taxonomy" + + +def test_parse_hybrid_library_raises_on_taxonomy_violation() -> None: + taxonomy = Taxonomy( + id="hybrid_taxonomy", + categories=[ + TaxonomyCategory(id="production", ports=[TaxonomyItem(id="missing_port")]) + ], + ) + with pytest.raises(ValueError, match="missing_port"): + parse_yaml_hybrid_library(io.StringIO(_TAXONOMY_LIB), taxonomy) From 1d1b13ce13142afa68df2a27bea4104ff7c91148 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:18:19 +0000 Subject: [PATCH 2/6] refactor(taxonomy): tighten wording and remove duplication in taxonomy checks No behavior change: trim a redundant docstring line, drop an f-prefix on a non-interpolating string, pass `taxonomy` by keyword in the hybrid parser, and factor the repeated taxonomy fixtures out of the tests. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016T83KCgRW8Ps3X3rA2PCtU --- src/gems_craft/model/parsing.py | 7 +++--- src/gems_craft_hybrid/model/parsing.py | 2 +- .../e2e/functional/test_study_from_folder.py | 3 +-- .../lib_parsing/test_taxonomy_check.py | 20 ++++++++-------- .../gems_craft_hybrid/test_hybrid_parsing.py | 23 ++++++++++--------- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/gems_craft/model/parsing.py b/src/gems_craft/model/parsing.py index a251a331..78ed30d6 100644 --- a/src/gems_craft/model/parsing.py +++ b/src/gems_craft/model/parsing.py @@ -124,9 +124,8 @@ def _check_declared_taxonomy( ) -> None: """Check a library against the taxonomy it declares conformance to. - Only called for libraries carrying a ``taxonomy`` field. The taxonomy itself is - resolved by the caller (see ``load_taxonomy``), so that parsing stays independent - of where taxonomy files live. + The taxonomy is resolved by the caller (see ``load_taxonomy``), so that parsing + stays independent of where taxonomy files live. """ # Deferred import: gems_craft.model.taxonomy imports this module. from gems_craft.model.taxonomy import check_library_against_taxonomy @@ -134,7 +133,7 @@ def _check_declared_taxonomy( if taxonomy is None: raise ValueError( f"Library '{library.id}' declares taxonomy '{library.taxonomy}' but no " - f"taxonomy was provided to check it against." + "taxonomy was provided to check it against." ) if library.taxonomy != taxonomy.id: raise ValueError( diff --git a/src/gems_craft_hybrid/model/parsing.py b/src/gems_craft_hybrid/model/parsing.py index ee1c2eac..c775d5a2 100644 --- a/src/gems_craft_hybrid/model/parsing.py +++ b/src/gems_craft_hybrid/model/parsing.py @@ -41,4 +41,4 @@ class HybridLibrarySchema(LibrarySchema): def parse_yaml_hybrid_library( input: TextIO, taxonomy: Optional[Taxonomy] = None ) -> HybridLibrarySchema: - return parse_yaml_library(input, HybridLibrarySchema, taxonomy) + return parse_yaml_library(input, HybridLibrarySchema, taxonomy=taxonomy) diff --git a/tests/e2e/functional/test_study_from_folder.py b/tests/e2e/functional/test_study_from_folder.py index 714eab4d..d742ad14 100644 --- a/tests/e2e/functional/test_study_from_folder.py +++ b/tests/e2e/functional/test_study_from_folder.py @@ -7,7 +7,6 @@ from gems_craft.study.folder import load_study from gems_runner.study.runner import run_study -LIB_FILE = Path("input") / "model-libraries" / "antares_historic.yml" TAXONOMY_FILE = Path("input") / "taxonomy.yml" @@ -38,7 +37,7 @@ def _study_declaring_taxonomy(tmp_path: Path) -> Path: study_dir = tmp_path / "7_4" shutil.copytree(Path(__file__).parent / "studies" / "7_4", study_dir) - lib_path = study_dir / LIB_FILE + lib_path = study_dir / "input" / "model-libraries" / "antares_historic.yml" lib_path.write_text( lib_path.read_text().replace( " id: antares-historic-weo", diff --git a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py index 88582555..c792f736 100644 --- a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py +++ b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py @@ -448,6 +448,15 @@ def test_model_exposing_all_required_fields_is_valid() -> None: taxonomy-category: production """ +# Same violating model, but the library itself does not opt in to a taxonomy. +_LIB_WITHOUT_TAXONOMY = """ +library: + id: mylib + models: + - id: generator + taxonomy-category: production +""" + def test_parse_library_declaring_taxonomy_is_checked() -> None: taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) @@ -478,14 +487,5 @@ def test_parse_library_with_mismatched_taxonomy_id_raises() -> None: def test_parse_library_without_declared_taxonomy_is_not_checked() -> None: """A model may carry a taxonomy-category without the library opting in.""" taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) - lib = _parse_lib( - """ -library: - id: mylib - models: - - id: generator - taxonomy-category: production -""", - taxonomy, - ) + lib = _parse_lib(_LIB_WITHOUT_TAXONOMY, taxonomy) assert lib.taxonomy is None diff --git a/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py b/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py index 892bc0b2..9c33b1e4 100644 --- a/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py +++ b/tests/unittests/gems_craft_hybrid/test_hybrid_parsing.py @@ -227,23 +227,24 @@ def test_load_hybrid_system_parses_thermal_capacity_connections() -> None: """ -def test_parse_hybrid_library_checks_declared_taxonomy() -> None: - taxonomy = Taxonomy( +def _taxonomy_requiring(port_id: str) -> Taxonomy: + return Taxonomy( id="hybrid_taxonomy", categories=[ - TaxonomyCategory(id="production", ports=[TaxonomyItem(id="injection_port")]) + TaxonomyCategory(id="production", ports=[TaxonomyItem(id=port_id)]) ], ) - lib = parse_yaml_hybrid_library(io.StringIO(_TAXONOMY_LIB), taxonomy) + + +def test_parse_hybrid_library_checks_declared_taxonomy() -> None: + lib = parse_yaml_hybrid_library( + io.StringIO(_TAXONOMY_LIB), _taxonomy_requiring("injection_port") + ) assert lib.taxonomy == "hybrid_taxonomy" def test_parse_hybrid_library_raises_on_taxonomy_violation() -> None: - taxonomy = Taxonomy( - id="hybrid_taxonomy", - categories=[ - TaxonomyCategory(id="production", ports=[TaxonomyItem(id="missing_port")]) - ], - ) with pytest.raises(ValueError, match="missing_port"): - parse_yaml_hybrid_library(io.StringIO(_TAXONOMY_LIB), taxonomy) + parse_yaml_hybrid_library( + io.StringIO(_TAXONOMY_LIB), _taxonomy_requiring("missing_port") + ) From 1cf0e0f998a43debf51845de50c3169cf266c297 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:23:41 +0000 Subject: [PATCH 3/6] refactor(taxonomy): break the parsing/taxonomy cycle at the type level `parse_yaml_library` reached for `check_library_against_taxonomy` through an import inside the function body, because taxonomy.py imported the schemas from parsing.py. Those schemas are only ever annotations there, so the import moves under TYPE_CHECKING and parsing.py can import the checker normally: the runtime dependency now points one way, parsing -> taxonomy. Also folds the shared prefix out of the two error messages. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016T83KCgRW8Ps3X3rA2PCtU --- src/gems_craft/model/parsing.py | 32 +++++++++----------------------- src/gems_craft/model/taxonomy.py | 11 ++++++++--- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/gems_craft/model/parsing.py b/src/gems_craft/model/parsing.py index 78ed30d6..c4846891 100644 --- a/src/gems_craft/model/parsing.py +++ b/src/gems_craft/model/parsing.py @@ -17,13 +17,9 @@ from pydantic import ConfigDict, Field, ValidationError from yaml import safe_dump, safe_load +from gems_craft.model.taxonomy import Taxonomy, check_library_against_taxonomy from gems_craft.utils import ModifiedBaseModel -if typing.TYPE_CHECKING: - # Imported for typing only: gems_craft.model.taxonomy depends on this module - # for LibrarySchema/ModelSchema, so importing it here would be circular. - from gems_craft.model.taxonomy import Taxonomy - class ParameterSchema(ModifiedBaseModel): id: str @@ -120,41 +116,31 @@ class LibrarySchema(ModifiedBaseModel): def _check_declared_taxonomy( - library: LibrarySchema, taxonomy: Optional["Taxonomy"] + library: LibrarySchema, taxonomy: Optional[Taxonomy] ) -> None: - """Check a library against the taxonomy it declares conformance to. - - The taxonomy is resolved by the caller (see ``load_taxonomy``), so that parsing - stays independent of where taxonomy files live. - """ - # Deferred import: gems_craft.model.taxonomy imports this module. - from gems_craft.model.taxonomy import check_library_against_taxonomy - + """Check a library against the taxonomy it declares conformance to.""" + declared = f"Library '{library.id}' declares taxonomy '{library.taxonomy}'" if taxonomy is None: raise ValueError( - f"Library '{library.id}' declares taxonomy '{library.taxonomy}' but no " - "taxonomy was provided to check it against." + f"{declared} but no taxonomy was provided to check it against." ) if library.taxonomy != taxonomy.id: - raise ValueError( - f"Library '{library.id}' declares taxonomy '{library.taxonomy}' but was " - f"checked against taxonomy '{taxonomy.id}'." - ) + raise ValueError(f"{declared} but was checked against '{taxonomy.id}'.") check_library_against_taxonomy(library, taxonomy) @overload def parse_yaml_library( - input: typing.TextIO, *, taxonomy: Optional["Taxonomy"] = None + input: typing.TextIO, *, taxonomy: Optional[Taxonomy] = None ) -> LibrarySchema: ... @overload def parse_yaml_library( - input: typing.TextIO, schema: Type[_L], taxonomy: Optional["Taxonomy"] = None + input: typing.TextIO, schema: Type[_L], taxonomy: Optional[Taxonomy] = None ) -> _L: ... def parse_yaml_library( input: typing.TextIO, schema: Type[LibrarySchema] = LibrarySchema, - taxonomy: Optional["Taxonomy"] = None, + taxonomy: Optional[Taxonomy] = None, ) -> LibrarySchema: tree = safe_load(input) try: diff --git a/src/gems_craft/model/taxonomy.py b/src/gems_craft/model/taxonomy.py index 9734cdba..6eba7e18 100644 --- a/src/gems_craft/model/taxonomy.py +++ b/src/gems_craft/model/taxonomy.py @@ -12,14 +12,17 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional import yaml from pydantic import Field -from gems_craft.model.parsing import LibrarySchema, ModelSchema from gems_craft.utils import ModifiedBaseModel +if TYPE_CHECKING: + # Annotations only — parsing.py imports this module, so this would be circular. + from gems_craft.model.parsing import LibrarySchema, ModelSchema + class TaxonomyItem(ModifiedBaseModel): id: str @@ -76,7 +79,9 @@ def _missing( ) -def check_library_against_taxonomy(library: LibrarySchema, taxonomy: Taxonomy) -> None: +def check_library_against_taxonomy( + library: "LibrarySchema", taxonomy: Taxonomy +) -> None: """ Validates that every model declaring a taxonomy_category: 1. References a category that exists in the taxonomy. From d00c1d82f4a20c3d624c9f0f45ede71a96d47061 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:32:23 +0000 Subject: [PATCH 4/6] docs: condense taxonomy changelog and revert user-guide changes Trim the changelog entries to the essentials and drop the taxonomy section added to the user guide. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016T83KCgRW8Ps3X3rA2PCtU --- AGENTS.md | 2 +- docs/CHANGELOG.md | 20 ++++++-------------- docs/user-guide/inputs.md | 34 ---------------------------------- 3 files changed, 7 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 669677fc..500dda3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ The codebase is split into three packages along a solver-dependency boundary: **`gems_craft/model/`** — Immutable model templates. - `Model`: defines component behavior (parameters, variables, constraints, ports) - `Library`: a collection of models, loaded from YAML -- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`; `check_library_against_taxonomy` enforces conformance. It is called from `parse_yaml_library` for every library declaring a `taxonomy` field — the caller supplies the `Taxonomy` (`load_study` reads it from the optional `input/taxonomy.yml`), and parsing fails if none is supplied or its id differs from the declared one. +- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`; `check_library_against_taxonomy` enforces conformance, called from `parse_yaml_library` for libraries declaring a `taxonomy`. The caller supplies the `Taxonomy`; `load_study` reads it from the optional `input/taxonomy.yml`. **`gems_craft/expression/`** — Mathematical expression language and AST (structural/static analysis only — no numeric evaluation). - `ExpressionNode`: base frozen dataclass for all expression tree nodes diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c63fa96..77b6294b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,23 +14,15 @@ All notable changes to GemsPy are documented here. what a heuristic reads/writes via `models[].heuristics` in `optim-config.yml`. -- **Taxonomy conformance is now enforced at parse time** - `parse_yaml_library` - accepts an optional `taxonomy` argument and runs - `check_library_against_taxonomy` on every library declaring a `taxonomy` - field, which until now was parsed but never validated. `load_study` reads the - taxonomy from the new optional `input/taxonomy.yml`; `parse_yaml_hybrid_library` - and `gems_runner.main.input_libs` thread the argument through. +- **Taxonomy conformance checked at parse time** - `parse_yaml_library` takes an + optional `taxonomy` and checks every library declaring a `taxonomy` field. + `load_study` reads it from the optional `input/taxonomy.yml`. ### Changed -- **Breaking: a library declaring `taxonomy` no longer parses unchecked** - - `parse_yaml_library` raises `ValueError` when such a library is parsed without - a taxonomy to check it against, and when the supplied taxonomy's id differs - from the declared one. Libraries with no `taxonomy` field are unaffected, even - if their models carry a `taxonomy-category`. -- **Breaking: `TaxonomyData` renamed to `TaxonomySchema`** - aligns with the - `LibrarySchema` / `ModelSchema` / `PortTypeSchema` naming used for the other - YAML-backed pydantic models. No alias is kept. +- **Breaking** - parsing a library that declares a `taxonomy` raises `ValueError` + if no taxonomy is supplied, or if its id differs from the declared one. +- **Breaking** - `TaxonomyData` renamed to `TaxonomySchema`; no alias kept. ## [0.1.3] - 2026-07-24 diff --git a/docs/user-guide/inputs.md b/docs/user-guide/inputs.md index 6b9c54ec..f5101705 100644 --- a/docs/user-guide/inputs.md +++ b/docs/user-guide/inputs.md @@ -14,7 +14,6 @@ my_study/ ├── input/ │ ├── system.yml │ ├── optim-config.yml -│ ├── taxonomy.yml ← optional │ ├── model-libraries/ │ │ └── *.yml │ └── data-series/ @@ -35,39 +34,6 @@ from `input/data-series/modeler-scenariobuilder.dat` (if present). --- -## Taxonomy checking - -A library may declare the taxonomy it conforms to: - -~~~ yaml -library: - id: basic - taxonomy: basic_taxonomy_of_models -~~~ - -When it does, every model carrying a `taxonomy-category` is checked against the -matching category of that taxonomy: the model must expose all the variables, -parameters, ports, port-field-definitions, constraints, binding-constraints, -extra-outputs and properties the category requires (extra items are allowed). - -`load_study()` reads the taxonomy from `input/taxonomy.yml`. A library that -declares a `taxonomy` fails to load when that file is missing, or when its id -does not match the declared one. Libraries with no `taxonomy` field are never -checked, even if their models carry a `taxonomy-category`. - -Outside the directory layout, pass the taxonomy explicitly: - -~~~ python -from gems_craft.model.parsing import parse_yaml_library -from gems_craft.model.taxonomy import load_taxonomy - -taxonomy = load_taxonomy(Path("taxonomy.yml")) -with open("simple_library.yml") as lib_file: - library = parse_yaml_library(lib_file, taxonomy=taxonomy) -~~~ - ---- - ## File-by-file loading (programmatic) Use the lower-level functions when you want to load files individually or build From 6a22687147e7cf3f2ad2366a707d0f5b8790bf42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:36:47 +0000 Subject: [PATCH 5/6] docs: list the optional taxonomy.yml in the study layout diagram Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016T83KCgRW8Ps3X3rA2PCtU --- docs/user-guide/inputs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user-guide/inputs.md b/docs/user-guide/inputs.md index f5101705..7d9bfdd4 100644 --- a/docs/user-guide/inputs.md +++ b/docs/user-guide/inputs.md @@ -14,6 +14,7 @@ my_study/ ├── input/ │ ├── system.yml │ ├── optim-config.yml +│ ├── taxonomy.yml ← optional │ ├── model-libraries/ │ │ └── *.yml │ └── data-series/ From 5c2f58d0e8760709a1333cb5e856b89e8d0cebd6 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:34:35 +0200 Subject: [PATCH 6/6] Fixing tbittar's comment in PR 278 (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(taxonomy): split library validation out of parsing Addresses tbittar's review on #278: cross-artifact validation had been put inside parse_yaml_library, which is the reading/validating mixing that #265 set out to undo. - New gems_craft/model/validation.py holds check_library_against_taxonomy (moved unchanged from taxonomy.py, with its _missing helper) plus validate_libraries_against_taxonomy, which absorbs the declared-id and missing-taxonomy checks. - parse_yaml_library goes back to a pure reader: no taxonomy argument, no validation. input_libs likewise loses the argument. - load_study reads input/taxonomy.yml and calls the validation explicitly after parsing, alongside consistency_check. - taxonomy.py no longer imports parsing, so the TYPE_CHECKING guard added to break that cycle is gone — the cycle no longer exists. - Acts on the TODO left by #265: consistency_check moves from resolve_components.py to a new gems_craft/study/validation.py; its callers are repointed and a now-dead Model import is dropped. Conformance is enforced when a study is loaded rather than on every parse, matching how consistency_check and validate_optim_config already work. * refactor(study): disambiguate the two consistency checks `Study.check_consistency()` and `study/validation.py`'s `consistency_check()` were word-order permutations of each other, both under `gems_craft.study` and both raising ValueError about "consistency", while checking disjoint things. - `consistency_check` -> `check_component_models`: components reference a model id known to the library. - `Study.check_consistency()` -> `validation.check_data_requirements(study)`: the database covers every model parameter with a matching time/scenario structure. This resolves the TODO left by #265 and leaves `study.py` as a plain container. No behavior change; names and import paths only. --- AGENTS.md | 2 +- docs/CHANGELOG.md | 22 ++- src/gems_craft/model/parsing.py | 24 +--- src/gems_craft/model/taxonomy.py | 91 +----------- src/gems_craft/model/validation.py | 131 ++++++++++++++++++ src/gems_craft/study/folder.py | 8 +- src/gems_craft/study/resolve_components.py | 18 --- src/gems_craft/study/study.py | 33 +---- src/gems_craft/study/validation.py | 63 +++++++++ src/gems_runner/main/main.py | 7 +- src/gems_runner/simulation/optimization.py | 5 +- tests/e2e/functional/perf_pypsa.py | 4 +- .../test_component_dependent_time_shift.py | 4 +- .../functional/test_libs_yaml_system_yaml.py | 6 +- tests/e2e/functional/test_scenario_builder.py | 4 +- .../lib_parsing/test_taxonomy_check.py | 40 ++++-- .../system/test_data_consistency.py | 17 +-- .../system_parsing/test_components_parsing.py | 11 +- 18 files changed, 277 insertions(+), 213 deletions(-) create mode 100644 src/gems_craft/model/validation.py create mode 100644 src/gems_craft/study/validation.py diff --git a/AGENTS.md b/AGENTS.md index 600989d0..eef978bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ The codebase is split into three packages along a solver-dependency boundary: **`gems_craft/model/`** — Immutable model templates. - `Model`: defines component behavior (parameters, variables, constraints, ports) - `Library`: a collection of models, loaded from YAML -- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`; `check_library_against_taxonomy` enforces conformance, called from `parse_yaml_library` for libraries declaring a `taxonomy`. The caller supplies the `Taxonomy`; `load_study` reads it from the optional `input/taxonomy.yml`. +- `Taxonomy` (`taxonomy.py`): categories naming the items a model must expose. Models opt in via `taxonomy-category`. `taxonomy.py` holds the data and `load_taxonomy` only; conformance lives in `validation.py` (`check_library_against_taxonomy`, and `validate_libraries_against_taxonomy` for libraries declaring a `taxonomy`), which `load_study` calls after parsing — `parse_yaml_library` stays a pure reader. `load_study` reads the `Taxonomy` from the optional `input/taxonomy.yml`. - `PortTypeSchema` (`parsing.py`) also declares the hybrid-only `area-connection` (`AreaConnectionSchema`) and `thermal-capacity-connection` (`PortThermalCapacitySchema`) fields; `resolve_library.py`'s `_convert_port_type` parses and discards them for every library, hybrid or not. **`gems_craft/expression/`** — Mathematical expression language and AST (structural/static analysis only — no numeric evaluation). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d136fa3d..022be60c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,14 +5,26 @@ All notable changes to GemsPy are documented here. ## [Unreleased] ### Added -- **Taxonomy conformance checked at parse time** - `parse_yaml_library` takes an - optional `taxonomy` and checks every library declaring a `taxonomy` field. - `load_study` reads it from the optional `input/taxonomy.yml`. +- **Taxonomy conformance checked when a study is loaded** - `load_study` reads + the optional `input/taxonomy.yml` and calls + `validate_libraries_against_taxonomy` on every library declaring a `taxonomy` + field. `parse_yaml_library` is unchanged and performs no validation. ### Changed -- **Breaking** - parsing a library that declares a `taxonomy` raises `ValueError` - if no taxonomy is supplied, or if its id differs from the declared one. +- **Breaking** - loading a study whose library declares a `taxonomy` raises + `ValueError` if no taxonomy is supplied, or if its id differs from the declared + one. - **Breaking** - `TaxonomyData` renamed to `TaxonomySchema`; no alias kept. +- **Breaking** - `check_library_against_taxonomy` moved from + `gems_craft.model.taxonomy` to the new `gems_craft.model.validation`, and + `consistency_check` from `gems_craft.study.resolve_components` to the new + `gems_craft.study.validation`, where it is renamed + `check_component_models`. The `Study.check_consistency()` method moved to + that same module as the function `check_data_requirements(study)` — the two + checks are disjoint (component model ids vs. database coverage) and their + near-identical old names invited confusion. Reading and validating are now + separate modules, as in `optim_config/`. No behavior change; names and + import paths only. --- diff --git a/src/gems_craft/model/parsing.py b/src/gems_craft/model/parsing.py index bbacbed5..cadb383e 100644 --- a/src/gems_craft/model/parsing.py +++ b/src/gems_craft/model/parsing.py @@ -17,7 +17,6 @@ from pydantic import ConfigDict, Field, ValidationError from yaml import safe_dump, safe_load -from gems_craft.model.taxonomy import Taxonomy, check_library_against_taxonomy from gems_craft.utils import ModifiedBaseModel @@ -124,31 +123,12 @@ class LibrarySchema(ModifiedBaseModel): version: Optional[str] = None -def _check_declared_taxonomy( - library: LibrarySchema, taxonomy: Optional[Taxonomy] -) -> None: - """Check a library against the taxonomy it declares conformance to.""" - declared = f"Library '{library.id}' declares taxonomy '{library.taxonomy}'" - if taxonomy is None: - raise ValueError( - f"{declared} but no taxonomy was provided to check it against." - ) - if library.taxonomy != taxonomy.id: - raise ValueError(f"{declared} but was checked against '{taxonomy.id}'.") - check_library_against_taxonomy(library, taxonomy) - - -def parse_yaml_library( - input: typing.TextIO, taxonomy: Optional[Taxonomy] = None -) -> LibrarySchema: +def parse_yaml_library(input: typing.TextIO) -> LibrarySchema: tree = safe_load(input) try: - library = LibrarySchema.model_validate(tree["library"]) + return LibrarySchema.model_validate(tree["library"]) except ValidationError as e: raise ValueError(f"An error occurred during parsing: {e}") - if library.taxonomy is not None: - _check_declared_taxonomy(library, taxonomy) - return library def write_yaml_library(library: LibrarySchema, path: Path) -> None: diff --git a/src/gems_craft/model/taxonomy.py b/src/gems_craft/model/taxonomy.py index 6eba7e18..b2eb2410 100644 --- a/src/gems_craft/model/taxonomy.py +++ b/src/gems_craft/model/taxonomy.py @@ -12,17 +12,13 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from typing import List, Optional import yaml from pydantic import Field from gems_craft.utils import ModifiedBaseModel -if TYPE_CHECKING: - # Annotations only — parsing.py imports this module, so this would be circular. - from gems_craft.model.parsing import LibrarySchema, ModelSchema - class TaxonomyItem(ModifiedBaseModel): id: str @@ -63,88 +59,3 @@ def load_taxonomy(taxonomy_file: Path) -> Taxonomy: return Taxonomy( id=data.id, description=data.description, categories=data.categories ) - - -def _missing( - required: List[TaxonomyItem], exposed: List, exposed_key: Callable -) -> List[str]: - """Return the sorted taxonomy item ids not exposed by the model. - - Taxonomy items are always identified by their ``id``; ``exposed_key`` maps each - model-side item to the identifier to compare against (e.g. the ``port.field`` - string for port-field-definitions). - """ - return sorted( - {item.id for item in required} - {exposed_key(item) for item in exposed} - ) - - -def check_library_against_taxonomy( - library: "LibrarySchema", taxonomy: Taxonomy -) -> None: - """ - Validates that every model declaring a taxonomy_category: - 1. References a category that exists in the taxonomy. - 2. Exposes all variables, parameters, ports, port-field-definitions, - constraints, binding-constraints, extra-outputs and properties listed - in that taxonomy category. - - Raises ValueError describing the first violation found. - """ - categories: Dict[str, TaxonomyCategory] = {c.id: c for c in taxonomy.categories} - - by_id: Callable = lambda x: x.id - - # Each entry maps a human-readable field-group name to the required items - # (from the taxonomy category) and the items exposed by the model, plus the - # function identifying a model-side item within that group. Taxonomy items are - # homogeneous (``TaxonomyItem``) and always identified by their ``id``. - def field_groups( - category: TaxonomyCategory, model_schema: "ModelSchema" - ) -> List[tuple]: - port_field_key: Callable = lambda d: f"{d.port}.{d.field}" - return [ - ("variable", category.variables, model_schema.variables, by_id), - ("parameter", category.parameters, model_schema.parameters, by_id), - ("port", category.ports, model_schema.ports, by_id), - ( - "port-field-definition", - category.port_field_definitions, - model_schema.port_field_definitions, - port_field_key, - ), - ("constraint", category.constraints, model_schema.constraints, by_id), - ( - "binding-constraint", - category.binding_constraints, - model_schema.binding_constraints, - by_id, - ), - ( - "extra-output", - category.extra_outputs, - model_schema.extra_outputs or [], - by_id, - ), - ("property", category.properties, model_schema.properties, by_id), - ] - - for model_schema in library.models: - cat_id = model_schema.taxonomy_category - if cat_id is None: - continue - - if cat_id not in categories: - raise ValueError( - f"Model '{model_schema.id}' references taxonomy category '{cat_id}' " - f"which does not exist in taxonomy '{taxonomy.id}'." - ) - - category = categories[cat_id] - for group_name, required, exposed, key in field_groups(category, model_schema): - missing = _missing(required, exposed, key) - if missing: - raise ValueError( - f"Model '{model_schema.id}' (taxonomy-category: '{cat_id}') is " - f"missing {group_name}(s) required by the taxonomy: {missing}." - ) diff --git a/src/gems_craft/model/validation.py b/src/gems_craft/model/validation.py new file mode 100644 index 00000000..6a6ee951 --- /dev/null +++ b/src/gems_craft/model/validation.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +"""Cross-validation of parsed model libraries. + +Kept apart from `parsing.py` (which only reads YAML into schemas) and from +`taxonomy.py` (which only holds the taxonomy data and reads it from disk), so +that reading and validating stay separate concerns — mirroring +`optim_config/parsing.py` and `optim_config/validation.py`. +""" + +from typing import Callable, Dict, List, Optional + +from gems_craft.model.parsing import LibrarySchema, ModelSchema +from gems_craft.model.taxonomy import Taxonomy, TaxonomyCategory, TaxonomyItem + + +def validate_libraries_against_taxonomy( + libraries: List[LibrarySchema], taxonomy: Optional[Taxonomy] +) -> None: + """Check every library declaring a ``taxonomy`` field against ``taxonomy``. + + Libraries that do not declare a taxonomy are left alone, even when some of + their models carry a ``taxonomy-category``. + + Raises ValueError if a declaring library has no taxonomy to check against, + if it declares a different taxonomy id, or if any of its models violates it. + """ + for library in libraries: + if library.taxonomy is None: + continue + declared = f"Library '{library.id}' declares taxonomy '{library.taxonomy}'" + if taxonomy is None: + raise ValueError( + f"{declared} but no taxonomy was provided to check it against." + ) + if library.taxonomy != taxonomy.id: + raise ValueError(f"{declared} but was checked against '{taxonomy.id}'.") + check_library_against_taxonomy(library, taxonomy) + + +def _missing( + required: List[TaxonomyItem], exposed: List, exposed_key: Callable +) -> List[str]: + """Return the sorted taxonomy item ids not exposed by the model. + + Taxonomy items are always identified by their ``id``; ``exposed_key`` maps each + model-side item to the identifier to compare against (e.g. the ``port.field`` + string for port-field-definitions). + """ + return sorted( + {item.id for item in required} - {exposed_key(item) for item in exposed} + ) + + +def check_library_against_taxonomy(library: LibrarySchema, taxonomy: Taxonomy) -> None: + """ + Validates that every model declaring a taxonomy_category: + 1. References a category that exists in the taxonomy. + 2. Exposes all variables, parameters, ports, port-field-definitions, + constraints, binding-constraints, extra-outputs and properties listed + in that taxonomy category. + + Raises ValueError describing the first violation found. + """ + categories: Dict[str, TaxonomyCategory] = {c.id: c for c in taxonomy.categories} + + by_id: Callable = lambda x: x.id + + # Each entry maps a human-readable field-group name to the required items + # (from the taxonomy category) and the items exposed by the model, plus the + # function identifying a model-side item within that group. Taxonomy items are + # homogeneous (``TaxonomyItem``) and always identified by their ``id``. + def field_groups( + category: TaxonomyCategory, model_schema: ModelSchema + ) -> List[tuple]: + port_field_key: Callable = lambda d: f"{d.port}.{d.field}" + return [ + ("variable", category.variables, model_schema.variables, by_id), + ("parameter", category.parameters, model_schema.parameters, by_id), + ("port", category.ports, model_schema.ports, by_id), + ( + "port-field-definition", + category.port_field_definitions, + model_schema.port_field_definitions, + port_field_key, + ), + ("constraint", category.constraints, model_schema.constraints, by_id), + ( + "binding-constraint", + category.binding_constraints, + model_schema.binding_constraints, + by_id, + ), + ( + "extra-output", + category.extra_outputs, + model_schema.extra_outputs or [], + by_id, + ), + ("property", category.properties, model_schema.properties, by_id), + ] + + for model_schema in library.models: + cat_id = model_schema.taxonomy_category + if cat_id is None: + continue + + if cat_id not in categories: + raise ValueError( + f"Model '{model_schema.id}' references taxonomy category '{cat_id}' " + f"which does not exist in taxonomy '{taxonomy.id}'." + ) + + category = categories[cat_id] + for group_name, required, exposed, key in field_groups(category, model_schema): + missing = _missing(required, exposed, key) + if missing: + raise ValueError( + f"Model '{model_schema.id}' (taxonomy-category: '{cat_id}') is " + f"missing {group_name}(s) required by the taxonomy: {missing}." + ) diff --git a/src/gems_craft/study/folder.py b/src/gems_craft/study/folder.py index 79020138..5ef9d306 100644 --- a/src/gems_craft/study/folder.py +++ b/src/gems_craft/study/folder.py @@ -15,14 +15,15 @@ from gems_craft.model.parsing import parse_yaml_library from gems_craft.model.resolve_library import resolve_library from gems_craft.model.taxonomy import load_taxonomy +from gems_craft.model.validation import validate_libraries_against_taxonomy from gems_craft.study.parsing import parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, - consistency_check, resolve_system, ) from gems_craft.study.scenario_builder import ScenarioBuilder from gems_craft.study.study import Study +from gems_craft.study.validation import check_component_models def load_study(study_dir: Path) -> Study: @@ -50,7 +51,8 @@ def load_study(study_dir: Path) -> Study: input_libraries = [] for lib_file in lib_folder.glob("*.yml"): with lib_file.open() as lib: - input_libraries.append(parse_yaml_library(lib, taxonomy=taxonomy)) + input_libraries.append(parse_yaml_library(lib)) + validate_libraries_against_taxonomy(input_libraries, taxonomy) with system_file.open() as c: input_study = parse_yaml_system(c) @@ -59,7 +61,7 @@ def load_study(study_dir: Path) -> Study: model_dict: dict[str, Model] = {} for library in lib_dict.values(): model_dict |= library.models - consistency_check(system, model_dict) + check_component_models(system, model_dict) scenario_builder_path = ( study_dir / "input" / "data-series" / "modeler-scenariobuilder.dat" diff --git a/src/gems_craft/study/resolve_components.py b/src/gems_craft/study/resolve_components.py index ced3457b..83197607 100644 --- a/src/gems_craft/study/resolve_components.py +++ b/src/gems_craft/study/resolve_components.py @@ -12,7 +12,6 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple, Union -from gems_craft.model import Model from gems_craft.model.library import Library from gems_craft.study import ( Component, @@ -132,23 +131,6 @@ def _get_component_by_id( return components_dict.get(component_id) -# TODO: cross-validation logic mixed into a "resolve" (preprocessing) module — -# move to a dedicated gems_craft/study/validation.py, mirroring optim_config/. -def consistency_check(system: System, input_models: Dict[str, Model]) -> bool: - """ - Checks if all components in the System have a valid model from the library. - Returns True if all components are consistent, raises ValueError otherwise. - """ - # TODO: Update this consistency check to check if each component have a valid model from the lib it refers to (and not all libs) - model_ids_set = input_models.keys() - for component in system.all_components: - if component.model.id not in model_ids_set: - raise ValueError( - f"Error: Component {component.id} has invalid model ID: {component.model.id}" - ) - return True - - def build_data_base( input_system: SystemSchema, timeseries_dir: Optional[Path], diff --git a/src/gems_craft/study/study.py b/src/gems_craft/study/study.py index 32080be0..431ebd46 100644 --- a/src/gems_craft/study/study.py +++ b/src/gems_craft/study/study.py @@ -28,10 +28,9 @@ class Study: DataBase (parameter values for those components). These two objects are always used together to build an optimisation - problem. ``Study`` gathers them into a single, coherent unit and - provides the cross-validation logic that was previously spread between - ``DataBase.requirements_consistency`` and the callers of - ``build_problem``. + problem. ``Study`` gathers them into a single, coherent unit; the + cross-validation of the pair lives in ``study/validation.py`` + (``check_data_requirements``). """ system: System @@ -52,29 +51,3 @@ def models(self) -> Dict[str, Model]: return { mk: components[0].model for mk, components in self.model_components.items() } - - # TODO: this is a second, disjoint consistency check alongside - # resolve_components.consistency_check() — consider consolidating both - # into one validation module for System/Study. - def check_consistency(self) -> None: - """Validate that the database supplies data for every parameter of every - component defined in the system. - - Raises - ------ - ValueError - If a required data entry is missing or its time/scenario structure - does not match what the model parameter expects. - """ - for component in self.system.components: - for param in component.model.parameters.values(): - data_structure = self.database.get_data(component.id, param.name) - - if not data_structure.check_requirement( - component.model.parameters[param.name].structure.time, - component.model.parameters[param.name].structure.scenario, - ): - raise ValueError( - f"Data inconsistency for component: {component.id}, " - f"parameter: {param.name}. Requirement not met." - ) diff --git a/src/gems_craft/study/validation.py b/src/gems_craft/study/validation.py new file mode 100644 index 00000000..1539c0f5 --- /dev/null +++ b/src/gems_craft/study/validation.py @@ -0,0 +1,63 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +"""Cross-validation of a resolved system against the models and data it refers to. + +Kept apart from `resolve_components.py` (which only resolves the parsed system +into its runtime objects) and from `study.py` (which only holds them together) +— mirroring `optim_config/parsing.py` and `optim_config/validation.py`. +""" + +from typing import Dict + +from gems_craft.model import Model +from gems_craft.study.study import Study +from gems_craft.study.system import System + + +def check_component_models(system: System, input_models: Dict[str, Model]) -> bool: + """ + Checks if all components in the System have a valid model from the library. + Returns True if all components are consistent, raises ValueError otherwise. + """ + # TODO: Update this check to verify that each component has a valid model from the lib it refers to (and not all libs) + model_ids_set = input_models.keys() + for component in system.all_components: + if component.model.id not in model_ids_set: + raise ValueError( + f"Error: Component {component.id} has invalid model ID: {component.model.id}" + ) + return True + + +def check_data_requirements(study: Study) -> None: + """Validate that the database supplies data for every parameter of every + component defined in the system. + + Raises + ------ + ValueError + If a required data entry is missing or its time/scenario structure + does not match what the model parameter expects. + """ + for component in study.system.components: + for param in component.model.parameters.values(): + data_structure = study.database.get_data(component.id, param.name) + + if not data_structure.check_requirement( + component.model.parameters[param.name].structure.time, + component.model.parameters[param.name].structure.scenario, + ): + raise ValueError( + f"Data inconsistency for component: {component.id}, " + f"parameter: {param.name}. Requirement not met." + ) diff --git a/src/gems_runner/main/main.py b/src/gems_runner/main/main.py index 93c6be3e..a0d33721 100644 --- a/src/gems_runner/main/main.py +++ b/src/gems_runner/main/main.py @@ -16,7 +16,6 @@ from gems_craft.model.library import Library from gems_craft.model.parsing import parse_yaml_library from gems_craft.model.resolve_library import resolve_library -from gems_craft.model.taxonomy import Taxonomy from gems_craft.optim_config.parsing import OptimConfig from gems_craft.study import Study from gems_craft.study.data import DataBase @@ -27,14 +26,12 @@ from gems_runner.study.runner import run_study -def input_libs( - yaml_lib_paths: List[Path], taxonomy: Optional[Taxonomy] = None -) -> Dict[str, Library]: +def input_libs(yaml_lib_paths: List[Path]) -> Dict[str, Library]: yaml_libraries = [] yaml_library_ids = set() for path in yaml_lib_paths: with path.open("r") as file: - yaml_lib = parse_yaml_library(file, taxonomy=taxonomy) + yaml_lib = parse_yaml_library(file) if yaml_lib.id in yaml_library_ids: raise ValueError(f"The identifier '{yaml_lib.id}' is defined twice") yaml_libraries.append(yaml_lib) diff --git a/src/gems_runner/simulation/optimization.py b/src/gems_runner/simulation/optimization.py index 71752c58..30706219 100644 --- a/src/gems_runner/simulation/optimization.py +++ b/src/gems_runner/simulation/optimization.py @@ -48,6 +48,7 @@ from gems_craft.study.parsing import IntegerStrategyId from gems_craft.study.study import Study from gems_craft.study.system import Component +from gems_craft.study.validation import check_data_requirements from gems_runner.simulation.linearize import ( VectorizedExpr, VectorizedLinearExprBuilder, @@ -1071,7 +1072,7 @@ def build_problem( problem is built. Entries whose variable is time-independent, or is absent from this block, are ignored. """ - study.check_consistency() + check_data_requirements(study) oob_filter = OutOfBoundsFilter(optim_config) if optim_config is not None else None builder = _OptimizationProblemBuilder( @@ -1145,7 +1146,7 @@ def build_decomposed_problems( """ from gems_craft.optim_config.parsing import ElementLocation - study.check_consistency() + check_data_requirements(study) oob_filter = OutOfBoundsFilter(optim_config) diff --git a/tests/e2e/functional/perf_pypsa.py b/tests/e2e/functional/perf_pypsa.py index 50943679..e5f26433 100644 --- a/tests/e2e/functional/perf_pypsa.py +++ b/tests/e2e/functional/perf_pypsa.py @@ -12,10 +12,10 @@ from gems_craft.study.parsing import parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, - consistency_check, resolve_system, ) from gems_craft.study.system import System +from gems_craft.study.validation import check_component_models from gems_runner.simulation import TimeBlock, build_problem @@ -30,7 +30,7 @@ def setup_data(pypsa_dir: Path) -> Tuple[System, DataBase]: input_study = parse_yaml_system(c) lib_dict = resolve_library([input_library]) system = resolve_system(input_study, lib_dict) - consistency_check(system, lib_dict["pypsa_models"].models) + check_component_models(system, lib_dict["pypsa_models"].models) database = build_data_base(input_study, series_dir) return system, database diff --git a/tests/e2e/functional/test_component_dependent_time_shift.py b/tests/e2e/functional/test_component_dependent_time_shift.py index f9bd5aaa..983e1115 100644 --- a/tests/e2e/functional/test_component_dependent_time_shift.py +++ b/tests/e2e/functional/test_component_dependent_time_shift.py @@ -100,9 +100,9 @@ from gems_craft.study.parsing import parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, - consistency_check, resolve_system, ) +from gems_craft.study.validation import check_component_models from gems_runner.simulation import TimeBlock, build_problem from tests.e2e.functional.libs.standard import ( BALANCE_PORT_TYPE, @@ -413,7 +413,7 @@ def test_two_components_different_lags_yaml( lib_dict = resolve_library([input_library]) system = resolve_system(input_system, lib_dict) - consistency_check(system, lib_dict["time_shift_test"].models) + check_component_models(system, lib_dict["time_shift_test"].models) database = build_data_base(input_system, timeseries_dir=_series_dir) diff --git a/tests/e2e/functional/test_libs_yaml_system_yaml.py b/tests/e2e/functional/test_libs_yaml_system_yaml.py index 1e886d2a..99c130d3 100644 --- a/tests/e2e/functional/test_libs_yaml_system_yaml.py +++ b/tests/e2e/functional/test_libs_yaml_system_yaml.py @@ -47,11 +47,11 @@ from gems_craft.study.parsing import SystemSchema, parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, - consistency_check, resolve_system, ) from gems_craft.study.study import Study from gems_craft.study.system import System +from gems_craft.study.validation import check_component_models from gems_runner.simulation import TimeBlock, build_problem @@ -60,7 +60,7 @@ def test_basic_balance_using_yaml( ) -> None: result_lib = resolve_library([input_library]) system = resolve_system(input_system, result_lib) - consistency_check(system, result_lib["basic"].models) + check_component_models(system, result_lib["basic"].models) database = build_data_base(input_system, None) @@ -87,7 +87,7 @@ def _setup_test(study_file_name: str) -> Study: input_system = parse_yaml_system(c) lib_dict = resolve_library([input_library]) system = resolve_system(input_system, lib_dict) - consistency_check(system, lib_dict["basic"].models) + check_component_models(system, lib_dict["basic"].models) database = build_data_base(input_system, series_dir) return Study(system, database) diff --git a/tests/e2e/functional/test_scenario_builder.py b/tests/e2e/functional/test_scenario_builder.py index c3758171..f46f884f 100644 --- a/tests/e2e/functional/test_scenario_builder.py +++ b/tests/e2e/functional/test_scenario_builder.py @@ -21,10 +21,10 @@ from gems_craft.study.parsing import parse_yaml_system from gems_craft.study.resolve_components import ( build_data_base, - consistency_check, resolve_system, ) from gems_craft.study.scenario_builder import ScenarioBuilder +from gems_craft.study.validation import check_component_models from gems_runner.simulation import build_problem from gems_runner.simulation.time_block import TimeBlock @@ -58,7 +58,7 @@ def test_system_with_scenarization( yaml_comp = parse_yaml_system(file) components = resolve_system(yaml_comp, lib_dict) - consistency_check(components, lib_dict["basic"].models) + check_component_models(components, lib_dict["basic"].models) timeblock = TimeBlock(1, list(range(2))) problem = build_problem(Study(components, database), timeblock, list(range(3))) diff --git a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py index c792f736..44fa3fff 100644 --- a/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py +++ b/tests/unittests/gems_craft/lib_parsing/test_taxonomy_check.py @@ -12,7 +12,6 @@ import io from pathlib import Path -from typing import Optional import pytest @@ -21,9 +20,12 @@ Taxonomy, TaxonomyCategory, TaxonomyItem, - check_library_against_taxonomy, load_taxonomy, ) +from gems_craft.model.validation import ( + check_library_against_taxonomy, + validate_libraries_against_taxonomy, +) def _make_taxonomy(*categories: TaxonomyCategory) -> Taxonomy: @@ -34,8 +36,8 @@ def _make_category(cat_id: str, port_ids: list[str]) -> TaxonomyCategory: return TaxonomyCategory(id=cat_id, ports=[TaxonomyItem(id=p) for p in port_ids]) -def _parse_lib(yaml_content: str, taxonomy: Optional[Taxonomy] = None): - return parse_yaml_library(io.StringIO(yaml_content), taxonomy=taxonomy) +def _parse_lib(yaml_content: str): + return parse_yaml_library(io.StringIO(yaml_content)) # --- valid cases --- @@ -421,7 +423,7 @@ def test_model_exposing_all_required_fields_is_valid() -> None: check_library_against_taxonomy(lib, taxonomy) # must not raise -# --- parse_yaml_library wiring --- +# --- validate_libraries_against_taxonomy --- _CONFORMING_LIB = """ library: @@ -458,34 +460,42 @@ def test_model_exposing_all_required_fields_is_valid() -> None: """ -def test_parse_library_declaring_taxonomy_is_checked() -> None: +def test_library_declaring_taxonomy_is_checked() -> None: taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) - lib = _parse_lib(_CONFORMING_LIB, taxonomy) + lib = _parse_lib(_CONFORMING_LIB) + validate_libraries_against_taxonomy([lib], taxonomy) # must not raise assert lib.taxonomy == "test_taxonomy" -def test_parse_library_declaring_taxonomy_raises_on_violation() -> None: +def test_library_declaring_taxonomy_raises_on_violation() -> None: taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) with pytest.raises(ValueError, match="injection_port"): - _parse_lib(_VIOLATING_LIB, taxonomy) + validate_libraries_against_taxonomy([_parse_lib(_VIOLATING_LIB)], taxonomy) -def test_parse_library_declaring_taxonomy_without_argument_raises() -> None: +def test_library_declaring_taxonomy_without_argument_raises() -> None: with pytest.raises(ValueError, match="no taxonomy was provided"): - _parse_lib(_CONFORMING_LIB) + validate_libraries_against_taxonomy([_parse_lib(_CONFORMING_LIB)], None) -def test_parse_library_with_mismatched_taxonomy_id_raises() -> None: +def test_library_with_mismatched_taxonomy_id_raises() -> None: taxonomy = Taxonomy( id="other_taxonomy", categories=[_make_category("production", ["injection_port"])], ) with pytest.raises(ValueError, match="other_taxonomy"): - _parse_lib(_CONFORMING_LIB, taxonomy) + validate_libraries_against_taxonomy([_parse_lib(_CONFORMING_LIB)], taxonomy) -def test_parse_library_without_declared_taxonomy_is_not_checked() -> None: +def test_library_without_declared_taxonomy_is_not_checked() -> None: """A model may carry a taxonomy-category without the library opting in.""" taxonomy = _make_taxonomy(_make_category("production", ["injection_port"])) - lib = _parse_lib(_LIB_WITHOUT_TAXONOMY, taxonomy) + lib = _parse_lib(_LIB_WITHOUT_TAXONOMY) + validate_libraries_against_taxonomy([lib], taxonomy) # must not raise assert lib.taxonomy is None + + +def test_parsing_a_declaring_library_does_not_validate_it() -> None: + """Parsing is pure reading: conformance is the caller's business.""" + lib = _parse_lib(_VIOLATING_LIB) # must not raise + assert lib.taxonomy == "test_taxonomy" diff --git a/tests/unittests/gems_craft/system/test_data_consistency.py b/tests/unittests/gems_craft/system/test_data_consistency.py index 400a1990..dcab18b1 100644 --- a/tests/unittests/gems_craft/system/test_data_consistency.py +++ b/tests/unittests/gems_craft/system/test_data_consistency.py @@ -41,6 +41,7 @@ create_component, ) from gems_craft.study.data import load_ts_from_file +from gems_craft.study.validation import check_data_requirements from tests.unittests.gems_craft.system.libs.standard import ( BALANCE_PORT_TYPE, CONSTANT, @@ -152,7 +153,7 @@ def test_requirements_consistency_demand_model_fix_ok( # When # No ValueError should be raised - Study(mock_network, database).check_consistency() + check_data_requirements(Study(mock_network, database)) def test_requirements_consistency_generator_model_ok(mock_network: System) -> None: @@ -165,7 +166,7 @@ def test_requirements_consistency_generator_model_ok(mock_network: System) -> No database.add_data("D", "demand", ConstantData(30)) # When - Study(mock_network, database).check_consistency() + check_data_requirements(Study(mock_network, database)) def test_consistency_generation_time_free_for_constant_model_raises_exception( @@ -184,7 +185,7 @@ def test_consistency_generation_time_free_for_constant_model_raises_exception( # When with pytest.raises(ValueError, match="Data inconsistency"): - Study(mock_network, database).check_consistency() + check_data_requirements(Study(mock_network, database)) def test_requirements_consistency_demand_model_time_varying_ok( @@ -200,7 +201,7 @@ def test_requirements_consistency_demand_model_time_varying_ok( # When # No ValueError should be raised - Study(mock_network, database).check_consistency() + check_data_requirements(Study(mock_network, database)) def test_requirements_consistency_time_varying_parameter_with_correct_data_passes( @@ -225,7 +226,7 @@ def test_requirements_consistency_time_varying_parameter_with_correct_data_passe system.connect(PortRef(gen, "balance_port"), PortRef(node, "balance_port")) # No ValueError should be raised - Study(system, database).check_consistency() + check_data_requirements(Study(system, database)) @pytest.mark.parametrize( @@ -270,7 +271,7 @@ def test_requirements_consistency_time_varying_parameter_with_scenario_varying_d # When # ValueError should be raised with pytest.raises(ValueError, match="Data inconsistency"): - Study(system, database).check_consistency() + check_data_requirements(Study(system, database)) @pytest.mark.parametrize( @@ -306,7 +307,7 @@ def test_requirements_consistency_scenario_varying_parameter_with_time_varying_d # ValueError should be raised with pytest.raises(ValueError, match="Data inconsistency"): - Study(system, database).check_consistency() + check_data_requirements(Study(system, database)) def test_requirements_consistency_scenario_varying_parameter_with_correct_data_passes( @@ -331,7 +332,7 @@ def test_requirements_consistency_scenario_varying_parameter_with_correct_data_p system.add_component(gen) # No ValueError should be raised - Study(system, database).check_consistency() + check_data_requirements(Study(system, database)) def test_load_data_from_txt() -> None: diff --git a/tests/unittests/gems_craft/system_parsing/test_components_parsing.py b/tests/unittests/gems_craft/system_parsing/test_components_parsing.py index 7173a3fe..00a3978d 100644 --- a/tests/unittests/gems_craft/system_parsing/test_components_parsing.py +++ b/tests/unittests/gems_craft/system_parsing/test_components_parsing.py @@ -18,7 +18,8 @@ load_input_system, parse_yaml_system, ) -from gems_craft.study.resolve_components import consistency_check, resolve_system +from gems_craft.study.resolve_components import resolve_system +from gems_craft.study.validation import check_component_models COMPO_FILE = Path(__file__).parent / "systems/system.yml" @@ -50,12 +51,12 @@ def test_parsing_components_ok( assert len(result.connections) == 2 -def test_consistency_check_ok( +def test_check_component_models_ok( input_system: SystemSchema, input_library: LibrarySchema ) -> None: result_lib = resolve_library([input_library]) result_system = resolve_system(input_system, result_lib) - consistency_check(result_system, result_lib["basic"].models) + check_component_models(result_system, result_lib["basic"].models) def test_load_input_system_ok(tmp_path: Path) -> None: @@ -93,7 +94,7 @@ def test_load_input_system_missing_file_raises_error() -> None: load_input_system(missing) -def test_consistency_check_ko( +def test_check_component_models_ko( input_system: SystemSchema, input_library: LibrarySchema ) -> None: result_lib = resolve_library([input_library]) @@ -103,7 +104,7 @@ def test_consistency_check_ko( ValueError, match=r"Error: Component G has invalid model ID: basic.generator", ): - consistency_check(result_comp, result_lib["basic"].models) + check_component_models(result_comp, result_lib["basic"].models) # ---------------------------------------------------------------------------