Skip to content

Commit 94aa8e7

Browse files
committed
Allow exclude with playlist-file; update docstrings throughout
exclude is no longer restricted to the inline include form — it now applies to both base-set forms (include and playlist-file). The exclude logic was lifted out of _resolve_inline (now removed) into _compute_scenario_ids so it runs uniformly after the base set is built. - _check_constraints: 'exclude' now requires include OR playlist-file - _load_playlist: returns Set[int] instead of List[int]; sorting deferred to _compute_scenario_ids after exclude is applied - _compute_scenario_ids: single unified method handles both forms + exclude - Warning message updated: 'include set' → 'base set' Docstrings updated: - ScenarioScopeConfig: full class docstring documenting both forms, exclude compatibility, caching behaviour, and index convention - _expand_entries: clarified entry formats and boolean handling note - validate_optim_config: enumerated bullet-point list of all checks - ScenarioBuilder.validate_mc_scenarios: documents return contract Four new tests cover playlist-file + exclude (basic, range, all-excluded, orphan warning). Updated test for the relaxed exclude-without-base error. https://claude.ai/code/session_01EfjU9tBfdyZ9nZmQFCe5uU
1 parent 0ea0cb4 commit 94aa8e7

5 files changed

Lines changed: 144 additions & 39 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ scenario-scope:
3737

3838
Other changes:
3939

40+
- `exclude` is now compatible with both `include` and `playlist-file`.
41+
Use it to subtract a few scenarios at run time without modifying the
42+
playlist file.
4043
- `validate_optim_config()` now accepts an optional `scenario_builder`
4144
argument and cross-checks all playlist indices against every scenario group,
4245
raising a `ValueError` for out-of-bounds indices.

docs/user-guide/optim-config.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ The total number of timesteps solved is `last-time-step − first-time-step + 1`
7070
Selects which Monte-Carlo scenarios to simulate. Indices are **0-based**,
7171
consistent with the `modeler-scenariobuilder.dat` file convention.
7272

73-
Two mutually exclusive forms are supported:
73+
The base scenario set is defined by exactly one of two mutually exclusive
74+
keys: `include` (inline) or `playlist-file` (from a JSON file). `exclude`
75+
is optional and applies to **either** form.
7476

7577
### Inline form (`include` / `exclude`)
7678

@@ -80,7 +82,7 @@ integers, and inclusive `"a-b"` range strings.
8082
| Key | Type | Default | Description |
8183
|---|---|---|---|
8284
| `include` | list | — | Scenarios to run (required in inline form) |
83-
| `exclude` | list | — | Scenarios to remove from the include set (optional) |
85+
| `exclude` | list | — | Scenarios to remove from the base set (optional) |
8486

8587
Each entry in `include` or `exclude` may be:
8688

@@ -115,9 +117,9 @@ scenario-scope:
115117

116118
- All indices must be ≥ 0.
117119
- Overlapping entries in `include` are deduplicated automatically.
118-
- Excludes that do not appear in `include` produce a warning and have no effect.
120+
- Excludes that do not appear in the base set produce a warning and have no effect.
119121
- Output is always sorted in ascending order.
120-
- `exclude` cannot be used without `include`.
122+
- `exclude` cannot be used without `include` or `playlist-file`.
121123

122124
**Default behaviour** (no `scenario-scope` key at all, or an empty block):
123125
runs scenario 0 only.
@@ -126,7 +128,7 @@ runs scenario 0 only.
126128

127129
### Playlist-file form (`playlist-file`)
128130

129-
Point to a JSON file containing a flat array of 1-based integer scenario
131+
Point to a JSON file containing a flat array of 0-based integer scenario
130132
indices. Useful when the list of scenarios is generated programmatically or
131133
is too large to embed in YAML.
132134

@@ -139,11 +141,21 @@ scenario-scope:
139141
playlist-file: mc_playlist.json # resolved relative to optim-config.yml
140142
~~~
141143

142-
The referenced file must contain a flat JSON array of positive integers, for
143-
example:
144+
The referenced file must contain a flat JSON array of non-negative integers:
144145

145146
~~~ json
146-
[1, 3, 5, 7, 9, 11, 13]
147+
[0, 2, 4, 6, 8, 10, 12]
148+
~~~
149+
150+
`exclude` can be combined with `playlist-file` to subtract specific scenarios
151+
at run time without modifying the file:
152+
153+
~~~ yaml
154+
scenario-scope:
155+
playlist-file: mc_playlist.json
156+
exclude:
157+
- 4
158+
- "8-10"
147159
~~~
148160

149161
GemsPy reads and validates the playlist eagerly when `load_optim_config()` is
@@ -154,7 +166,7 @@ called, so any I/O or format errors surface immediately at load time.
154166
- The file must be a flat JSON array of integers (no booleans, strings, or objects).
155167
- All indices must be ≥ 0.
156168
- Duplicates are silently removed; the result is sorted ascending.
157-
- `include` / `exclude` and `playlist-file` are mutually exclusive.
169+
- `include` and `playlist-file` are mutually exclusive.
158170

