"""
SUEWS Simulation Class.
Modern, object-oriented interface for SUEWS urban climate model simulations.
Provides a user-friendly wrapper around the existing SuPy infrastructure.
"""
import copy
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any, Optional, Union
import warnings
import pandas as pd
from pydantic import BaseModel
from ._check import check_forcing
from ._env import logger_supy
from ._run_rust import _check_rust_available, run_suews_rust_chunked
# Import SuPy components directly
from ._supy_module import _save_supy
from .data_model import RefValue
from .data_model.core import SUEWSConfig
# Import new OOP classes
from .suews_checkpoint import SUEWSCheckpoint
from .suews_forcing import SUEWSForcing
from .suews_output import SUEWSOutput
from .util._io import prepare_dataframe_forcing, read_forcing
# Constants
DEFAULT_OUTPUT_FREQ_SECONDS = 3600 # Default hourly output frequency
DEFAULT_FORCING_FILE_PATTERNS = [
"*.txt",
"*.csv",
"*.met",
] # Valid forcing file extensions
def _validate_n_jobs(n_jobs: int) -> Optional[int]:
"""Return a Rust worker cap for a public ``n_jobs`` value."""
if isinstance(n_jobs, bool) or not isinstance(n_jobs, int):
raise ValueError("n_jobs must be an integer: -1, 1, or a positive value")
if n_jobs == 0 or n_jobs < -1:
raise ValueError("n_jobs must be -1, 1, or a positive integer")
return None if n_jobs == -1 else n_jobs
[docs]
class SUEWSSimulation:
"""
Simplified SUEWS simulation class for urban climate modelling.
This class provides a clean interface for:
- Loading and updating configuration
- Managing forcing data
- Running simulations
- Saving results
Examples
--------
Basic usage:
>>> sim = SUEWSSimulation("config.yaml")
>>> sim.update_forcing("forcing.txt")
>>> sim.run()
>>> sim.save("output_dir/")
Updating configuration:
>>> sim.update_config({"model": {"control": {"tstep": 600}}})
>>> sim.reset()
>>> sim.run()
"""
def __init__(self, config: Union[str, Path, dict, Any] = None):
"""
Initialize SUEWS simulation.
Parameters
----------
config : str, Path, dict, or SUEWSConfig, optional
Initial configuration source:
- Path to YAML configuration file
- Dictionary with a full configuration (YAML-shaped, validated
through ``SUEWSConfig`` exactly like a YAML file)
- SUEWSConfig object
- None to create empty simulation
"""
self._config = None
self._config_path = None
self._df_state_init = None
self._df_forcing = None
self._df_output = None
self._df_state_final = None
self._checkpoint = None
self._run_completed = False
if config is not None:
self.update_config(config)
[docs]
def update_config(
self,
config: Union[str, Path, dict, SUEWSConfig],
auto_load_forcing: bool = True,
) -> "SUEWSSimulation":
"""
Update simulation configuration.
Can accept full or partial configuration updates.
Parameters
----------
config : str, Path, dict, or SUEWSConfig
Configuration source. Pass a path to a YAML file, a
``SUEWSConfig`` object, or a dictionary with parameters. A
dictionary is treated as a full configuration when no config is
loaded yet, or as a partial update merged onto the existing config.
Either way the result is re-validated through ``SUEWSConfig``, so
enum/RefValue coercion and range checks apply exactly as for YAML
input. Unknown keys raise ``ValueError``; list values replace the
existing list.
auto_load_forcing : bool, optional
If True (default), automatically load forcing data specified in the
config file. If False, forcing must be loaded explicitly using
``update_forcing()``.
Set to False when:
- You want explicit control over forcing data loading
- Forcing file paths in config are placeholders
- You plan to provide forcing data programmatically
Returns
-------
SUEWSSimulation
Self, for method chaining.
Examples
--------
>>> sim.update_config("new_config.yaml")
>>> sim.update_config({"model": {"control": {"tstep": 300}}})
>>> sim.update_config("config.yaml").update_forcing("forcing.txt")
Explicit forcing control:
>>> sim.update_config("config.yaml", auto_load_forcing=False)
>>> sim.update_forcing("custom_forcing.txt")
"""
if isinstance(config, (str, Path)):
# Load from YAML file
config_path = Path(config).expanduser().resolve()
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
# Load YAML
self._config = SUEWSConfig.from_yaml(str(config_path))
self._config_path = config_path
# Convert to initial state DataFrame
self._df_state_init = self._config.to_df_state()
# Optionally try to load forcing from config
if auto_load_forcing:
self._try_load_forcing_from_config()
elif isinstance(config, dict):
if self._config is None:
# No existing config: treat the dict as a full configuration
# and build it through the validated SUEWSConfig path
# Partial dicts are only meaningful as updates to an existing
# configuration.
candidate = SUEWSConfig.from_dict(config)
if not candidate.sites:
raise ValueError(
"Configuration dict defines no sites. Provide a full "
"configuration dict (including 'sites'), or load a "
"YAML file / SUEWSConfig first and then apply "
"partial updates via update_config()."
)
self._config = candidate
# Parity with the YAML branch: auto-load forcing declared
# in the config. Relative paths resolve against the CWD
# here (no file anchor); failures warn rather than raise.
if auto_load_forcing:
self._try_load_forcing_from_config()
else:
# Merge the partial update onto the existing config, then
# re-validate the whole configuration so enum coercion,
# RefValue wrapping, and range checks all apply.
# The merged dict is the user's effective input (original
# explicitly-set fields plus this update), so from_dict's
# default raw snapshot of it is the correct record for
# raw-gated completeness checks.
merged = self._merge_config_updates(self._config, config)
self._config = SUEWSConfig.from_dict(
merged,
yaml_path=str(self._config_path) if self._config_path else None,
)
# Regenerate state DataFrame
self._df_state_init = self._config.to_df_state()
else:
# Assume it's a SUEWSConfig object
self._config = config
self._df_state_init = self._config.to_df_state()
return self
@staticmethod
def _merge_config_updates(config: SUEWSConfig, updates: dict) -> dict:
"""Merge a partial update dict onto a validated config's dump.
Returns a plain dict ready for re-validation through
``SUEWSConfig.from_dict``; the configuration is never mutated in
place, so enum coercion, RefValue wrapping, and range checks always
apply to the merged result.
Merge semantics:
- dicts merge recursively into model-backed nodes; unknown field
names raise ``ValueError`` instead of being silently dropped
- ``{"value": ...}`` dicts merge into RefValue leaves; any other
dict shape on a RefValue leaf replaces it wholesale (alternate
readable input forms are re-validated, not merged)
- lists replace wholesale
- ``sites`` additionally accepts the established mini-language:
``{index: patch}``, ``{site_name: patch}``, or a single-site
shorthand patch
"""
# Dump only what the user explicitly set (exclude_unset): merging
# onto a full dump would materialise pydantic defaults as if the
# user had declared them, so conditional validators gated on
# model_fields_set or on the raw-input snapshot (e.g. RSL/faibldg,
# site completeness) would fire false errors on sparse configs.
# Internal bookkeeping keys are re-stamped by from_dict; carrying
# them through the merge would nest snapshots inside snapshots.
base = {
k: v
for k, v in config.model_dump(exclude_unset=True, mode="json").items()
if not k.startswith("_")
}
def normalise_legacy_input(model_cls, upd):
"""Run the model's mode='before' validators on an update dict.
The full from_dict/from_yaml path normalises legacy input
forms (field renames such as ``stabilitymethod`` ->
``stability``, the ``output_file``/``forcing_file`` lifts)
through each model's before-validators. Partial updates must
accept the same forms, so the patch is run through the same
machinery before the unknown-key check.
"""
decorators = getattr(model_cls, "__pydantic_decorators__", None)
if decorators is None:
return upd
for name, decorator in decorators.model_validators.items():
if decorator.info.mode != "before":
continue
validator = getattr(model_cls, name, None)
if validator is None:
continue
result = validator(upd)
if isinstance(result, dict):
upd = result
return upd
def merge_node(node_obj, base_dict, upd, path):
if isinstance(node_obj, BaseModel) and not isinstance(node_obj, RefValue):
upd = normalise_legacy_input(type(node_obj), dict(upd))
for key, value in upd.items():
key_path = f"{path}.{key}" if path else str(key)
if not path and key == "sites":
merge_sites(base_dict, value)
continue
if (
isinstance(node_obj, BaseModel)
and not isinstance(node_obj, RefValue)
and key not in type(node_obj).model_fields
):
raise ValueError(f"Unknown configuration key: '{key_path}'")
attr = getattr(node_obj, key, None) if node_obj is not None else None
if isinstance(attr, RefValue):
if isinstance(value, dict) and not (
set(value) <= set(RefValue.model_fields)
):
# Alternate readable input form; replace wholesale
base_dict[key] = copy.deepcopy(value)
continue
# Scalars become {"value": ...} so the field keeps its
# RefValue shape (and any ref metadata) across updates
patch = (
copy.deepcopy(value)
if isinstance(value, dict)
else {"value": copy.deepcopy(value)}
)
base_val = base_dict.get(key)
if isinstance(base_val, dict):
base_val.update(patch)
else:
if "value" not in patch:
# Metadata-only patch with no dumped base entry
# (field at its default): seed the current value
# so re-validation does not see a value-less dict
seed = attr.model_dump(exclude_none=True, mode="json")
seed.update(patch)
patch = seed
base_dict[key] = patch
elif isinstance(value, dict) and isinstance(attr, BaseModel):
base_val = base_dict.get(key)
if not isinstance(base_val, dict):
base_dict[key] = base_val = {}
merge_node(attr, base_val, value, key_path)
elif (
isinstance(value, dict)
and set(value) <= set(RefValue.model_fields)
and "value" not in value
and attr is not None
and not isinstance(attr, (dict, list))
):
# Metadata-only RefValue-shaped patch on a bare-valued
# field: seed the current value before re-validation
current = attr.value if isinstance(attr, Enum) else attr
base_dict[key] = {
"value": copy.deepcopy(current),
**copy.deepcopy(value),
}
else:
# Scalars, enums, lists, and dict input for non-model
# nodes replace wholesale; validation coerces the result
base_dict[key] = copy.deepcopy(value)
def apply_site_patch(index, patch):
if not isinstance(patch, dict):
raise ValueError(
f"Site update for sites[{index}] must be a dict, "
f"got {type(patch).__name__}"
)
if not 0 <= index < len(config.sites):
raise ValueError(
f"Site index {index} out of range "
f"({len(config.sites)} site(s) configured)"
)
merge_node(
config.sites[index],
base["sites"][index],
patch,
f"sites[{index}]",
)
def merge_sites(base_dict, sites_value):
if isinstance(sites_value, list):
# Plain list replaces the sites list wholesale
base_dict["sites"] = copy.deepcopy(sites_value)
return
if not isinstance(sites_value, dict) or not sites_value:
raise ValueError("'sites' update must be a non-empty dict or a list")
site_names = [getattr(site, "name", None) for site in config.sites]
for key, value in sites_value.items():
if isinstance(key, int):
apply_site_patch(key, value)
elif key in site_names:
apply_site_patch(site_names.index(key), value)
elif len(config.sites) == 1:
if hasattr(config.sites[0], key):
# Single-site shorthand: the whole dict is one patch
apply_site_patch(0, sites_value)
return
# Unmatched site name with a single site: patch that site
apply_site_patch(0, value)
elif hasattr(config.sites[0], key):
# Single-site shorthand used with multiple sites is
# ambiguous: skip rather than guess which site to patch
logger_supy.warning(
"Skipping ambiguous sites update key '%s': "
"single-site shorthand with multiple sites configured",
key,
)
else:
# Not a site field, so it can only be a (misspelled or
# stale) site name: raise rather than silently no-op
raise ValueError(
f"Unknown site '{key}' in sites update. "
f"Configured sites: {', '.join(map(repr, site_names))}"
)
# Deep-copied so the legacy-input coercions (which restructure
# nested dicts in place) never mutate the caller's update dict.
merge_node(config, base, copy.deepcopy(updates), "")
return base
[docs]
def update_forcing(
self, forcing_data: Union[str, Path, list, pd.DataFrame, SUEWSForcing]
) -> "SUEWSSimulation":
"""
Update meteorological forcing data.
Parameters
----------
forcing_data : str, Path, list of paths, pandas.DataFrame, or SUEWSForcing
Forcing data source:
- Path to a single forcing file
- List of paths to forcing files (concatenated in order)
- Path to directory containing forcing files (deprecated)
- DataFrame with forcing data
- SUEWSForcing object
Returns
-------
SUEWSSimulation
Self, for method chaining.
Notes
-----
Regardless of input type, forcing data is resampled to match the
model timestep from ``model.control.tstep`` in the configuration
(defaulting to 300 seconds when no configuration is loaded). In-memory
inputs (DataFrame or :class:`~supy.SUEWSForcing`) are validated as
already being in the model-ready canonical form -- canonical column
names, a :class:`pandas.DatetimeIndex`, and pressure in hPa -- and
rejected with a clear error otherwise. Unlike file loading, no column
renaming or unit conversion is applied to in-memory inputs; build them
with :func:`supy.util.read_forcing` or
:meth:`supy.SUEWSForcing.from_file` to guarantee that contract.
Examples
--------
>>> sim.update_forcing("forcing_2023.txt")
>>> sim.update_forcing(["forcing_2023.txt", "forcing_2024.txt"])
>>> sim.update_forcing(df_forcing)
>>> sim.update_forcing(SUEWSForcing.from_file("forcing.txt"))
>>> sim.update_config(cfg).update_forcing(forcing).run()
"""
# Get tstep from config if available, otherwise default to 300s
tstep_mod = 300
if self._config is not None:
try:
tstep_val = self._config.model.control.tstep
tstep_mod = (
tstep_val.value if hasattr(tstep_val, "value") else tstep_val
)
except AttributeError:
logger_supy.debug(
"Could not extract tstep from config; using default %ds",
tstep_mod,
)
if isinstance(forcing_data, RefValue):
forcing_data = forcing_data.value
if isinstance(forcing_data, SUEWSForcing):
self._df_forcing = prepare_dataframe_forcing(
forcing_data.to_dataframe(include_extras=True), tstep_mod=tstep_mod
)
elif isinstance(forcing_data, pd.DataFrame):
self._df_forcing = prepare_dataframe_forcing(
forcing_data, tstep_mod=tstep_mod
)
elif isinstance(forcing_data, list):
# Handle list of files
self._df_forcing = SUEWSSimulation._load_forcing_from_list(
forcing_data, tstep_mod=tstep_mod
)
elif isinstance(forcing_data, (str, Path)):
forcing_path = Path(forcing_data).expanduser().resolve()
if not forcing_path.exists():
raise FileNotFoundError(f"Forcing path not found: {forcing_path}")
self._df_forcing = SUEWSSimulation._load_forcing_file(
forcing_path, tstep_mod=tstep_mod
)
else:
raise ValueError(f"Unsupported forcing data type: {type(forcing_data)}")
return self
def _try_load_forcing_from_config(self):
"""Try to load forcing data from configuration if not explicitly provided."""
if self._config is None:
return
try:
if hasattr(self._config, "model") and hasattr(
self._config.model, "control"
):
forcing_file_obj = self._config.model.control.forcing.file
if forcing_file_obj is not None:
# Handle RefValue wrapper
if hasattr(forcing_file_obj, "value"):
forcing_value = forcing_file_obj.value
else:
forcing_value = forcing_file_obj
# Skip default placeholder value
if forcing_value and forcing_value != "forcing.txt":
# Resolve paths relative to config file if needed
if self._config_path:
forcing_value = self._resolve_forcing_paths(forcing_value)
self.update_forcing(forcing_value)
except Exception as e:
warnings.warn(f"Could not load forcing from config: {e}", stacklevel=2)
def _resolve_forcing_paths(
self, paths: Union[str, list[str]]
) -> Union[str, list[str]]:
"""Resolve forcing paths relative to config file location.
Parameters
----------
paths : str or list of str
Path(s) to resolve. Relative paths are resolved relative to config file.
Returns
-------
str or list of str
Resolved path(s). Absolute paths are returned unchanged.
"""
if isinstance(paths, list):
return [self._resolve_single_path(p) for p in paths]
else:
return self._resolve_single_path(paths)
def _resolve_single_path(self, path: str) -> str:
"""Resolve a single path relative to config file if it's relative.
Parameters
----------
path : str
Path to resolve
Returns
-------
str
Resolved path. Absolute paths are returned unchanged.
Notes
-----
Relative paths can use '..' to reference parent directories. This is
intentional to allow flexible file organization. Path traversal restrictions
are not enforced since:
1. Config files are created by the user themselves
2. Code runs on the user's own machine
3. No untrusted external input is involved
"""
path_str = str(path)
# Treat platform-native absolute paths as literal
if Path(path_str).is_absolute() or PurePosixPath(path_str).is_absolute():
return path_str
# Relative path - resolve relative to config file location
# Using resolve() handles '..' and normalizes the path
return str((self._config_path.parent / Path(path_str)).resolve())
def _resolve_output_path(self, path: Union[str, Path]) -> Path:
"""Resolve an output path relative to the loaded config file."""
path_str = str(path)
path_output = Path(path_str).expanduser()
if path_output.is_absolute() or PurePosixPath(path_str).is_absolute():
return path_output
if self._config_path is not None:
return (self._config_path.parent / path_output).resolve()
return path_output
@staticmethod
def _load_forcing_from_list(
forcing_list: list[Union[str, Path]], tstep_mod: int = 300
) -> pd.DataFrame:
"""Load and concatenate forcing data from a list of files."""
if not forcing_list:
raise ValueError("Empty forcing file list provided")
dfs = []
for item in forcing_list:
path = Path(item).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Forcing file not found: {path}")
if path.is_dir():
raise ValueError(
f"Directory '{path}' found in forcing file list. "
"Directories are not allowed in lists."
)
df = read_forcing(str(path), tstep_mod=tstep_mod)
dfs.append(df)
result = pd.concat(dfs, axis=0).sort_index()
result.index.freq = pd.infer_freq(result.index)
return result
@staticmethod
def _load_forcing_file(forcing_path: Path, tstep_mod: int = 300) -> pd.DataFrame:
"""Load forcing data from file or directory."""
if forcing_path.is_dir():
# Issue deprecation warning for directory usage
warnings.warn(
f"Loading forcing data from directory '{forcing_path}' is deprecated. "
"Please specify individual files or use a list of files instead.",
DeprecationWarning,
stacklevel=3,
)
# Find forcing files in directory
forcing_files = []
for pattern in DEFAULT_FORCING_FILE_PATTERNS:
forcing_files.extend(sorted(forcing_path.glob(pattern)))
if not forcing_files:
raise FileNotFoundError(
f"No forcing files found in directory: {forcing_path}"
)
# Concatenate all files
dfs = []
for file in forcing_files:
dfs.append(read_forcing(str(file), tstep_mod=tstep_mod))
return pd.concat(dfs, axis=0).sort_index()
else:
return read_forcing(str(forcing_path), tstep_mod=tstep_mod)
[docs]
def run(
self,
start_date=None,
end_date=None,
chunk_day: int = 3660,
n_jobs: int = -1,
**run_kwargs,
) -> SUEWSOutput:
"""
Run SUEWS simulation using the Rust bridge backend.
Parameters
----------
start_date : str, optional
Start date for simulation (inclusive).
end_date : str, optional
End date for simulation (inclusive).
chunk_day : int, optional
Chunk size in days for splitting long simulations, by default 3660
(~10 years). Smaller values reduce peak memory at a small overhead
cost.
n_jobs : int, optional
Parallel worker control for multi-grid runs. ``-1`` (default) uses
Rayon default parallelism, ``1`` forces serial execution, and
values greater than ``1`` cap Rayon to that many threads.
Returns
-------
SUEWSOutput
Simulation results wrapped in an OOP interface with analysis
and plotting convenience methods. Access raw DataFrame via
``.to_dataframe()`` or ``.df``.
Raises
------
RuntimeError
If configuration or forcing data is missing.
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> output = sim.run()
>>> output.QH # Access sensible heat flux
>>> output.diurnal_average("QH") # Get diurnal pattern
>>> output.to_dataframe() # Get raw DataFrame
"""
# Handle deprecated backend kwarg
backend = run_kwargs.pop("backend", None)
if backend is not None and backend != "rust":
raise ValueError(
f"The '{backend}' backend has been removed. "
f"Only the 'rust' backend is available. "
f"Remove the backend parameter or use backend='rust'."
)
max_workers = _validate_n_jobs(n_jobs)
serial_mode = n_jobs == 1
_check_rust_available()
# Validate inputs
if self._df_state_init is None:
raise RuntimeError("No configuration loaded. Use update_config() first.")
if self._df_forcing is None:
raise RuntimeError("No forcing data loaded. Use update_forcing() first.")
if self._config is None:
# Backward-compatible path: allow runs initialised from df_state
# (legacy functional workflows and continuation tests).
try:
self._config = SUEWSConfig.from_df_state(self._df_state_init)
except Exception:
# Some legacy state CSVs carry an extra unnamed index level
# (e.g. MultiIndex [('grid', None)]). Strip to grid only.
df_state_for_cfg = self._df_state_init.copy()
if isinstance(df_state_for_cfg.index, pd.MultiIndex):
if "grid" in df_state_for_cfg.index.names:
grid_vals = df_state_for_cfg.index.get_level_values("grid")
else:
grid_vals = df_state_for_cfg.index.get_level_values(0)
df_state_for_cfg.index = pd.Index(grid_vals, name="grid")
self._config = SUEWSConfig.from_df_state(df_state_for_cfg)
# Fall back to config values if start_date/end_date not provided
if start_date is None and self._config is not None:
if (
hasattr(self._config, "model")
and hasattr(self._config.model, "control")
and hasattr(self._config.model.control, "start_time")
):
start_date = self._config.model.control.start_time
if end_date is None and self._config is not None:
if (
hasattr(self._config, "model")
and hasattr(self._config.model, "control")
and hasattr(self._config.model.control, "end_time")
):
end_date = self._config.model.control.end_time
# Slice forcing data
df_forcing_slice = self._df_forcing.loc[start_date:end_date]
# Validate forcing data, including physics-specific forcing requirements
# (e.g. laimethod=0 requires populated effective observed-LAI sources).
physics_dict = None
if self._config is not None and hasattr(self._config, "model"):
physics = getattr(self._config.model, "physics", None)
if physics is not None and hasattr(physics, "model_dump"):
physics_dict = physics.model_dump(mode="python")
# Cross-check physics path against forcing columns.
# Helper is silent on success; raises ValueError on mismatch.
if physics is not None:
from .data_model.core.forcing_validation import (
validate_forcing_columns_against_physics,
)
validate_forcing_columns_against_physics(df_forcing_slice, physics)
list_issues = check_forcing(df_forcing_slice, physics=physics_dict)
if isinstance(list_issues, list) and len(list_issues) > 0:
issues_summary = list_issues[:3] if len(list_issues) > 3 else list_issues
suffix = (
f" (and {len(list_issues) - 3} more)" if len(list_issues) > 3 else ""
)
raise ValueError(f"Invalid forcing data: {issues_summary}{suffix}")
# Run simulation via Rust bridge
initial_state_json_by_grid = (
self._checkpoint.grid_states if self._checkpoint is not None else None
)
df_output, dict_state_json = run_suews_rust_chunked(
config=self._config,
df_forcing=df_forcing_slice,
chunk_day=chunk_day,
serial_mode=serial_mode,
max_workers=max_workers,
initial_state_json_by_grid=initial_state_json_by_grid,
)
self._df_output = df_output
self._checkpoint = (
SUEWSCheckpoint.from_grid_states(
dict_state_json,
last_timestamp=df_forcing_slice.index.max(),
)
if dict_state_json
else None
)
# Keep a legacy DFState-shaped final state for compatibility only.
from ._version import __version__
df_state_final = self._df_state_init.copy()
df_state_final[("version", "0")] = __version__
self._df_state_final = df_state_final
self._run_completed = True
# Wrap results in SUEWSOutput
return SUEWSOutput(
df_output=self._df_output,
df_state_final=self._df_state_final,
config=self._config,
checkpoint=self._checkpoint,
)
[docs]
def save(
self, output_path: Optional[Union[str, Path]] = None, **save_kwargs
) -> list[str]:
"""
Save simulation results according to OutputControl settings.
Parameters
----------
output_path : str or Path, optional
Output directory path. If None, uses current directory.
save_kwargs : dict
Additional keyword arguments for customising output.
**Currently supported kwargs:**
- **format** : str
Output format: 'txt' (default) or 'parquet'.
Note: This overrides config file settings.
**Not currently supported** (due to internal constraints):
- freq_s: Controlled by config.model.control.output.freq
- site: Derived from config.sites[0].name
- save_tstep: Not configurable via OOP interface
- output_level: Not configurable via OOP interface
These parameters are determined by the configuration object.
To change them, update your configuration file or use
``update_config()`` before running the simulation.
Returns
-------
list
List of paths to saved files.
Raises
------
RuntimeError
If no simulation results are available.
Examples
--------
Save with default settings from config:
>>> sim.run()
>>> paths = sim.save()
Save to specific directory with custom format:
>>> sim.run()
>>> paths = sim.save("output/", format="parquet")
"""
if not self._run_completed:
raise RuntimeError("No simulation results available. Run simulation first.")
if self._df_state_final is None:
raise NotImplementedError(
"save() is not yet supported for the Rust backend. "
"Access results directly via sim.output.df_output"
)
# Set default path with priority: parameter > config > current directory
if output_path is None:
# Check if dir is specified in config
config_dir = None
try:
output_control = self._config.model.control.output
if output_control.dir:
config_dir = output_control.dir
except AttributeError:
pass
output_path = (
self._resolve_output_path(config_dir) if config_dir else Path(".")
)
else:
output_path = Path(output_path)
# Extract parameters from config
output_format = None
output_config = None
freq_s = DEFAULT_OUTPUT_FREQ_SECONDS
site = ""
if self._config:
# Get output frequency from OutputControl if available
if (
hasattr(self._config, "model")
and hasattr(self._config.model, "control")
and hasattr(self._config.model.control, "output")
):
output_config = self._config.model.control.output
if hasattr(output_config, "freq") and output_config.freq is not None:
freq_s = output_config.freq
# Removed for now - can't update from YAML (TODO)
# if hasattr(output_config, 'format') and output_config.format is not None:
# output_format = output_config.format
# Get site name from first site
if hasattr(self._config, "sites") and len(self._config.sites) > 0:
site = self._config.sites[0].name
if "format" in save_kwargs: # TODO: When yaml format working, make elif
output_format = save_kwargs["format"]
# Use internal save helper for all formats
list_path_save = _save_supy(
df_output=self._df_output,
df_state_final=self._df_state_final,
freq_s=int(freq_s),
site=site,
path_dir_save=str(output_path),
# **save_kwargs # Problematic, save_supy expects explicit arguments
output_config=output_config,
output_format=output_format,
save_state=False,
)
if self._checkpoint is not None:
checkpoint_name = (
f"{site}_SUEWS_checkpoint.json" if site else "SUEWS_checkpoint.json"
)
checkpoint_path = self._checkpoint.to_file(output_path / checkpoint_name)
list_path_save.append(checkpoint_path)
return list_path_save
[docs]
def reset(self) -> "SUEWSSimulation":
"""Reset simulation to initial state, clearing results.
Returns
-------
SUEWSSimulation
Self, for method chaining.
Examples
--------
>>> sim.run()
>>> sim.reset().run() # Re-run with same configuration
"""
self._df_output = None
self._df_state_final = None
self._checkpoint = None
self._run_completed = False
return self
[docs]
@classmethod
def from_sample_data(cls):
"""Create SUEWSSimulation instance with built-in sample data.
This factory method provides a quick way to create a simulation object
pre-loaded with sample configuration and forcing data, ideal for tutorials,
testing, and learning the SUEWS workflow.
Returns
-------
SUEWSSimulation
Simulation instance ready to run with sample data loaded.
Examples
--------
Quick start with sample data:
>>> from supy import SUEWSSimulation
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.run()
>>> results = sim.results
"""
from ._env import trv_supy_module
from ._supy_module import _load_sample_data
# Load core simulation data (state and forcing)
df_state_init, df_forcing = _load_sample_data()
sample_config_path = Path(trv_supy_module / "sample_data" / "sample_config.yml")
sim = cls()
# Try to load config for metadata (non-critical)
# The actual state is set from df_state_init below, so config is optional
try:
sim.update_config(sample_config_path)
except (FileNotFoundError, IOError) as exc:
# File access issues - warn but continue
warnings.warn(
f"Could not load sample configuration file: {exc}\n"
"Simulation will use data from df_state_init instead.",
UserWarning,
stacklevel=2,
)
except Exception as exc:
# Other unexpected errors - warn but continue
warnings.warn(
f"Unexpected error loading sample configuration: {exc}\n"
"Simulation will use data from df_state_init instead.",
UserWarning,
stacklevel=2,
)
# Set core simulation data (overrides any config-derived state)
sim._df_state_init = df_state_init
sim._df_forcing = df_forcing
return sim
@staticmethod
def _coerce_checkpoint(
checkpoint: Union[str, Path, SUEWSCheckpoint],
) -> SUEWSCheckpoint:
if isinstance(checkpoint, SUEWSCheckpoint):
return checkpoint
if isinstance(checkpoint, (str, Path)):
return SUEWSCheckpoint.from_file(checkpoint)
raise TypeError(
"checkpoint must be a SUEWSCheckpoint, str, or Path, "
f"got {type(checkpoint).__name__}"
)
[docs]
@classmethod
def from_checkpoint(
cls,
config: Union[str, Path, dict, SUEWSConfig],
checkpoint: Union[str, Path, SUEWSCheckpoint],
) -> "SUEWSSimulation":
"""Create a continuation simulation from YAML config and checkpoint."""
if config is None:
raise ValueError(
"Checkpoint continuation requires a YAML/SUEWSConfig "
"configuration as well as the checkpoint."
)
sim = cls()
if not isinstance(config, (str, Path, dict)):
config = copy.deepcopy(config)
sim.update_config(config, auto_load_forcing=False)
return sim.continue_from(checkpoint)
[docs]
def continue_from(
self, checkpoint: Union[str, Path, SUEWSCheckpoint]
) -> "SUEWSSimulation":
"""Use a typed checkpoint as the initial runtime state."""
if self._config is None:
raise RuntimeError(
"Checkpoint continuation requires a loaded configuration. "
"Use SUEWSSimulation.from_checkpoint(config, checkpoint) or "
"call update_config() before continue_from()."
)
self._checkpoint = self._coerce_checkpoint(checkpoint)
self._df_output = None
self._df_state_final = None
self._run_completed = False
return self
[docs]
@classmethod
def from_state(cls, state: Union[str, Path, pd.DataFrame]):
"""Create SUEWSSimulation from legacy DFState.
This compatibility adapter reads older DFState CSV/parquet/DataFrame
artifacts. New continuation workflows should use
``from_checkpoint(config, checkpoint)``.
Parameters
----------
state : str, Path, or pandas.DataFrame
State to load for continuation. Can be:
- Path to CSV file: `df_state.csv` or `df_state_{site}.csv`
- Path to Parquet file: `{site}_SUEWS_state_final.parquet`
- DataFrame: `df_state_final` from previous simulation
Returns
-------
SUEWSSimulation
Simulation instance initialised with loaded state, ready for
new forcing data and run.
Warnings
--------
If the saved state was created with a different SUEWS version,
a warning is issued about potential compatibility issues.
Examples
--------
Legacy import from saved file:
>>> # Period 1
>>> sim1 = SUEWSSimulation("config.yaml")
>>> sim1.update_forcing("forcing_2023.txt")
>>> sim1.run()
>>> paths = sim1.save("output/")
>>> # Period 2 - legacy DFState continuation
>>> sim2 = SUEWSSimulation.from_state("output/df_state.csv")
>>> sim2.update_forcing("forcing_2024.txt")
>>> sim2.run()
Legacy import from DataFrame directly:
>>> # In-memory continuation without saving to file
>>> sim1 = SUEWSSimulation.from_sample_data()
>>> sim1.run()
>>> df_state_final = sim1.state_final
>>>
>>> # Continue with new forcing
>>> sim2 = SUEWSSimulation.from_state(df_state_final)
>>> sim2.update_forcing("forcing_2024.txt")
>>> sim2.run()
Load from Parquet format:
>>> sim2 = SUEWSSimulation.from_state(
... "output/TestSite_SUEWS_state_final.parquet"
... )
See Also
--------
from_checkpoint : Create continuation from YAML config and checkpoint
save : Save simulation results and checkpoint
reset : Clear results and reset to initial state
checkpoint : Access typed checkpoint from completed simulation
"""
warnings.warn(
"SUEWSSimulation.from_state() is a legacy DFState compatibility "
"adapter. Use SUEWSSimulation.from_checkpoint(config, checkpoint) "
"for restart/continuation runs.",
DeprecationWarning,
stacklevel=2,
)
from ._version import __version__ as current_version
# Load state from file or use DataFrame directly
if isinstance(state, pd.DataFrame):
df_state_saved = state.copy()
elif isinstance(state, (str, Path)):
state_path = Path(state).expanduser().resolve()
if not state_path.exists():
raise FileNotFoundError(f"State file not found: {state_path}")
# Load based on file extension
if state_path.suffix == ".csv":
df_state_saved = pd.read_csv(
state_path,
header=[0, 1],
index_col=0,
parse_dates=True,
)
elif state_path.suffix == ".parquet":
df_state_saved = pd.read_parquet(state_path)
else:
raise ValueError(
f"Unsupported state file format: {state_path.suffix}. "
"Expected .csv or .parquet"
)
else:
raise TypeError(
f"state must be str, Path, or DataFrame, got {type(state).__name__}"
)
# Extract last timestep as initial state for continuation
idx_names = list(df_state_saved.index.names)
if "datetime" in idx_names:
datetime_level = idx_names.index("datetime")
last_datetime = df_state_saved.index.get_level_values(datetime_level).max()
if isinstance(df_state_saved.index, pd.MultiIndex):
df_state_init = df_state_saved.xs(
last_datetime, level="datetime"
).copy()
else:
df_state_init = df_state_saved.loc[[last_datetime]].copy()
else:
# Already single-timestep state
df_state_init = df_state_saved.copy()
# Check version compatibility
if ("version", "0") in df_state_saved.columns:
saved_version = df_state_saved[("version", "0")].iloc[0]
if saved_version != current_version:
warnings.warn(
f"State was saved with SUEWS version {saved_version}, "
f"but current version is {current_version}. "
"This may cause compatibility issues.",
UserWarning,
stacklevel=2,
)
# Create simulation instance with loaded state
sim = cls()
sim._df_state_init = df_state_init
return sim
[docs]
@classmethod
def from_output(cls, output: SUEWSOutput) -> "SUEWSSimulation":
"""Create SUEWSSimulation from previous output for continuation runs.
Convenience method that extracts the final state from a SUEWSOutput
object and creates a new simulation ready for continuation.
Parameters
----------
output : SUEWSOutput
Output object from a previous simulation run.
Returns
-------
SUEWSSimulation
Simulation instance initialised with checkpoint from output,
ready for new forcing data and run.
Examples
--------
Seamless continuation from previous run:
>>> # Run first period
>>> sim1 = SUEWSSimulation("config.yaml")
>>> sim1.update_forcing("forcing_2023.txt")
>>> output1 = sim1.run()
>>> # Continue from output checkpoint
>>> sim2 = SUEWSSimulation.from_output(output1)
>>> sim2.update_forcing("forcing_2024.txt")
>>> output2 = sim2.run()
See Also
--------
from_checkpoint : Create from YAML config and checkpoint
SUEWSOutput.checkpoint : Typed checkpoint property for restart
"""
if output.checkpoint is not None:
if output.config is None:
raise RuntimeError(
"SUEWSOutput has a checkpoint but no configuration. "
"Use SUEWSSimulation.from_checkpoint(config, checkpoint)."
)
return cls.from_checkpoint(
copy.deepcopy(output.config),
output.checkpoint,
)
warnings.warn(
"SUEWSSimulation.from_output() is falling back to legacy DFState. "
"Prefer SUEWSSimulation.from_checkpoint(config, output.checkpoint).",
DeprecationWarning,
stacklevel=2,
)
return cls.from_state(output.state_final)
def __repr__(self) -> str:
"""Concise representation showing simulation status.
Returns
-------
str
Status indicator: Complete, Ready, or Not configured
Examples
--------
>>> sim = SUEWSSimulation()
>>> sim
SUEWSSimulation(Not configured)
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim
SUEWSSimulation(Ready: 1 site, 105408 timesteps)
>>> sim.run()
>>> sim
SUEWSSimulation(Complete: 105408 results)
"""
if self._run_completed:
n_results = len(self._df_output) if self._df_output is not None else 0
return f"SUEWSSimulation(Complete: {n_results} results)"
elif self._df_state_init is not None and self._df_forcing is not None:
n_sites = len(self._df_state_init)
n_timesteps = len(self._df_forcing)
return f"SUEWSSimulation(Ready: {n_sites} site(s), {n_timesteps} timesteps)"
else:
missing = []
if self._df_state_init is None:
missing.append("config")
if self._df_forcing is None:
missing.append("forcing")
return f"SUEWSSimulation(Not configured: missing {', '.join(missing)})"
[docs]
def is_ready(self) -> bool:
"""Check if simulation is configured and ready to run.
Returns
-------
bool
True if both configuration and forcing data are loaded.
Examples
--------
>>> sim = SUEWSSimulation()
>>> sim.is_ready()
False
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.is_ready()
True
"""
return self._df_state_init is not None and self._df_forcing is not None
[docs]
def is_complete(self) -> bool:
"""Check if simulation has been run successfully.
Returns
-------
bool
True if simulation has completed and results are available.
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.is_complete()
False
>>> sim.run()
>>> sim.is_complete()
True
"""
return self._run_completed
[docs]
def get_variable(
self,
var_name: str,
group: Optional[str] = None,
site: Optional[Union[int, str]] = None,
) -> pd.DataFrame:
"""Extract specific variable from simulation results.
Convenience method to extract variables from the MultiIndex column structure
without needing to understand the internal data layout.
Parameters
----------
var_name : str
Variable name to extract (e.g., 'QH', 'QE', 'Tair').
group : str, optional
Output group name if variable appears in multiple groups.
If None and variable is in multiple groups, raises ValueError.
site : int or str, optional
Site index or name. If None, returns all sites.
Returns
-------
pandas.DataFrame
DataFrame with selected variable(s), indexed by time.
Raises
------
RuntimeError
If simulation hasn't been run yet.
ValueError
If variable name not found in results, or if variable is ambiguous
(appears in multiple groups) and no group specified.
Examples
--------
Extract sensible heat flux:
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.run()
>>> qh = sim.get_variable("QH")
>>> qh.plot() # Quick visualisation
Handle variables in multiple groups:
>>> # If 'Tair' appears in both 'forcing' and 'output' groups:
>>> tair = sim.get_variable("Tair", group="output")
See Also
--------
results : Full simulation output DataFrame
"""
if not self._run_completed:
raise RuntimeError("No results available. Run simulation first.")
if self._df_output is None:
raise RuntimeError("Results DataFrame is None")
# Check if variable exists in results
all_vars = self._df_output.columns.get_level_values("var").unique()
if var_name not in all_vars:
raise ValueError(
f"Variable '{var_name}' not found. "
f"Available variables: {', '.join(all_vars[:10])}"
+ ("..." if len(all_vars) > 10 else "")
)
# Check if variable appears in multiple groups
matching_groups = []
for grp in self._df_output.columns.get_level_values("group").unique():
try:
# Check if this group contains the variable
_ = self._df_output.xs((grp, var_name), level=("group", "var"), axis=1)
matching_groups.append(grp)
except KeyError:
continue
if len(matching_groups) == 0:
# Should not happen if var_name was found above
raise ValueError(f"Variable '{var_name}' not found in any group")
elif len(matching_groups) > 1:
# Variable is ambiguous - need group specification
if group is None:
raise ValueError(
f"Variable '{var_name}' appears in multiple groups: "
f"{', '.join(matching_groups)}. "
f"Specify group parameter (e.g., group='{matching_groups[0]}')"
)
elif group not in matching_groups:
raise ValueError(
f"Variable '{var_name}' not found in group '{group}'. "
f"Available groups for this variable: {', '.join(matching_groups)}"
)
# Extract from specified group
result = self._df_output.xs(
(group, var_name), level=("group", "var"), axis=1
)
else:
# Variable is in only one group
if group is not None and group != matching_groups[0]:
raise ValueError(
f"Variable '{var_name}' only exists in group '{matching_groups[0]}', "
f"not in '{group}'"
)
result = self._df_output.xs(var_name, level="var", axis=1)
# Filter by site if requested
if site is not None:
if isinstance(site, str):
result = result[site]
else:
result = result.iloc[:, site]
return result
@property
def config(self) -> Optional[SUEWSConfig]:
"""Access to simulation configuration.
Returns
-------
SUEWSConfig or None
Complete SUEWS configuration object.
None if no configuration loaded.
See Also
--------
update_config : Load or update configuration
state_init : Access initial state derived from configuration
"""
return self._config
@property
def forcing(self) -> Optional[SUEWSForcing]:
"""Access to forcing data as SUEWSForcing object.
Returns
-------
SUEWSForcing or None
Meteorological forcing data wrapped in OOP interface with
validation and analysis methods. None if no forcing loaded.
See Also
--------
:ref:`df_forcing_var` : Complete forcing data structure and variable descriptions
update_forcing : Load forcing data from files or DataFrames
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.forcing.summary() # Get forcing statistics
>>> sim.forcing.Tair # Access air temperature
>>> sim.forcing.df # Access raw DataFrame
"""
if self._df_forcing is None:
return None
df_main, extras = SUEWSForcing._split_per_landcover_columns(self._df_forcing)
forcing = SUEWSForcing(df_main)
forcing._extras = extras
return forcing
@property
def results(self) -> Optional[pd.DataFrame]:
"""Access to simulation results DataFrame (raw).
.. deprecated:: 2025.1
Use ``output = sim.run()`` to get a ``SUEWSOutput`` object,
then ``output.df`` for the raw DataFrame if needed.
Returns
-------
pandas.DataFrame or None
Complete simulation output with all variable groups.
None if simulation hasn't been run yet.
See Also
--------
output : Access results as SUEWSOutput object with analysis methods
:ref:`df_output_var` : Complete output data structure and variable descriptions
get_variable : Extract specific variables from output groups
save : Save results to files
"""
warnings.warn(
"sim.results is deprecated and will be removed in version 2026.1. "
"Use 'output = sim.run()' to get a SUEWSOutput "
"object, then 'output.df' for the raw DataFrame if needed.",
DeprecationWarning,
stacklevel=2,
)
return self._df_output
@property
def output(self) -> Optional[SUEWSOutput]:
"""Access to simulation results as SUEWSOutput object.
.. note::
Preferred pattern is ``output = sim.run()`` which returns the
same ``SUEWSOutput`` object. This property is provided for
convenience when re-accessing results after simulation.
Returns
-------
SUEWSOutput or None
Simulation results wrapped in OOP interface with analysis
and plotting convenience methods. None if simulation hasn't
been run yet.
See Also
--------
run : Run simulation and return SUEWSOutput (preferred)
:ref:`df_output_var` : Complete output data structure
Examples
--------
Preferred pattern - capture return value:
>>> sim = SUEWSSimulation.from_sample_data()
>>> output = sim.run() # Capture output
>>> output.QH # Access sensible heat flux
Alternative - use property after run:
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.run()
>>> sim.output.QH # Re-access via property
"""
if self._df_output is None:
return None
return SUEWSOutput(
df_output=self._df_output,
df_state_final=self._df_state_final,
config=self._config,
checkpoint=self._checkpoint,
)
@property
def state_init(self) -> Optional[pd.DataFrame]:
"""Initial state DataFrame for simulation.
Returns
-------
pandas.DataFrame or None
Initial state with surface characteristics and parameters.
None if no configuration loaded.
See Also
--------
:ref:`df_state_var` : Complete state data structure and variable descriptions
checkpoint : Typed checkpoint after simulation
from_checkpoint : Create simulation from checkpoint
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.state_init.shape
(1, 1403)
"""
return self._df_state_init
@property
def state_final(self) -> Optional[pd.DataFrame]:
"""Legacy final state DataFrame after simulation.
Prefer ``checkpoint`` for restart/continuation workflows.
Returns
-------
pandas.DataFrame or None
Legacy final state after simulation run.
None if simulation hasn't been run yet.
See Also
--------
:ref:`df_state_var` : Complete state data structure and variable descriptions
state_init : Initial state before simulation
reset : Clear results and reset to initial state
checkpoint : Typed restart artifact
from_checkpoint : Create new simulation from checkpoint
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.run()
>>> sim.state_final is not None
True
"""
return self._df_state_final
@property
def checkpoint(self) -> Optional[SUEWSCheckpoint]:
"""Typed checkpoint produced by the most recent run."""
return self._checkpoint
@property
def state_checkpoint(self) -> Optional[SUEWSCheckpoint]:
"""Alias for ``checkpoint``."""
return self._checkpoint