Skip to content

Commit 3641c55

Browse files
feat(study): introduce Study class to encapsulate System and DataBase
- Added a new `Study` dataclass that combines `System` and `DataBase`, centralizing consistency checks. - Updated `build_problem()` and `build_decomposed_problems()` to accept `Study` directly. - Refactored related functions and tests to utilize the new `Study` structure, ensuring seamless integration. - Removed redundant parameters and streamlined the API for better clarity and usability.
1 parent 39bdb5c commit 3641c55

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

src/gems/model/taxonomy.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (c) 2026, RTE (https://www.rte-france.com)
2+
#
3+
# See AUTHORS.txt
4+
#
5+
# This Source Code Form is subject to the terms of the Mozilla Public
6+
# License, v. 2.0. If a copy of the MPL was not distributed with this
7+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
8+
#
9+
# SPDX-License-Identifier: MPL-2.0
10+
#
11+
# This file is part of the Antares project.
12+
13+
from dataclasses import dataclass, field
14+
from pathlib import Path
15+
from typing import Dict, List, Optional
16+
17+
import yaml
18+
from pydantic import Field
19+
20+
from gems.model.parsing import LibrarySchema
21+
from gems.utils import ModifiedBaseModel
22+
23+
24+
class TaxonomyItem(ModifiedBaseModel):
25+
id: str
26+
27+
28+
class TaxonomyCategory(ModifiedBaseModel):
29+
id: str
30+
parent_category: Optional[str] = None
31+
variables: List[TaxonomyItem] = Field(default_factory=list)
32+
parameters: List[TaxonomyItem] = Field(default_factory=list)
33+
ports: List[TaxonomyItem] = Field(default_factory=list)
34+
constraints: List[TaxonomyItem] = Field(default_factory=list)
35+
extra_outputs: List[TaxonomyItem] = Field(default_factory=list)
36+
properties: List[TaxonomyItem] = Field(default_factory=list)
37+
38+
39+
class TaxonomyData(ModifiedBaseModel):
40+
id: str
41+
description: str = ""
42+
categories: List[TaxonomyCategory] = Field(default_factory=list)
43+
44+
45+
@dataclass
46+
class Taxonomy:
47+
id: str
48+
description: str = ""
49+
categories: List[TaxonomyCategory] = field(default_factory=list)
50+
51+
52+
def load_taxonomy(taxonomy_file: Path) -> Taxonomy:
53+
with open(taxonomy_file, encoding="utf-8") as f:
54+
raw = yaml.safe_load(f)
55+
if "taxonomy" not in raw:
56+
raise ValueError(f"Missing 'taxonomy' key at root of {taxonomy_file}")
57+
data = TaxonomyData.model_validate(raw["taxonomy"])
58+
return Taxonomy(id=data.id, description=data.description, categories=data.categories)
59+
60+
61+
def check_library_against_taxonomy(library: LibrarySchema, taxonomy: Taxonomy) -> None:
62+
"""
63+
Validates that every model declaring a taxonomy_category:
64+
1. References a category that exists in the taxonomy.
65+
2. Exposes all port IDs listed in that taxonomy category.
66+
67+
Raises ValueError describing the first violation found.
68+
"""
69+
categories: Dict[str, TaxonomyCategory] = {c.id: c for c in taxonomy.categories}
70+
71+
for model_schema in library.models:
72+
cat_id = model_schema.taxonomy_category
73+
if cat_id is None:
74+
continue
75+
76+
if cat_id not in categories:
77+
raise ValueError(
78+
f"Model '{model_schema.id}' references taxonomy category '{cat_id}' "
79+
f"which does not exist in taxonomy '{taxonomy.id}'."
80+
)
81+
82+
category = categories[cat_id]
83+
model_port_ids = {p.id for p in model_schema.ports}
84+
missing = sorted({item.id for item in category.ports} - model_port_ids)
85+
if missing:
86+
raise ValueError(
87+
f"Model '{model_schema.id}' (taxonomy-category: '{cat_id}') is missing "
88+
f"port(s) required by the taxonomy: {missing}."
89+
)
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Copyright (c) 2026, RTE (https://www.rte-france.com)
2+
#
3+
# See AUTHORS.txt
4+
#
5+
# This Source Code Form is subject to the terms of the Mozilla Public
6+
# License, v. 2.0. If a copy of the MPL was not distributed with this
7+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
8+
#
9+
# SPDX-License-Identifier: MPL-2.0
10+
#
11+
# This file is part of the Antares project.
12+
13+
import io
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
from gems.model.parsing import parse_yaml_library
19+
from gems.model.taxonomy import (
20+
Taxonomy,
21+
TaxonomyCategory,
22+
TaxonomyItem,
23+
check_library_against_taxonomy,
24+
load_taxonomy,
25+
)
26+
27+
28+
def _make_taxonomy(*categories: TaxonomyCategory) -> Taxonomy:
29+
return Taxonomy(id="test_taxonomy", categories=list(categories))
30+
31+
32+
def _make_category(cat_id: str, port_ids: list[str]) -> TaxonomyCategory:
33+
return TaxonomyCategory(
34+
id=cat_id, ports=[TaxonomyItem(id=p) for p in port_ids]
35+
)
36+
37+
38+
def _parse_lib(yaml_content: str):
39+
return parse_yaml_library(io.StringIO(yaml_content))
40+
41+
42+
# --- valid cases ---
43+
44+
45+
def test_model_with_valid_taxonomy_category_and_matching_port() -> None:
46+
taxonomy = _make_taxonomy(_make_category("production", ["injection_port"]))
47+
lib = _parse_lib("""
48+
library:
49+
id: mylib
50+
port-types:
51+
- id: flow
52+
fields:
53+
- id: flow
54+
models:
55+
- id: generator
56+
taxonomy-category: production
57+
ports:
58+
- id: injection_port
59+
type: flow
60+
""")
61+
check_library_against_taxonomy(lib, taxonomy) # must not raise
62+
63+
64+
def test_model_with_extra_ports_beyond_taxonomy_is_valid() -> None:
65+
taxonomy = _make_taxonomy(_make_category("production", ["injection_port"]))
66+
lib = _parse_lib("""
67+
library:
68+
id: mylib
69+
port-types:
70+
- id: flow
71+
fields:
72+
- id: flow
73+
models:
74+
- id: generator
75+
taxonomy-category: production
76+
ports:
77+
- id: injection_port
78+
type: flow
79+
- id: emission_port
80+
type: flow
81+
""")
82+
check_library_against_taxonomy(lib, taxonomy) # extra port is allowed
83+
84+
85+
def test_model_without_taxonomy_category_is_skipped() -> None:
86+
taxonomy = _make_taxonomy()
87+
lib = _parse_lib("""
88+
library:
89+
id: mylib
90+
port-types:
91+
- id: flow
92+
fields:
93+
- id: flow
94+
models:
95+
- id: node
96+
ports:
97+
- id: injection_port
98+
type: flow
99+
""")
100+
check_library_against_taxonomy(lib, taxonomy) # must not raise
101+
102+
103+
def test_category_with_no_required_ports_always_passes() -> None:
104+
taxonomy = _make_taxonomy(_make_category("storage", []))
105+
lib = _parse_lib("""
106+
library:
107+
id: mylib
108+
models:
109+
- id: battery
110+
taxonomy-category: storage
111+
""")
112+
check_library_against_taxonomy(lib, taxonomy) # no ports required
113+
114+
115+
# --- error cases ---
116+
117+
118+
def test_unknown_taxonomy_category_raises() -> None:
119+
taxonomy = _make_taxonomy(_make_category("production", []))
120+
lib = _parse_lib("""
121+
library:
122+
id: mylib
123+
models:
124+
- id: bus
125+
taxonomy-category: balance
126+
""")
127+
with pytest.raises(ValueError, match="balance"):
128+
check_library_against_taxonomy(lib, taxonomy)
129+
130+
131+
def test_model_missing_required_taxonomy_port_raises() -> None:
132+
taxonomy = _make_taxonomy(
133+
_make_category("production", ["injection_port", "emission_port"])
134+
)
135+
lib = _parse_lib("""
136+
library:
137+
id: mylib
138+
port-types:
139+
- id: flow
140+
fields:
141+
- id: flow
142+
models:
143+
- id: generator
144+
taxonomy-category: production
145+
ports:
146+
- id: injection_port
147+
type: flow
148+
""")
149+
with pytest.raises(ValueError, match="emission_port"):
150+
check_library_against_taxonomy(lib, taxonomy)
151+
152+
153+
def test_error_message_includes_model_id_and_category() -> None:
154+
taxonomy = _make_taxonomy(_make_category("balance", ["balance_port"]))
155+
lib = _parse_lib("""
156+
library:
157+
id: mylib
158+
models:
159+
- id: my_bus
160+
taxonomy-category: balance
161+
""")
162+
with pytest.raises(ValueError, match="my_bus") as exc_info:
163+
check_library_against_taxonomy(lib, taxonomy)
164+
assert "balance" in str(exc_info.value)
165+
assert "balance_port" in str(exc_info.value)
166+
167+
168+
# --- load_taxonomy ---
169+
170+
171+
def test_load_taxonomy_from_yaml_file(tmp_path: Path) -> None:
172+
taxonomy_file = tmp_path / "taxonomy.yml"
173+
taxonomy_file.write_text("""
174+
taxonomy:
175+
id: my_taxonomy
176+
description: "Test taxonomy"
177+
categories:
178+
- id: production
179+
ports:
180+
- id: injection_port
181+
parameters:
182+
- id: p_max
183+
- id: balance
184+
ports:
185+
- id: balance_port
186+
- id: storage
187+
""")
188+
taxonomy = load_taxonomy(taxonomy_file)
189+
190+
assert taxonomy.id == "my_taxonomy"
191+
assert taxonomy.description == "Test taxonomy"
192+
assert len(taxonomy.categories) == 3
193+
194+
production = next(c for c in taxonomy.categories if c.id == "production")
195+
assert [p.id for p in production.ports] == ["injection_port"]
196+
197+
balance = next(c for c in taxonomy.categories if c.id == "balance")
198+
assert [p.id for p in balance.ports] == ["balance_port"]
199+
200+
storage = next(c for c in taxonomy.categories if c.id == "storage")
201+
assert storage.ports == []
202+
203+
204+
def test_load_taxonomy_missing_root_key_raises(tmp_path: Path) -> None:
205+
bad_file = tmp_path / "bad.yml"
206+
bad_file.write_text("categories:\n - id: foo\n")
207+
with pytest.raises(ValueError, match="taxonomy"):
208+
load_taxonomy(bad_file)
209+
210+
211+
def test_load_and_check_roundtrip(tmp_path: Path) -> None:
212+
taxonomy_file = tmp_path / "taxonomy.yml"
213+
taxonomy_file.write_text("""
214+
taxonomy:
215+
id: antares_taxonomy
216+
categories:
217+
- id: production
218+
ports:
219+
- id: balance_port
220+
- id: consumption
221+
ports:
222+
- id: balance_port
223+
""")
224+
taxonomy = load_taxonomy(taxonomy_file)
225+
lib = _parse_lib("""
226+
library:
227+
id: antares
228+
port-types:
229+
- id: flow
230+
fields:
231+
- id: flow
232+
models:
233+
- id: generator
234+
taxonomy-category: production
235+
ports:
236+
- id: balance_port
237+
type: flow
238+
- id: load
239+
taxonomy-category: consumption
240+
ports:
241+
- id: balance_port
242+
type: flow
243+
""")
244+
check_library_against_taxonomy(lib, taxonomy) # must not raise

0 commit comments

Comments
 (0)