159171
---
160172

src/gems/optim_config/parsing.py

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,16 @@ def parsed_parameters(self) -> Dict[str, Any]:
136136

137137

138138
def _expand_entries(entries: List[Union[int, str]]) -> Set[int]:
139-
"""Expand 0-based ints and inclusive 'a-b' range strings into a set of 0-based indices."""
139+
"""Expand a list of scenario specifiers into a set of 0-based indices.
140+
141+
Each entry may be:
142+
- a non-negative integer (e.g. ``5``),
143+
- a string integer (e.g. ``"5"``), or
144+
- an inclusive range string (e.g. ``"0-9"``).
145+
146+
Booleans are rejected upstream by the Pydantic field validator and will
147+
never reach this function.
148+
"""
140149
result: Set[int] = set()
141150
for entry in entries:
142151
if isinstance(entry, int):
@@ -163,6 +172,33 @@ def _expand_entries(entries: List[Union[int, str]]) -> Set[int]:
163172

164173

165174
class ScenarioScopeConfig(ModifiedBaseModel):
175+
"""Declares which Monte-Carlo scenarios to simulate.
176+
177+
Two mutually exclusive ways to define the base scenario set:
178+
179+
- **Inline** (``include``): a list of 0-based integers, string-integers,
180+
and/or ``"a-b"`` range strings.
181+
- **File** (``playlist_file``): path to a flat JSON array of 0-based
182+
integers, resolved relative to ``optim-config.yml`` by
183+
``load_optim_config()``.
184+
185+
``exclude`` is optional and compatible with *both* forms. It subtracts a
186+
set of scenarios from the base set using the same entry format. Entries
187+
in ``exclude`` that are not in the base set are silently ignored (a
188+
``UserWarning`` is emitted).
189+
190+
``include`` and ``playlist_file`` are mutually exclusive.
191+
``exclude`` without any base set raises ``ValueError``.
192+
193+
All indices are 0-based, consistent with
194+
``modeler-scenariobuilder.dat``.
195+
196+
The resolved list is computed lazily on first access to
197+
``scenario_ids`` and cached for the lifetime of the object.
198+
``load_optim_config()`` triggers eager resolution so that file I/O
199+
errors surface at load time.
200+
"""
201+
166202
include: Optional[List[Union[int, str]]] = None
167203
exclude: Optional[List[Union[int, str]]] = None
168204
playlist_file: Optional[Path] = None
@@ -186,8 +222,8 @@ def _check_constraints(self) -> "ScenarioScopeConfig":
186222
has_file = self.playlist_file is not None
187223
if has_inline and has_file:
188224
raise ValueError("'include' and 'playlist-file' are mutually exclusive")
189-
if self.exclude is not None and not has_inline:
190-
raise ValueError("'exclude' requires 'include'")
225+
if self.exclude is not None and not has_inline and not has_file:
226+
raise ValueError("'exclude' requires 'include' or 'playlist-file'")
191227
return self
192228

193229
@property
@@ -198,12 +234,27 @@ def scenario_ids(self) -> List[int]:
198234

199235
def _compute_scenario_ids(self) -> List[int]:
200236
if self.playlist_file is not None:
201-
return self._load_playlist()
202-
if self.include is None:
237+
included = self._load_playlist()
238+
elif self.include is not None:
239+
included = _expand_entries(self.include)
240+
else:
203241
return [0]
204-
return self._resolve_inline()
205242

206-
def _load_playlist(self) -> List[int]:
243+
if self.exclude is not None:
244+
excluded = _expand_entries(self.exclude)
245+
orphans = excluded - included
246+
if orphans:
247+
warnings.warn(
248+
f"Excluded scenario indices {sorted(orphans)} "
249+
"are not in the base set and have no effect",
250+
UserWarning,
251+
stacklevel=2,
252+
)
253+
included -= excluded
254+
255+
return sorted(included)
256+
257+
def _load_playlist(self) -> Set[int]:
207258
try:
208259
with self.playlist_file.open() as f: # type: ignore[union-attr]
209260
data = json.load(f)
@@ -223,20 +274,7 @@ def _load_playlist(self) -> List[int]:
223274
raise ValueError(
224275
f"'{self.playlist_file}': all scenario indices must be >= 0"
225276
)
226-
return sorted(set(data))
227-
228-
def _resolve_inline(self) -> List[int]:
229-
included = _expand_entries(self.include or [])
230-
excluded = _expand_entries(self.exclude or [])
231-
orphans = excluded - included
232-
if orphans:
233-
warnings.warn(
234-
f"Excluded scenario indices {sorted(orphans)} "
235-
"are not in the include set and have no effect",
236-
UserWarning,
237-
stacklevel=2,
238-
)
239-
return sorted(included - excluded)
277+
return set(data)
240278

