Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ All notable changes to GemsPy are documented here.
what a heuristic reads/writes via `models[].heuristics` in
`optim-config.yml`.

### Fixed
- **Parameter time/scenario dependency is validated when the system is
resolved** (#258) - a component parameter may declare the same dependency as
its model or narrow it (`true` -> `false` on either axis), never extend it.
- **Clearer data-shape errors** - the "exactly one column/row" and "float value
is expected" errors now name the offending component and parameter.

## [0.1.3] - 2026-07-24

### Added
Expand Down
3 changes: 1 addition & 2 deletions docs/user-guide/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
GemsPy supports two loading strategies: a directory-based approach that reads
an entire study at once, and a file-by-file approach for programmatic control.

---

## Directory-based loading (recommended)

When your study follows the standard directory layout, use `load_study()`:
Expand Down Expand Up @@ -67,6 +65,7 @@ database = build_data_base(input_system, Path(series_dir))
`build_data_base()` reads all timeseries files referenced by the system
(`.txt` or `.csv`) from `series_dir`.


### Assembling a Study

Once you have `system` and `database`, wrap them in a `Study`:
Expand Down
58 changes: 36 additions & 22 deletions src/gems_craft/study/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import numpy as np
import pandas as pd

from gems_craft.expression.indexing_structure import IndexingStructure

if TYPE_CHECKING:
from gems_craft.study.scenario_builder import ScenarioBuilder

Expand Down Expand Up @@ -48,9 +50,17 @@ def get_value(
raise NotImplementedError()

@abstractmethod
def indexing_structure(self) -> "IndexingStructure":
"""Axes along which this data actually varies."""

def check_requirement(self, time: bool, scenario: bool) -> bool:
"""Check if the data structure meets certain requirements."""
pass
"""Whether this data may be used for a parameter declared ``(time, scenario)``.

Data may vary along fewer axes than the parameter declares — it is then
broadcast over the missing ones — but never along more.
"""
own = self.indexing_structure()
return (time or not own.time) and (scenario or not own.scenario)


@dataclass(frozen=True)
Expand All @@ -64,10 +74,8 @@ def get_value(
) -> float:
return self.value

def check_requirement(self, time: bool, scenario: bool) -> bool:
if not isinstance(self, ConstantData):
raise ValueError("Invalid data type for ConstantData")
return True
def indexing_structure(self) -> "IndexingStructure":
return IndexingStructure(time=False, scenario=False)


@dataclass(frozen=True)
Expand All @@ -90,10 +98,8 @@ def get_value(
)
return result

def check_requirement(self, time: bool, scenario: bool) -> bool:
if not isinstance(self, TimeSeriesData):
raise ValueError("Invalid data type for TimeSeriesData")
return time
def indexing_structure(self) -> "IndexingStructure":
return IndexingStructure(time=True, scenario=False)


@dataclass(frozen=True)
Expand All @@ -119,10 +125,8 @@ def get_value(
)
return result

def check_requirement(self, time: bool, scenario: bool) -> bool:
if not isinstance(self, ScenarioSeriesData):
raise ValueError("Invalid data type for ScenarioSeriesData")
return scenario
def indexing_structure(self) -> "IndexingStructure":
return IndexingStructure(time=False, scenario=True)


@dataclass(frozen=True)
Expand All @@ -142,10 +146,8 @@ def get_value(
raise KeyError("Time scenario data requires a scenario index.")
return self.time_scenario_series.values[np.ix_(np.asarray(timestep), scenario)]

def check_requirement(self, time: bool, scenario: bool) -> bool:
if not isinstance(self, TimeScenarioSeriesData):
raise ValueError("Invalid data type for TimeScenarioSeriesData")
return time and scenario
def indexing_structure(self) -> "IndexingStructure":
return IndexingStructure(time=True, scenario=True)


def load_ts_from_file(
Expand Down Expand Up @@ -178,19 +180,31 @@ def load_ts_from_file(
)


def dataframe_to_time_series(ts_dataframe: pd.DataFrame) -> pd.Series:
def dataframe_to_time_series(
ts_dataframe: pd.DataFrame, context: str = ""
) -> pd.Series:
if ts_dataframe.shape[1] != 1:
raise ValueError(
f"Could not convert input data to time series data. Expect data series with exactly one column, got shape {ts_dataframe.shape}"
f"Could not convert input data to time series data{context}. "
f"A time-dependent, scenario-independent parameter expects exactly one "
f"column (one timeseries), got shape {ts_dataframe.shape}. "
f"To use several timeseries, declare the parameter scenario-dependent "
f"(the model must declare it scenario-dependent too)."
)
return ts_dataframe.iloc[:, 0]


def dataframe_to_scenario_series(ts_dataframe: pd.DataFrame) -> np.ndarray:
def dataframe_to_scenario_series(
ts_dataframe: pd.DataFrame, context: str = ""
) -> np.ndarray:
"""Return a 1-D numpy array of floats indexed by 0-based column index."""
if ts_dataframe.shape[0] != 1:
raise ValueError(
f"Could not convert input data to scenario series data. Expect data series with exactly one line, got shape {ts_dataframe.shape}"
f"Could not convert input data to scenario series data{context}. "
f"A scenario-dependent, time-independent parameter expects exactly one "
f"row, got shape {ts_dataframe.shape}. "
f"To use time-varying data, declare the parameter time-dependent "
f"(the model must declare it time-dependent too)."
)
return ts_dataframe.iloc[0, :].to_numpy(dtype=float)

Expand Down
58 changes: 54 additions & 4 deletions src/gems_craft/study/resolve_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ def _resolve_component(
f"{missing_params}."
)

_check_parameter_dependencies(component, model)

properties = _resolve_properties_raw_to_dict(component.properties, component.id)
missing = sorted(k for k in model.properties if k not in properties)
if missing:
Expand All @@ -113,6 +115,49 @@ def _resolve_component(
)


def _format_dependency(time: bool, scenario: bool) -> str:
return f"time-dependent: {str(time).lower()}, scenario-dependent: {str(scenario).lower()}"


def _check_parameter_dependencies(component: ComponentSchema, model: Model) -> None:
"""Check that each parameter's dependency does not exceed the model's.

A component may narrow what the model declares — supplying a constant for a
time-dependent parameter, or a single timeseries shared by every scenario for
a scenario-dependent one — but it may not add an axis the model does not
declare, since the model's expressions are written against that structure.

Parameters the model does not declare carry no dependency constraint and are
skipped; the missing-parameter check in :func:`_resolve_component` already
catches misspelled ids.
"""
for param in component.parameters or []:
declared = model.parameters.get(param.id)
if declared is None:
continue

extra_axes = [
axis
for axis, in_system, in_model in (
("time", param.time_dependent, declared.structure.time),
("scenario", param.scenario_dependent, declared.structure.scenario),
)
if in_system and not in_model
]
if extra_axes:
raise ValueError(
f"Component {component.id!r} (model {model.id!r}), parameter "
f"{param.id!r}: cannot declare the parameter "
f"{' and '.join(f'{a}-dependent' for a in extra_axes)} because the "
f"model does not. The model declares "
f"[{_format_dependency(declared.structure.time, declared.structure.scenario)}], "
f"the system declares "
f"[{_format_dependency(param.time_dependent, param.scenario_dependent)}]. "
f"A component may only narrow the model's declared dependency, "
f"never extend it."
)


def _resolve_port_refs(
connection: PortConnectionsSchema,
all_components: List[Component],
Expand Down Expand Up @@ -169,6 +214,7 @@ def build_data_base(
param.scenario_dependent,
param.value,
timeseries_dir,
context=f" for component {comp.id!r}, parameter {param.id!r}",
)
database.add_data(comp.id, param.id, param_value, scenario_group=group)

Expand All @@ -180,22 +226,26 @@ def _build_data(
scenario_dependent: bool,
param_value: Union[float, str],
timeseries_dir: Optional[Path],
context: str = "",
) -> AbstractDataStructure:
if isinstance(param_value, str):
ts_data = load_ts_from_file(param_value, timeseries_dir)
if time_dependent and scenario_dependent:
return TimeScenarioSeriesData(ts_data)
elif time_dependent:
return TimeSeriesData(dataframe_to_time_series(ts_data))
return TimeSeriesData(dataframe_to_time_series(ts_data, context))
elif scenario_dependent:
return ScenarioSeriesData(dataframe_to_scenario_series(ts_data))
return ScenarioSeriesData(dataframe_to_scenario_series(ts_data, context))
else:
raise ValueError(
f"A float value is expected for constant data, got {param_value}"
f"A float value is expected for constant data{context}, "
f"got the timeseries name {param_value!r}. Declare the parameter "
f"time-dependent and/or scenario-dependent to read it from a file."
)
else:
if time_dependent or scenario_dependent:
raise ValueError(
f"A timeseries name is expected for time or scenario dependent data, got {param_value}"
f"A timeseries name is expected for time or scenario dependent "
f"data{context}, got {param_value}."
)
return ConstantData(float(param_value))
16 changes: 13 additions & 3 deletions src/gems_craft/study/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
from gems_craft.study.system import Component, System


def _format_axes(time: bool, scenario: bool) -> str:
axes = [name for name, on in (("time", time), ("scenario", scenario)) if on]
return " and ".join(axes) if axes else "no axis"


@dataclass
class Study:
"""
Expand Down Expand Up @@ -69,12 +74,17 @@ def check_consistency(self) -> None:
for component in self.system.components:
for param in component.model.parameters.values():
data_structure = self.database.get_data(component.id, param.name)
declared = param.structure

if not data_structure.check_requirement(
component.model.parameters[param.name].structure.time,
component.model.parameters[param.name].structure.scenario,
declared.time, declared.scenario
):
actual = data_structure.indexing_structure()
raise ValueError(
f"Data inconsistency for component: {component.id}, "
f"parameter: {param.name}. Requirement not met."
f"parameter: {param.name}. The data varies along "
f"[{_format_axes(actual.time, actual.scenario)}] but the model "
f"{component.model.id!r} declares the parameter "
f"[{_format_axes(declared.time, declared.scenario)}]. Data may "
f"vary along fewer axes than the model declares, never more."
)
Loading
Loading