Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3b4b9bd
feat(model): allow overwriting scenario for run()
nielsvharten-tennet Jul 20, 2026
0853190
fix(model): remove check_if_daily_update_due and add test for reusing…
nielsvharten-tennet Jul 21, 2026
be31d40
fix(model): update soil_resistivity in one step to improve efficiency…
nielsvharten-tennet Jul 24, 2026
2181881
fix(iec_60287_parameter_extractor): make scenario for more stable
nielsvharten-tennet Jul 29, 2026
387d9c6
fix(model): make scenario last parameter of run() to ensure backward …
nielsvharten-tennet Jul 29, 2026
412b923
Merge remote-tracking branch 'origin/main' into feature/provide-scena…
nielsvharten-tennet Jul 29, 2026
5dc0554
perf(model/cable): update soil resistivity only once per step
nielsvharten-tennet Jul 29, 2026
a9f54ad
fix: move ownership of scenario from Model to run()
nielsvharten-tennet Jul 29, 2026
42f974f
feat(model): let run() accept unvalidated scenario (as scenario is va…
nielsvharten-tennet Jul 29, 2026
bd6c284
docs: update documentation after changing membership of scenario
nielsvharten-tennet Jul 29, 2026
20a7f79
refactor: improve scenario validation
BramVloedgraven Jul 31, 2026
7463223
Merge remote-tracking branch 'niels/feature/provide-scenario-for-run'…
BramVloedgraven Jul 31, 2026
6ce8862
refactor: remove redundant argument from pa.Column
BramVloedgraven Jul 31, 2026
180ac2e
refactor: update example notebooks
BramVloedgraven Jul 31, 2026
e3cf1d1
fix(model): process comments from PR and make extra_solution_layers a…
nielsvharten-tennet Jul 31, 2026
091ff9f
Merge remote-tracking branch 'niels/feature/provide-scenario-for-run'…
BramVloedgraven Jul 31, 2026
d56dc1b
refactor: allow for integers in scenario using coerce option
BramVloedgraven Jul 31, 2026
3639dc5
refactor: use the validated DataFrame[ScenarioModelT] in _compute_tem…
BramVloedgraven Jul 31, 2026
67f655f
refactor: add testing for valid scenario
BramVloedgraven Jul 31, 2026
2ba73cc
Merge pull request #1 from alliander-opensource/refactor/improve-data…
nielsvharten-tennet Jul 31, 2026
2da8746
fix(model): only pass scenario_index to _build_temperature_result_dat…
nielsvharten-tennet Jul 31, 2026
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
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ repos:

- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.16.1
hooks:
# Run the linter.
- id: ruff
Expand Down Expand Up @@ -58,7 +58,7 @@ repos:

- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.11.32
rev: 0.12.0
hooks:
- id: uv-lock

Expand Down
48 changes: 12 additions & 36 deletions cable_thermal_model/model/abstract_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,20 @@ class AbstractModel(ABC, Generic[ModelRunOptionsT, StateT, ScenarioSchemaT, Stat
"""Abstract base class for thermal cable models."""

static_env: StaticEnvT
scenario: DataFrame[ScenarioSchemaT]
_scenario_schema_class: type[ScenarioSchemaT]

def __str__(self):
"""Generates a concise string representation of the model."""
num_circuits = len(self.static_env.circuits)
num_days = (self.scenario.index[-1] - self.scenario.index[0]).days
num_days = round(num_days, 1) if num_days < 7 else int(num_days) # round for readability # noqa: PLR2004
return f"Model with {num_circuits} circuit environment and {num_days} day scenario"
return f"Model with {num_circuits} circuit environment"

def __repr__(self):
"""Generates an informative string representation of the model."""
return (
"Environment\n\n"
+ f"{tab_lines(repr(self.static_env))}\n"
+ "\n\tScenario\n"
+ f"{tab_lines(tab_lines(repr(self.scenario.describe())))}\n"
)
return "Environment\n\n" + f"{tab_lines(repr(self.static_env))}\n"

def __init__(self, static_env: StaticEnvT, scenario: DataFrame[ScenarioSchemaT]):
"""Initialise the model with a static environment and scenario DataFrame."""
# Validate that the scenario dataframe provides the required cable loads and ambient temperature.
def __init__(self, static_env: StaticEnvT):
"""Initialise the model with a static environment."""
self.static_env = static_env
self.set_scenario(scenario=scenario)
self._set_run_options(run_options=None)

def _validate_scenario(self, scenario: pd.DataFrame) -> DataFrame[ScenarioSchemaT]:
Expand All @@ -63,31 +53,13 @@ def _validate_scenario(self, scenario: pd.DataFrame) -> DataFrame[ScenarioSchema

return self._scenario_schema_class.validate(scenario)

def set_scenario(self, scenario: pd.DataFrame):
"""Sets a new scenario and validates it.

Args:
scenario: The new scenario dataframe

"""
self.scenario = self._validate_scenario(scenario=scenario)

# Set up time grids
self.time_max: float = (self.scenario.index[-1] - self.scenario.index[0]).total_seconds()
self.time_grid: list[float] = list((self.scenario.index - self.scenario.index[0]).total_seconds())
self.time_samples: int = len(self.time_grid)

@property
def n_scenario_rows(self) -> int:
"""Returns the number of time steps in the scenario."""
return len(self.scenario.index)

def run(
self,
scenario: pd.DataFrame,
initial_state: StateT | None = None,
run_options: ModelRunOptionsT | dict | None = None,
) -> ModelOutputSchema[StateT]:
"""Computes the temperature solutions for all cable objects.
"""Computes the temperature solutions for all cable objects in the model for the given scenario.

Notes:
Be careful about changing default run option values. The following settings affect the
Expand All @@ -99,9 +71,10 @@ def run(
- initial_state

Args:
scenario: Scenario dataframe for this run. The dataframe is validated internally before execution.
initial_state: Heating information from a previous computation.
run_options: Run options for the model. If `None` or a dictionary is provided, the
options are validated and default values are applied.
run_options: Run options for this simulation run. If `None` or a dictionary is provided,
the options are validated and default values are applied.

Returns:
ModelOutputSchema: Temperature solutions for all cables.
Expand All @@ -110,12 +83,14 @@ def run(
ValueError: If the provided initial state does not match the model environment.

"""
validated_scenario = self._validate_scenario(scenario=scenario)
self._set_run_options(run_options=run_options)

self._validate_initial_state(initial_state=initial_state)

# Compute the temperature solution.
result = self._compute_temperature_solution(
scenario=validated_scenario,
initial_state=initial_state,
)

Expand All @@ -128,6 +103,7 @@ def _set_run_options(self, run_options: ModelRunOptionsT | dict | None) -> None:
@abstractmethod
def _compute_temperature_solution(
self,
scenario: DataFrame[ScenarioSchemaT],
initial_state: StateT | None = None,
) -> ModelOutputSchema[StateT]:
"""Compute and return the full temperature solution for the configured scenario."""
Expand Down
24 changes: 10 additions & 14 deletions cable_thermal_model/model/cables/cable.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def update_pipe_fill_resistivity(self, temperature_grid: np.ndarray) -> None:
self._update_rho_grid(
start_index=pipe_fill_start_index,
end_index=pipe_fill_end_index,
rho=new_pipe_fill_rho,
rho_values=new_pipe_fill_rho,
)

def get_layer_indices_for_layer(self, layer: CableLayer) -> tuple[int, int]:
Expand Down Expand Up @@ -446,27 +446,27 @@ def _calculate_inter_rhos(self, radii: np.ndarray, inter_radii: np.ndarray, rhos
radii[1:] / radii[:-1]
)

def _update_rho_grid(self, start_index: int, end_index: int, rho: float) -> None:
"""Update a slice of the rho-grid with a new value if significant change is detected.
def _update_rho_grid(self, start_index: int, end_index: int, rho_values: np.ndarray | float) -> None:
"""Update a slice of the rho-grid if any new value differs by more than 1% from the current value.

Args:
start_index (int): The starting index of the slice to update (inclusive).
end_index (int): The ending index of the slice to update (inclusive).
rho (float): The new resistivity value to set for the specified slice.
rho_values (np.ndarray | float): The new resistivity value(s) to set for the specified slice.

"""
if start_index > end_index:
raise ValueError("The start_index exceeds the end_index. Cannot update the rho grid.")

rho_slice = self._rho_grid[start_index : end_index + 1]
if np.all(np.isclose(rho_slice, rho, rtol=1e-2)):
return
old_rho_values = self._rho_grid[start_index : end_index + 1]
rho_values_changed = not np.all(np.isclose(old_rho_values, rho_values, rtol=1e-2))

self._rho_grid[start_index : end_index + 1] = rho
self._invalidate_finite_difference_matrix_diagonals()
if rho_values_changed:
self._rho_grid[start_index : end_index + 1] = rho_values
self._invalidate_finite_difference_matrix_diagonals()

def _update_capacity_grid(self, start_index: int, end_index: int, capacity: float) -> None:
"""Update a slice of the capacity-grid with a new value if significant change is detected.
"""Update a slice of the capacity-grid with a new value.

Args:
start_index (int): The starting index of the slice to update (inclusive).
Expand All @@ -477,10 +477,6 @@ def _update_capacity_grid(self, start_index: int, end_index: int, capacity: floa
if start_index > end_index:
raise ValueError("The start_index exceeds the end_index. Cannot update the capacity grid.")

capacity_slice = self._capacity_grid[start_index : end_index + 1]
if np.all(np.isclose(capacity_slice, capacity, rtol=1e-2)):
return

self._capacity_grid[start_index : end_index + 1] = capacity

def _update_vector_with_heat_generation_for_layer(self, heat_generation: float, layer: CableLayer) -> None:
Expand Down
44 changes: 27 additions & 17 deletions cable_thermal_model/model/cables/cable_soil.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,30 +105,40 @@ def _update_soil_resistivity(self, soil_rho: float, dry_soil_radius: float | Non

Args:
soil_rho (float):
An optional float representing the thermal resistivity of the soil that is not dried out.
The new thermal resistivity of the soil that is not dried out.
dry_soil_radius (float | None):
A float representing the radius of the dried-out soil around the cable.
The radius of the dried-out soil around the cable. If None, no dried-out soil is assumed.
All grid points with radius <= dry_soil_radius are treated as dry.

"""
start_index = self._get_soil_grid_start_index()
soil_start_index = self._get_soil_grid_start_index()
new_rho_values = np.full(self.grid_size - soil_start_index, soil_rho)

if dry_soil_radius is not None:
dry_soil_end_index = int((self._radii_grid <= dry_soil_radius).sum()) - 1
if dry_soil_end_index >= soil_start_index:
dry_soil_rho = 2.5 # mK/W, value taken from NPR3626
new_rho_values[: dry_soil_end_index - soil_start_index + 1] = dry_soil_rho

self._update_rho_grid(
start_index=start_index,
start_index=soil_start_index,
end_index=self.grid_size - 1,
rho=soil_rho,
rho_values=new_rho_values,
)

if dry_soil_radius is not None:
dry_soil_rho = 2.5 # mK/W, value taken from NPR3626
end_index = int((self._radii_grid <= dry_soil_radius).sum()) - 1
if end_index > start_index:
self._update_rho_grid(
start_index=start_index,
end_index=end_index,
rho=dry_soil_rho,
)

def _get_dry_soil_radius(self, temperature_grid: np.ndarray, soil_drying: bool) -> float | None:
"""Return the radius of dried-out soil based on temperature and scenario settings."""
"""Return the dried-out soil radius from the current temperature grid.

When soil drying is enabled, all grid points with temperature >= ``_SOIL_DRYING_TEMPERATURE``
are considered dried out and the radius of the outermost such grid point is returned, if any.

Args:
temperature_grid (np.ndarray): The temperature grid for the cable, as calculated for a given timestep.
soil_drying (bool): Whether the scenario takes soil drying into account.

Returns:
float | None: Radius of dried-out soil, or None if drying is disabled or absent.
"""
if not soil_drying:
return None

Expand Down Expand Up @@ -163,7 +173,7 @@ def from_cable_with_added_soil_layer(
soil_radius: float,
logarithmic_soil_gridpoint_density: float,
) -> Self:
"""This method creates a copy of the current cable object this was run from, but with an extra added soil layer.
"""Create a fresh copy of the current cable object this was run from, but with an extra added soil layer.

Args:
cable (Cable):
Expand Down
Loading