"""
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 pathlib import Path, PurePosixPath
from typing import Any, Optional, Union
import warnings
import pandas as pd
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_forcing import SUEWSForcing
from .suews_output import SUEWSOutput
from .util._io import read_forcing
# Constants
DEFAULT_OUTPUT_FREQ_SECONDS = 3600 # Default hourly output frequency
DEFAULT_FORCING_FILE_PATTERNS = [
"*.txt",
"*.csv",
"*.met",
] # Valid forcing file extensions
[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 configuration parameters
- 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._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:
- Path to YAML file
- Dictionary with parameters (can be partial)
- SUEWSConfig object
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):
# Update existing config with dictionary
if self._config is None:
self._config = SUEWSConfig()
# Deep update the configuration
self._update_config_from_dict(config)
# 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
def _update_config_from_dict(self, updates: dict):
"""Apply dictionary updates to configuration."""
def recursive_update(obj, upd):
for key, value in upd.items():
if hasattr(obj, key):
attr = getattr(obj, key)
if isinstance(value, dict) and hasattr(attr, "__dict__"):
# Recursive to read nested dictionaries
recursive_update(attr, value)
# Check whether site index or site name is provided
elif isinstance(attr, list):
site_key = list(value.keys())[0]
site_value = value[site_key]
if isinstance(site_key, int):
# Select site on index
attr = attr[site_key]
recursive_update(attr, site_value)
elif isinstance(site_key, str):
# Select site on name
attr_site = next(
(item for item in attr if item.name == site_key),
None,
)
if attr_site:
recursive_update(attr_site, site_value)
elif len(attr) == 1:
# Without name or index and only one site
attr_site = attr[0]
# Distinguish site name pattern from shorthand
# If site_key is an attribute on the site object, it's shorthand
if hasattr(attr_site, site_key):
# Shorthand pattern: {'name': 'test'} or {'properties': {...}}
recursive_update(attr_site, value)
else:
# Site name pattern: {'NonExistent': {'gridiv': 99}}
recursive_update(attr_site, site_value)
else:
# Otherwise skip these parameters
continue
else:
setattr(obj, key, value)
recursive_update(self._config, updates)
[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
-----
When loading from file paths, forcing data is resampled to match the
model timestep from ``model.control.tstep`` in the configuration.
If no configuration is loaded, defaults to 300 seconds (5 minutes).
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 = forcing_data.df.copy()
elif isinstance(forcing_data, pd.DataFrame):
self._df_forcing = forcing_data.copy()
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 = getattr(
self._config.model.control, "forcing_file", None
)
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())
@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,
**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.
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'."
)
_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
list_issues = check_forcing(df_forcing_slice)
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
df_output, _ = run_suews_rust_chunked(
config=self._config,
df_forcing=df_forcing_slice,
chunk_day=chunk_day,
)
self._df_output = df_output
# Build state_final: copy initial state + version metadata
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,
)
[docs]
def save(
self, output_path: Optional[Union[str, Path]] = None, **save_kwargs
) -> list[str]:
"""
Save simulation results according to OutputConfig 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_file.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 path is specified in config
config_path = None
try:
output_file = self._config.model.control.output_file
if not isinstance(output_file, str) and output_file.path:
config_path = output_file.path
except AttributeError:
pass
output_path = Path(config_path) if config_path 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 OutputConfig if available
if (
hasattr(self._config, "model")
and hasattr(self._config.model, "control")
and hasattr(self._config.model.control, "output_file")
and not isinstance(self._config.model.control.output_file, str)
):
output_config = self._config.model.control.output_file
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,
)
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._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
[docs]
@classmethod
def from_state(cls, state: Union[str, Path, pd.DataFrame]):
"""Create SUEWSSimulation from saved state for continuation runs.
Load a previously saved model state to continue simulation from where
it left off. Useful for multi-period runs or scenario testing with
different forcing data.
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
--------
Continue from saved file:
>>> # Period 1
>>> sim1 = SUEWSSimulation("config.yaml")
>>> sim1.update_forcing("forcing_2023.txt")
>>> sim1.run()
>>> paths = sim1.save("output/")
>>> # Period 2 - continue from saved state
>>> sim2 = SUEWSSimulation.from_state("output/df_state.csv")
>>> sim2.update_forcing("forcing_2024.txt")
>>> sim2.run()
Continue 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
--------
save : Save simulation results and state
reset : Clear results and reset to initial state
state_final : Access final state from completed simulation
"""
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 final state 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 state
>>> sim2 = SUEWSSimulation.from_output(output1)
>>> sim2.update_forcing("forcing_2024.txt")
>>> output2 = sim2.run()
See Also
--------
from_state : Create from saved state file or DataFrame
SUEWSOutput.state_final : Final state property for restart
"""
df_state_init = output.state_final
sim = cls()
sim._df_state_init = df_state_init
# Deep copy config to avoid shared state issues
if output.config is not None:
sim._config = copy.deepcopy(output.config)
return sim
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
return SUEWSForcing(self._df_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,
)
@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
state_final : Final state after simulation
from_state : Create simulation from existing state
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]:
"""Final state DataFrame after simulation.
Available only after running simulation. Contains evolved
state variables that can be used to continue simulation.
Returns
-------
pandas.DataFrame or None
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
from_state : Create new simulation from this final state
Examples
--------
>>> sim = SUEWSSimulation.from_sample_data()
>>> sim.run()
>>> sim.state_final is not None
True
"""
return self._df_state_final