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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, 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).
Expand Down
22 changes: 17 additions & 5 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +18 to +27

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really needed to specify this as it only changes import paths


---

Expand Down
24 changes: 2 additions & 22 deletions src/gems_craft/model/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
91 changes: 1 addition & 90 deletions src/gems_craft/model/taxonomy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}."
)
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}."
)
8 changes: 5 additions & 3 deletions src/gems_craft/study/folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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"
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