241279

242280
class OptimConfig(ModifiedBaseModel):
@@ -416,12 +454,17 @@ def validate_optim_config(
416454
) -> None:
417455
"""Cross-validate optim-config entries against the resolved system.
418456
419-
Checks that every referenced ID exists, that master variables do not
420-
depend on time, and that master constraints and objectives only reference
421-
variables assigned to master or master-and-subproblems.
422-
When scenario_builder is provided, also verifies that all scenario indices
423-
from the playlist are defined in every scenario group.
424-
Raises ValueError listing all violations.
457+
Performs the following checks:
458+
459+
- Every model ID referenced in ``config.models`` exists in the system.
460+
- Master variables are time-independent.
461+
- Master constraints and objective contributions only reference variables
462+
assigned to ``master`` or ``master-and-subproblems``.
463+
- If ``scenario_builder`` is provided, every scenario index in
464+
``config.scenario_scope.scenario_ids`` is defined for every scenario
465+
group in the builder.
466+
467+
Raises ``ValueError`` listing all violations found.
425468
"""
426469
models_in_system = {c.model.id: c.model for c in system.all_components}
427470
errors: List[str] = []

src/gems/study/scenario_builder.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,14 @@ def resolve_vectorized(
6868
return arr[mc_scenarios]
6969

7070
def validate_mc_scenarios(self, scenario_ids: List[int]) -> List[str]:
71-
"""Return error messages for scenario indices not defined in any group."""
71+
"""Return error messages for scenario indices out of range in any group.
72+
73+
For each scenario group, checks that every index in ``scenario_ids``
74+
is within ``[0, len(group_array) - 1]``. Returns a list of
75+
human-readable error strings (one per offending group); an empty list
76+
means all indices are valid. Does not raise — callers collect errors
77+
and raise at the end.
78+
"""
7279
errors = []
7380
for group, arr in self._group_arrays.items():
7481
out_of_bounds = [idx for idx in scenario_ids if idx >= len(arr)]

tests/unittests/optim_config/test_scenario_scope_config.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,51 @@ def test_exclude_orphan_raises_warning() -> None:
8888
assert "4" in str(caught[0].message)
8989

9090

91-
def test_exclude_without_include_raises() -> None:
92-
with pytest.raises(ValueError, match="'exclude' requires 'include'"):
91+
def test_exclude_without_any_base_raises() -> None:
92+
with pytest.raises(
93+
ValueError, match="'exclude' requires 'include' or 'playlist-file'"
94+
):
9395
ScenarioScopeConfig(exclude=[0])
9496

9597

98+
# ---------------------------------------------------------------------------
99+
# playlist-file + exclude
100+
# ---------------------------------------------------------------------------
101+
102+
103+
def test_playlist_file_with_exclude(tmp_path: Path) -> None:
104+
playlist = tmp_path / "playlist.json"
105+
playlist.write_text(json.dumps([0, 1, 2, 3, 4]))
106+
cfg = ScenarioScopeConfig(playlist_file=playlist, exclude=[2, 4])
107+
assert cfg.scenario_ids == [0, 1, 3]
108+
109+
110+
def test_playlist_file_with_exclude_range(tmp_path: Path) -> None:
111+
playlist = tmp_path / "playlist.json"
112+
playlist.write_text(json.dumps([0, 1, 2, 3, 4, 5, 6]))
113+
cfg = ScenarioScopeConfig(playlist_file=playlist, exclude=["2-4"])
114+
assert cfg.scenario_ids == [0, 1, 5, 6]
115+
116+
117+
def test_playlist_file_with_exclude_all_leaves_empty(tmp_path: Path) -> None:
118+
playlist = tmp_path / "playlist.json"
119+
playlist.write_text(json.dumps([0, 1, 2]))
120+
cfg = ScenarioScopeConfig(playlist_file=playlist, exclude=["0-2"])
121+
assert cfg.scenario_ids == []
122+
123+
124+
def test_playlist_file_with_exclude_orphan_warns(tmp_path: Path) -> None:
125+
playlist = tmp_path / "playlist.json"
126+
playlist.write_text(json.dumps([0, 1, 2]))
127+
cfg = ScenarioScopeConfig(playlist_file=playlist, exclude=[5])
128+
with warnings.catch_warnings(record=True) as caught:
129+
warnings.simplefilter("always")
130+
result = cfg.scenario_ids
131+
assert result == [0, 1, 2]
132+
assert len(caught) == 1
133+
assert "5" in str(caught[0].message)
134+
135+
96136
# ---------------------------------------------------------------------------
97137
# Inline form — validation errors
98138
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)