Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. `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).
Expand Down
24 changes: 24 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ All notable changes to GemsPy are documented here.

## [Unreleased]

### Added
- **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** - 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.

---

## [0.2.0] - 2026-08-24

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ my_study/
├── input/
│ ├── system.yml
│ ├── optim-config.yml
│ ├── taxonomy.yml ← optional
│ ├── model-libraries/
│ │ └── *.yml
│ └── data-series/
Expand Down
90 changes: 3 additions & 87 deletions src/gems_craft/model/taxonomy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@

from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
from typing import List, Optional

import yaml
from pydantic import Field

from gems_craft.model.parsing import LibrarySchema, ModelSchema
from gems_craft.utils import ModifiedBaseModel


Expand All @@ -38,7 +37,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)
Expand All @@ -56,90 +55,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
)


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}."
)
131 changes: 131 additions & 0 deletions src/gems_craft/model/validation.py
Original file line number Diff line number Diff line change
@@ -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}."
)
15 changes: 12 additions & 3 deletions src/gems_craft/study/folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@
- `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

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.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:
Expand All @@ -28,7 +32,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.
Expand All @@ -39,11 +44,15 @@ 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))
validate_libraries_against_taxonomy(input_libraries, taxonomy)

with system_file.open() as c:
input_study = parse_yaml_system(c)
Expand All @@ -52,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"
Expand Down
18 changes: 0 additions & 18 deletions src/gems_craft/study/resolve_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading