diff --git a/.claude/rules/python/conventions.md b/.claude/rules/python/conventions.md index ea56e42a9..cf34dab3b 100644 --- a/.claude/rules/python/conventions.md +++ b/.claude/rules/python/conventions.md @@ -23,17 +23,32 @@ SUEWS-specific Python conventions. Complements ruff for standard linting. ## Critical Rules -1. **Config separation**: No config objects in low-level functions +1. **Python 3.9 compatibility**: Use `Optional[X]` not `X | None` for union types + ```python + from typing import Optional, Union + + # BAD: PEP 604 syntax requires Python 3.10+ + def foo(x: str | None = None): ... + def bar(items: list[int] | None): ... + + # GOOD: Works on Python 3.9+ + def foo(x: Optional[str] = None): ... + def bar(items: Optional[list[int]]): ... + ``` + **Why**: PEP 604 union syntax (`X | Y`) requires Python 3.10+. Pydantic evaluates + type hints at runtime, so `from __future__ import annotations` is not a safe workaround. + +2. **Config separation**: No config objects in low-level functions ```python # BAD: def save_supy(df_output, config): ... # GOOD: def save_supy(df_output, freq_s: int = 3600): ... ``` -2. **Deep copy**: Use `copy.deepcopy()` for mutable state +3. **Deep copy**: Use `copy.deepcopy()` for mutable state -3. **Logging**: Use `logger_supy` not `print()` +4. **Logging**: Use `logger_supy` not `print()` -4. **pathlib**: Use `Path` not `os.path` +5. **pathlib**: Use `Path` not `os.path` --- diff --git a/docs/generate_datamodel_rst.py b/docs/generate_datamodel_rst.py index 8565fba6c..b505f2dd5 100644 --- a/docs/generate_datamodel_rst.py +++ b/docs/generate_datamodel_rst.py @@ -30,6 +30,13 @@ class RSTGenerator: """Generate RST documentation from extracted model documentation.""" + # Dimensional config models that should be displayed with tabbed format inline + # These models have orthogonal dimensions and need special documentation + DIMENSIONAL_CONFIG_MODELS = {"NetRadiationMethodConfig", "EmissionsMethodConfig"} + + # Models whose content is inlined in parent field (skip separate file generation) + INLINE_NESTED_MODELS = {"NetRadiationMethodConfig", "EmissionsMethodConfig"} + def __init__(self, doc_data: dict[str, Any]): """ Initialize the RST generator with extracted documentation data. @@ -60,6 +67,11 @@ def generate_all_rst( if model_doc.get("circular_ref"): continue # Skip circular references + # Skip models that are inlined in parent fields + if model_name in self.INLINE_NESTED_MODELS: + print(f"Skipped (inlined): {model_name}") + continue + rst_content = self._format_model(model_name, model_doc) rst_file = output_dir / f"{model_name.lower()}.rst" @@ -365,8 +377,208 @@ def _format_field_metadata( return lines + def _is_dimensional_config(self, field_doc: dict[str, Any]) -> bool: + """Check if field has a dimensional config model (for tabbed display). + + Args: + field_doc: Field documentation dictionary + + Returns: + True if this field should use tabbed dimensional display + """ + nested_model = field_doc.get("nested_model") + return nested_model in self.DIMENSIONAL_CONFIG_MODELS + + def _format_dimensional_field( + self, field_doc: dict[str, Any], model_name: str + ) -> list[str]: + """Generate tabbed RST for dimensional config fields. + + Args: + field_doc: Field documentation dictionary + model_name: Name of the containing model + + Returns: + List of RST lines with tab-set for recommended/legacy formats + """ + lines = [] + field_name = field_doc["name"] + nested_model = field_doc.get("nested_model", "") + + # Add index entries + lines.extend(self._add_field_index_entries(field_name, model_name)) + + # Use input:option directive + lines.append(f".. input:option:: {field_name}") + lines.append("") + + # Add main description + description = field_doc.get("description", "") + if description: + lines.append(f" {description}") + lines.append("") + + # Generate tabbed content based on model type + if nested_model == "NetRadiationMethodConfig": + lines.extend(self._format_netradiationmethod_tabs()) + elif nested_model == "EmissionsMethodConfig": + lines.extend(self._format_emissionsmethod_tabs()) + + return lines + + def _format_netradiationmethod_tabs(self) -> list[str]: + """Generate tabbed RST for netradiationmethod field. + + Returns: + List of RST lines with tab-set for recommended/legacy formats + """ + lines = [] + + lines.append(" .. tab-set::") + lines.append("") + lines.append(" .. tab-item:: Recommended") + lines.append(" :selected:") + lines.append("") + lines.append(" Configure using orthogonal dimensions for clearer semantics:") + lines.append("") + lines.append(" .. code-block:: yaml") + lines.append("") + lines.append(" netradiationmethod:") + lines.append(" scheme: narp # obs | narp | spartacus") + lines.append(" ldown: air # obs | cloud | air (required when scheme != obs)") + lines.append("") + lines.append(" **scheme** options:") + lines.append("") + lines.append(" * ``obs`` - Use observed Q* directly from forcing file") + lines.append(" * ``narp`` - NARP parameterisation (Offerle et al. 2003, Loridan et al. 2011)") + lines.append(" * ``spartacus`` - SPARTACUS-Surface integration **(experimental)**") + lines.append("") + lines.append(" **ldown** options (required when scheme ≠ obs):") + lines.append("") + lines.append(" * ``obs`` - Use observed L\\ :sub:`down` from forcing file") + lines.append(" * ``cloud`` - Model L\\ :sub:`down` from cloud cover fraction") + lines.append(" * ``air`` - Model L\\ :sub:`down` from air temperature and relative humidity") + lines.append("") + lines.append(" .. tab-item:: Legacy") + lines.append("") + lines.append(" .. note::") + lines.append("") + lines.append(" The following numeric codes are valid input and fully supported.") + lines.append(" They are planned for deprecation in a future version; the nested format is recommended.") + lines.append("") + lines.append(" **Primary codes:**") + lines.append("") + lines.append(" * ``0`` (OBSERVED) - Uses observed Q* [→ scheme: obs]") + lines.append(" * ``1`` (LDOWN_OBSERVED) - NARP with observed L\\ :sub:`down` [→ scheme: narp, ldown: obs]") + lines.append(" * ``2`` (LDOWN_CLOUD) - NARP with L\\ :sub:`down` from cloud cover [→ scheme: narp, ldown: cloud]") + lines.append(" * ``3`` (LDOWN_AIR) - NARP with L\\ :sub:`down` from air temp/RH [→ scheme: narp, ldown: air]") + lines.append(" * ``1001`` (LDOWN_SS_OBSERVED) - SPARTACUS with observed L\\ :sub:`down` [→ scheme: spartacus, ldown: obs]") + lines.append(" * ``1002`` (LDOWN_SS_CLOUD) - SPARTACUS with L\\ :sub:`down` from cloud [→ scheme: spartacus, ldown: cloud]") + lines.append(" * ``1003`` (LDOWN_SS_AIR) - SPARTACUS with L\\ :sub:`down` from air [→ scheme: spartacus, ldown: air]") + lines.append("") + lines.append(" **Deprecated variants (map to primary codes):**") + lines.append("") + lines.append(" * ``11`` (LDOWN_SURFACE) - Surface temp variant [→ code 1]") + lines.append(" * ``12`` (LDOWN_CLOUD_SURFACE) - Surface temp variant [→ code 2]") + lines.append(" * ``13`` (LDOWN_AIR_SURFACE) - Surface temp variant [→ code 3]") + lines.append(" * ``100`` (LDOWN_ZENITH) - Zenith correction variant [→ code 1]") + lines.append(" * ``200`` (LDOWN_CLOUD_ZENITH) - Zenith correction variant [→ code 2]") + lines.append(" * ``300`` (LDOWN_AIR_ZENITH) - Zenith correction variant [→ code 3]") + lines.append("") + + return lines + + def _format_emissionsmethod_tabs(self) -> list[str]: + """Generate tabbed RST for emissionsmethod field. + + Returns: + List of RST lines with tab-set for recommended/legacy formats + """ + lines = [] + + lines.append(" .. tab-set::") + lines.append("") + lines.append(" .. tab-item:: Recommended") + lines.append(" :selected:") + lines.append("") + lines.append(" Configure using orthogonal dimensions for clearer semantics:") + lines.append("") + lines.append(" .. code-block:: yaml") + lines.append("") + lines.append(" emissionsmethod:") + lines.append(" heat: L11 # obs | L11 | J11 | L11_updated | J19 | J19_updated") + lines.append(" co2: none # none | rectangular | non_rectangular | conductance") + lines.append("") + lines.append(" **heat** options (anthropogenic heat Q\\ :sub:`F` method):") + lines.append("") + lines.append(" * ``obs`` - Use observed Q\\ :sub:`F` from forcing file (only valid when co2=none)") + lines.append(" * ``L11`` - Loridan et al. (2011) SAHP method with air temperature and population density") + lines.append(" * ``J11`` - Järvi et al. (2011) SAHP_2 method with heating/cooling degree days") + lines.append(" * ``L11_updated`` - Modified Loridan method using daily mean air temperature") + lines.append(" * ``J19`` - Järvi et al. (2019) method with building energy, metabolism, and traffic") + lines.append(" * ``J19_updated`` - As J19 but also calculates CO\\ :sub:`2` emissions") + lines.append("") + lines.append(" **co2** options (biogenic CO\\ :sub:`2` flux model):") + lines.append("") + lines.append(" * ``none`` - No biogenic CO\\ :sub:`2` modelling (heat calculation only)") + lines.append(" * ``rectangular`` - Rectangular hyperbola photosynthesis model **(experimental)**") + lines.append(" * ``non_rectangular`` - Non-rectangular hyperbola (Bellucco 2017) **(experimental)**") + lines.append(" * ``conductance`` - Conductance-based photosynthesis (Järvi 2019) **(experimental)**") + lines.append("") + lines.append(" .. note::") + lines.append("") + lines.append(" CO\\ :sub:`2` models (rectangular, non_rectangular, conductance) require a heat") + lines.append(" calculation method (not obs). Use ``heat: obs`` only when ``co2: none``.") + lines.append("") + lines.append(" .. tab-item:: Legacy") + lines.append("") + lines.append(" .. note::") + lines.append("") + lines.append(" The following numeric codes are valid input and fully supported.") + lines.append(" They are planned for deprecation in a future version; the nested format is recommended.") + lines.append("") + lines.append(" **Base codes (no biogenic CO\\ :sub:`2`):**") + lines.append("") + lines.append(" * ``0`` - Observed Q\\ :sub:`F` [→ heat: obs, co2: none]") + lines.append(" * ``1`` - Loridan 2011 SAHP [→ heat: L11, co2: none]") + lines.append(" * ``2`` - Järvi 2011 SAHP_2 [→ heat: J11, co2: none]") + lines.append(" * ``3`` - Loridan updated [→ heat: L11_updated, co2: none]") + lines.append(" * ``4`` - Järvi 2019 [→ heat: J19, co2: none]") + lines.append(" * ``5`` - Järvi 2019 updated [→ heat: J19_updated, co2: none]") + lines.append("") + lines.append(" **Rectangular hyperbola (1x):**") + lines.append("") + lines.append(" * ``11`` - Rectangular + L11 [→ heat: L11, co2: rectangular]") + lines.append(" * ``12`` - Rectangular + J11 [→ heat: J11, co2: rectangular]") + lines.append(" * ``13`` - Rectangular + L11_updated [→ heat: L11_updated, co2: rectangular]") + lines.append(" * ``14`` - Rectangular + J19 [→ heat: J19, co2: rectangular]") + lines.append(" * ``15`` - Rectangular + J19_updated [→ heat: J19_updated, co2: rectangular]") + lines.append("") + lines.append(" **Non-rectangular hyperbola (2x):**") + lines.append("") + lines.append(" * ``21`` - Non-rectangular + L11 [→ heat: L11, co2: non_rectangular]") + lines.append(" * ``22`` - Non-rectangular + J11 [→ heat: J11, co2: non_rectangular]") + lines.append(" * ``23`` - Non-rectangular + L11_updated [→ heat: L11_updated, co2: non_rectangular]") + lines.append(" * ``24`` - Non-rectangular + J19 [→ heat: J19, co2: non_rectangular]") + lines.append(" * ``25`` - Non-rectangular + J19_updated [→ heat: J19_updated, co2: non_rectangular]") + lines.append("") + lines.append(" **Conductance-based (4x):**") + lines.append("") + lines.append(" * ``41`` - Conductance + L11 [→ heat: L11, co2: conductance]") + lines.append(" * ``42`` - Conductance + J11 [→ heat: J11, co2: conductance]") + lines.append(" * ``43`` - Conductance + L11_updated [→ heat: L11_updated, co2: conductance]") + lines.append(" * ``44`` - Conductance + J19 [→ heat: J19, co2: conductance]") + lines.append(" * ``45`` - Conductance + J19_updated [→ heat: J19_updated, co2: conductance]") + lines.append("") + + return lines + def _format_field(self, field_doc: dict[str, Any], model_name: str) -> list[str]: """Format a single field as RST.""" + # Check for dimensional config fields that need tabbed display + if self._is_dimensional_config(field_doc): + return self._format_dimensional_field(field_doc, model_name) + lines = [] field_name = field_doc["name"] type_info = field_doc.get("type_info", {}) @@ -714,9 +926,10 @@ def _generate_index_dropdown(self) -> str: "", ]) - # Add all model files to toctree (excluding RefValue and Reference) + # Add all model files to toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -748,9 +961,10 @@ def _generate_index_simple(self) -> str: "", ]) - # Add all model files to toctree (excluding RefValue and Reference) + # Add all model files to toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -782,9 +996,10 @@ def _generate_index_compact(self) -> str: "", ]) - # Add all model files to toctree (excluding RefValue and Reference) + # Add all model files to toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -854,9 +1069,10 @@ def _generate_preview_index(self) -> str: "", ] - # Add all model files to hidden toctree + # Add all model files to hidden toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -891,9 +1107,10 @@ def _generate_index_tabs(self) -> str: "", ]) - # Add all model files to toctree (excluding RefValue and Reference) + # Add all model files to toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -931,9 +1148,10 @@ def _generate_index_hybrid(self) -> str: "", ]) - # Add all model files to toctree (excluding RefValue and Reference) + # Add all model files to toctree (excluding utility and inline models) + excluded = {"RefValue", "Reference"} | self.INLINE_NESTED_MODELS for model_name in sorted(self.models.keys()): - if model_name not in {"RefValue", "Reference"}: + if model_name not in excluded: lines.append(f" {model_name.lower()}") return "\n".join(lines) @@ -965,9 +1183,11 @@ def _get_model_children(self, model_name: str, depth: int = 0) -> dict: # Stop expanding at certain depth or for profile/utility types profile_types = {"DayProfile", "HourlyProfile", "WeeklyProfile"} utility_types = {"Reference", "RefValue"} + # Inline models are documented in parent field, skip separate expansion + skip_types = utility_types | self.INLINE_NESTED_MODELS # Don't expand profile types or utility types or if we're too deep - if model_name in profile_types or model_name in utility_types or depth > 4: + if model_name in profile_types or model_name in skip_types or depth > 4: return result # Collect all fields - both nested models and simple fields @@ -975,11 +1195,11 @@ def _get_model_children(self, model_name: str, depth: int = 0) -> dict: field_name = field["name"] nested_model = field.get("nested_model") - # Skip Reference and RefValue as they're utility types + # Skip utility types and inline models (documented in parent field) if ( nested_model and nested_model in self.models - and nested_model not in utility_types + and nested_model not in skip_types ): # For profile types, just note the field name without expanding if nested_model in profile_types: diff --git a/pyproject.toml b/pyproject.toml index d192fcd47..bc7bfd68f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,14 @@ markers = [ line-length = 120 target-version = "py39" +[tool.ruff.lint] +# FA102: Catch PEP 604 union syntax (X | None) which requires Python 3.10+ +# This prevents CI failures on Python 3.9 due to runtime TypeError +extend-select = ["FA102"] + +# Note: FA102 auto-fix adds `from __future__ import annotations` which can break Pydantic +# Instead, manually use Optional[X] from typing module (see .claude/rules/python/conventions.md) + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/src/supy/data_model/core/model.py b/src/supy/data_model/core/model.py index bd1e06158..c29f0e5d4 100644 --- a/src/supy/data_model/core/model.py +++ b/src/supy/data_model/core/model.py @@ -1,5 +1,5 @@ import yaml -from typing import Optional, Union, List +from typing import Optional, Union, List, Any import numpy as np from pydantic import ConfigDict, BaseModel, Field, field_validator, model_validator import pandas as pd @@ -8,6 +8,16 @@ from .type import RefValue, Reference, FlexibleRefValue from .type import init_df_state +from .physics_options import ( + NetRadiationMethodConfig, + RadiationPhysics, + LongwaveSource, + NETRAD_MAPPER, + EmissionsMethodConfig, + BiogenicModel, + QFMethod, + EMISSIONS_MAPPER, +) def _enum_description(enum_class: type[Enum]) -> str: @@ -71,6 +81,192 @@ def _enum_description(enum_class: type[Enum]) -> str: return summary +def _coerce_enum_value( + v: Any, enum_class: type[Enum], aliases: Optional[dict[str, str]] = None +) -> Any: + """Coerce string or dict input to enum value (case-insensitive). + + Supports: + - Enum member: returns as-is + - Integer: returns as-is (Pydantic handles) + - String: case-insensitive lookup by member name or alias + - Dict with 'value' key: extracts and processes the value + + Parameters + ---------- + v : Any + Input value to coerce + enum_class : type[Enum] + Target enum class for lookup + aliases : dict[str, str], optional + Short alias → member name mapping (case-insensitive) + + Returns + ------- + Any + Coerced value suitable for Pydantic validation + """ + if v is None or isinstance(v, enum_class): + return v + + # Handle dict with 'value' key + if isinstance(v, dict) and "value" in v: + v = v["value"] + + # Handle string (case-insensitive lookup by alias, then member name) + if isinstance(v, str): + v_upper = v.upper() + + # Check aliases first + if aliases: + for alias, member_name in aliases.items(): + if alias.upper() == v_upper: + return enum_class[member_name] + + # Then check member names + for member in enum_class: + if member.name.upper() == v_upper: + return member + # If no match, let Pydantic handle (will raise appropriate error) + + return v + + +# ============================================================================= +# Physics Option Aliases +# ============================================================================= +# +# Naming Convention for Physics Options +# ------------------------------------- +# Physics options support multiple input formats for flexibility: +# +# 1. NUMERIC CODES (legacy, still supported) +# - Integer values matching Fortran interface +# - Example: storageheatmethod: 1 +# +# 2. EXPLICIT MODEL NAMES (preferred for named models) +# - Use the established model acronym in lowercase +# - Examples: +# - ohm: Objective Hysteresis Model +# - anohm: Analytical OHM +# - estm: Element Surface Temperature Method +# - ehc: Explicit Heat Conduction +# - dyohm: Dynamic OHM +# - narp: Net All-wave Radiation Parameterization +# - most: Monin-Obukhov Similarity Theory +# - rst: Roughness Sublayer Theory +# +# 3. AUTHOR-YEAR FORMAT (Xyy) for methods without model names +# - Format: First letter of first author surname + two-digit year +# - Case-insensitive (K09, k09, K09 all work) +# - Examples: +# - K09: Kawai et al. 2009 (thermal roughness) +# - CN98: Campbell & Norman 1998 (stability functions) +# - W16: Ward et al. 2016 (stomatal conductance) +# - J11: Järvi et al. 2011 (stomatal conductance, QF) +# - B82: Brutsaert 1982 (thermal roughness) +# - M98: MacDonald et al. 1998 (momentum roughness) +# - GO99: Grimmond & Oke 1999 (roughness length) +# +# 4. GENERIC DESCRIPTIVE TERMS (for simple choices) +# - fixed, variable, auto: method selection +# - model, obs: modelled vs observed +# - provided, calc: use input vs calculate +# +# 5. BINARY OPTIONS (yes/no) +# - ohmincqf: yes/no (include QF in OHM) +# - snowuse: yes/no (enable snow module) +# +# All string inputs are case-insensitive. +# ============================================================================= + +# Maps short alias → enum member name +STORAGE_HEAT_ALIASES = { + "obs": "OBSERVED", + "ohm": "OHM_WITHOUT_QF", + "anohm": "ANOHM", # Analytical OHM + "estm": "ESTM", # Element Surface Temperature Method + "ehc": "EHC", # Explicit Heat Conduction + "dyohm": "DyOHM", # Dynamic OHM + "stebbs": "STEBBS", +} + +OHM_INC_QF_ALIASES = { + "no": "EXCLUDE", + "yes": "INCLUDE", +} + +ROUGHLEN_MOM_ALIASES = { + "fixed": "FIXED", + "variable": "VARIABLE", + "M98": "MACDONALD", # MacDonald et al. 1998 + "GO99": "LAMBDAP_DEPENDENT", # Grimmond & Oke 1999 +} + +ROUGHLEN_HEAT_ALIASES = { + "B82": "BRUTSAERT", # Brutsaert 1982 + "K09": "KAWAI", # Kawai et al. 2009 + "K07": "KANDA", # Kanda et al. 2007 + "auto": "ADAPTIVE", +} + +STABILITY_ALIASES = { + "H88": "HOEGSTROM", # Högström 1988 + "CN98": "CAMPBELL_NORMAN", # Campbell & Norman 1998 + "B71": "BUSINGER_HOEGSTROM", # Businger et al. 1971 +} + +SMD_ALIASES = { + "model": "MODELLED", + "obs_vol": "OBSERVED_VOLUMETRIC", + "obs_grav": "OBSERVED_GRAVIMETRIC", +} + +WATER_USE_ALIASES = { + "model": "MODELLED", + "obs": "OBSERVED", +} + +RSL_ALIASES = { + "most": "MOST", + "rst": "RST", + "auto": "VARIABLE", +} + +FAI_ALIASES = { + "provided": "USE_PROVIDED", + "calc": "SIMPLE_SCHEME", +} + +RSL_LEVEL_ALIASES = { + "off": "NONE", + "basic": "BASIC", + "full": "DETAILED", +} + +GS_MODEL_ALIASES = { + "J11": "JARVI", # Järvi et al. 2011 + "W16": "WARD", # Ward et al. 2016 +} + +SNOW_USE_ALIASES = { + "no": "DISABLED", + "yes": "ENABLED", +} + +STEBBS_ALIASES = { + "off": "NONE", + "default": "DEFAULT", + "custom": "PROVIDED", +} + +RC_ALIASES = { + "off": "NONE", + "basic": "BASIC", + "full": "DETAILED", +} + + class EmissionsMethod(Enum): """ Method for calculating anthropogenic heat flux (QF) and CO2 emissions. @@ -174,6 +370,37 @@ def __int__(self): def __repr__(self): return str(self.value) + @property + def model(self) -> str: + """Return model dimension: obs, narp, or spartacus. + + Returns + ------- + str + 'obs' for observed, 'narp' for NARP, 'spartacus' for SPARTACUS + """ + if self.value == 0: + return "obs" + elif self.value >= 1000: + return "spartacus" + else: + return "narp" + + @property + def ldown(self) -> Optional[str]: + """Return ldown dimension: obs, cloud, air, or None. + + Returns + ------- + str or None + L↓ source, or None for observed model + """ + if self.value == 0: + return None + # Extract last digit for ldown code + lw_code = self.value % 10 + return {1: "obs", 2: "cloud", 3: "air"}.get(lw_code) + class StorageHeatMethod(Enum): """ @@ -557,16 +784,97 @@ class ModelPhysics(BaseModel): model_config = ConfigDict(title="Physics Methods") - netradiationmethod: FlexibleRefValue(NetRadiationMethod) = Field( - default=NetRadiationMethod.LDOWN_AIR, - description=_enum_description(NetRadiationMethod), + netradiationmethod: NetRadiationMethodConfig = Field( + default_factory=lambda: NetRadiationMethodConfig( + scheme=RadiationPhysics.NARP, ldown=LongwaveSource.AIR + ), + description=( + "Method for calculating net all-wave radiation (Q*). " + "Uses orthogonal dimensions: scheme (obs/narp/spartacus) and " + "ldown source (obs/cloud/air). See nested structure for details." + ), json_schema_extra={"unit": "dimensionless"}, ) - emissionsmethod: FlexibleRefValue(EmissionsMethod) = Field( - default=EmissionsMethod.J11, - description=_enum_description(EmissionsMethod), + + @field_validator("netradiationmethod", mode="before") + @classmethod + def coerce_netradiationmethod(cls, v: Any) -> Any: + """Accept legacy and dimension-based forms for netradiationmethod. + + Accepted forms: + - Dimension-based: {"scheme": "narp", "ldown": "air"} + - Legacy RefValue: {"value": 3} + - Plain integer: 3 + - Enum member: NetRadiationMethod.LDOWN_AIR + - Already a NetRadiationMethodConfig instance + """ + if v is None or isinstance(v, NetRadiationMethodConfig): + return v + + # Dimension-based form: {scheme: narp, ldown: air} + if isinstance(v, dict): + if "scheme" in v: + return v # Pass to Pydantic for validation + + # Legacy form: {value: N} + if "value" in v: + code = int(v["value"]) + scheme_val, ldown_val = NETRAD_MAPPER.from_code(code) + result: dict[str, Any] = {"scheme": scheme_val} + if ldown_val: + result["ldown"] = ldown_val + return result + + # Plain int or enum + code = v.value if isinstance(v, Enum) else int(v) + scheme_val, ldown_val = NETRAD_MAPPER.from_code(code) + result = {"scheme": scheme_val} + if ldown_val: + result["ldown"] = ldown_val + return result + + emissionsmethod: EmissionsMethodConfig = Field( + default_factory=lambda: EmissionsMethodConfig( + heat=QFMethod.J11, co2=BiogenicModel.NONE + ), + description=( + "Method for calculating anthropogenic heat flux and CO2 emissions. " + "Uses orthogonal dimensions: heat (obs/L11/J11/L11_updated/J19/J19_updated) " + "and co2 (none/rectangular/non_rectangular/conductance). See nested structure for details." + ), json_schema_extra={"unit": "dimensionless"}, ) + + @field_validator("emissionsmethod", mode="before") + @classmethod + def coerce_emissionsmethod(cls, v: Any) -> Any: + """Accept legacy and dimension-based forms for emissionsmethod. + + Accepted forms: + - Dimension-based: {"heat": "L11", "co2": "none"} + - Legacy RefValue: {"value": 1} + - Plain integer: 1 + - Already an EmissionsMethodConfig instance + """ + if v is None or isinstance(v, EmissionsMethodConfig): + return v + + # Dimension-based form: {heat: L11, co2: none} + if isinstance(v, dict): + if "heat" in v or "co2" in v: + return v # Pass to Pydantic for validation + + # Legacy form: {value: N} + if "value" in v: + code = int(v["value"]) + co2_val, heat_val = EMISSIONS_MAPPER.from_code(code) + return {"heat": heat_val, "co2": co2_val} + + # Plain int + code = int(v) + co2_val, heat_val = EMISSIONS_MAPPER.from_code(code) + return {"heat": heat_val, "co2": co2_val} + storageheatmethod: FlexibleRefValue(StorageHeatMethod) = Field( default=StorageHeatMethod.OHM_WITHOUT_QF, description=_enum_description(StorageHeatMethod), @@ -657,6 +965,91 @@ class ModelPhysics(BaseModel): ) ref: Optional[Reference] = None + # Validators for case-insensitive string name support (with short aliases) + @field_validator("storageheatmethod", mode="before") + @classmethod + def coerce_storageheatmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for storageheatmethod.""" + return _coerce_enum_value(v, StorageHeatMethod, STORAGE_HEAT_ALIASES) + + @field_validator("ohmincqf", mode="before") + @classmethod + def coerce_ohmincqf(cls, v: Any) -> Any: + """Accept string names and short aliases (yes/no) for ohmincqf.""" + return _coerce_enum_value(v, OhmIncQf, OHM_INC_QF_ALIASES) + + @field_validator("roughlenmommethod", mode="before") + @classmethod + def coerce_roughlenmommethod(cls, v: Any) -> Any: + """Accept string names and short aliases for roughlenmommethod.""" + return _coerce_enum_value(v, MomentumRoughnessMethod, ROUGHLEN_MOM_ALIASES) + + @field_validator("roughlenheatmethod", mode="before") + @classmethod + def coerce_roughlenheatmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for roughlenheatmethod.""" + return _coerce_enum_value(v, HeatRoughnessMethod, ROUGHLEN_HEAT_ALIASES) + + @field_validator("stabilitymethod", mode="before") + @classmethod + def coerce_stabilitymethod(cls, v: Any) -> Any: + """Accept string names and short aliases for stabilitymethod.""" + return _coerce_enum_value(v, StabilityMethod, STABILITY_ALIASES) + + @field_validator("smdmethod", mode="before") + @classmethod + def coerce_smdmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for smdmethod.""" + return _coerce_enum_value(v, SMDMethod, SMD_ALIASES) + + @field_validator("waterusemethod", mode="before") + @classmethod + def coerce_waterusemethod(cls, v: Any) -> Any: + """Accept string names and short aliases for waterusemethod.""" + return _coerce_enum_value(v, WaterUseMethod, WATER_USE_ALIASES) + + @field_validator("rslmethod", mode="before") + @classmethod + def coerce_rslmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for rslmethod.""" + return _coerce_enum_value(v, RSLMethod, RSL_ALIASES) + + @field_validator("faimethod", mode="before") + @classmethod + def coerce_faimethod(cls, v: Any) -> Any: + """Accept string names and short aliases for faimethod.""" + return _coerce_enum_value(v, FAIMethod, FAI_ALIASES) + + @field_validator("rsllevel", mode="before") + @classmethod + def coerce_rsllevel(cls, v: Any) -> Any: + """Accept string names and short aliases for rsllevel.""" + return _coerce_enum_value(v, RSLLevel, RSL_LEVEL_ALIASES) + + @field_validator("gsmodel", mode="before") + @classmethod + def coerce_gsmodel(cls, v: Any) -> Any: + """Accept string names and short aliases for gsmodel.""" + return _coerce_enum_value(v, GSModel, GS_MODEL_ALIASES) + + @field_validator("snowuse", mode="before") + @classmethod + def coerce_snowuse(cls, v: Any) -> Any: + """Accept string names and short aliases (yes/no) for snowuse.""" + return _coerce_enum_value(v, SnowUse, SNOW_USE_ALIASES) + + @field_validator("stebbsmethod", mode="before") + @classmethod + def coerce_stebbsmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for stebbsmethod.""" + return _coerce_enum_value(v, StebbsMethod, STEBBS_ALIASES) + + @field_validator("rcmethod", mode="before") + @classmethod + def coerce_rcmethod(cls, v: Any) -> Any: + """Accept string names and short aliases for rcmethod.""" + return _coerce_enum_value(v, RCMethod, RC_ALIASES) + # We then need to set to 0 (or None) all the CO2-related parameters or rules # in the code and return them accordingly in the yml file. @@ -665,12 +1058,17 @@ def to_df_state(self, grid_id: int) -> pd.DataFrame: df_state = init_df_state(grid_id) # Helper function to set values in DataFrame - def set_df_value(col_name: str, value: float): + def set_df_value(col_name: str, value: Any): idx_str = "0" if (col_name, idx_str) not in df_state.columns: - # df_state[(col_name, idx_str)] = np.nan df_state[(col_name, idx_str)] = None - val = value.value if isinstance(value, RefValue) else value + # Handle different value types + if isinstance(value, NetRadiationMethodConfig): + val = value.int_value + elif isinstance(value, RefValue): + val = value.value + else: + val = value df_state.at[grid_id, (col_name, idx_str)] = int(val) list_attr = [ @@ -731,7 +1129,14 @@ def from_df_state(cls, df: pd.DataFrame, grid_id: int) -> "ModelPhysics": for attr in list_attr: try: - properties[attr] = RefValue(int(df.loc[grid_id, (attr, "0")])) + int_val = int(df.loc[grid_id, (attr, "0")]) + # Handle dimensional config methods specially + if attr == "netradiationmethod": + properties[attr] = NetRadiationMethodConfig.from_code(int_val) + elif attr == "emissionsmethod": + properties[attr] = EmissionsMethodConfig.from_code(int_val) + else: + properties[attr] = RefValue(int_val) except KeyError: raise ValueError(f"Missing attribute '{attr}' in the DataFrame") diff --git a/src/supy/data_model/core/physics_options.py b/src/supy/data_model/core/physics_options.py new file mode 100644 index 000000000..8613c3104 --- /dev/null +++ b/src/supy/data_model/core/physics_options.py @@ -0,0 +1,520 @@ +"""Nested physics option containers with orthogonal dimensions. + +This module provides configuration containers for physics methods that decompose +numeric codes into orthogonal dimensions, enabling cleaner YAML configuration +whilst maintaining backward compatibility with legacy formats. + +Example: + New dimension-based format:: + + netradiationmethod: + scheme: narp + ldown: air + + emissionsmethod: + heat: L11 + co2: none + + Legacy format (still supported):: + + netradiationmethod: + value: 3 + + emissionsmethod: + value: 1 +""" + +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any, Tuple, TypeVar +from pydantic import BaseModel, Field, model_validator +from enum import Enum + +from .type import Reference + + +# Type variable for from_code return type +T = TypeVar("T", bound="PhysicsOptionConfig") + + +class CodeMapper: + """Bidirectional mapper between dimension tuples and numeric codes. + + Eliminates duplication in code-to-dimensions and dimensions-to-code + conversion logic across different physics option configurations. + + Parameters + ---------- + forward_map : Dict[Tuple, int] + Mapping from dimension tuples to numeric codes + name : str + Name for error messages (e.g., "netradiationmethod") + aliases : Dict[int, Tuple], optional + Additional code-to-dimensions mappings for deprecated codes + + Examples + -------- + >>> mapper = CodeMapper({("narp", "air"): 3}, "netradiationmethod") + >>> mapper.to_code("narp", "air") + 3 + >>> mapper.from_code(3) + ('narp', 'air') + """ + + def __init__( + self, + forward_map: Dict[Tuple, int], + name: str, + aliases: Optional[Dict[int, Tuple]] = None, + ): + self._forward = forward_map + self._reverse: Dict[int, Tuple] = {v: k for k, v in forward_map.items()} + if aliases: + self._reverse.update(aliases) + self._name = name + + def to_code(self, *dims) -> int: + """Convert dimensions to numeric code. + + Parameters + ---------- + *dims + Dimension values as positional arguments + + Returns + ------- + int + Numeric code for Fortran interface + + Raises + ------ + ValueError + If dimension combination is invalid + """ + key = dims + if key not in self._forward: + raise ValueError( + f"Invalid {self._name} combination: {key}. " + f"Valid combinations: {list(self._forward.keys())}" + ) + return self._forward[key] + + def from_code(self, code: int) -> Tuple: + """Convert numeric code to dimensions. + + Parameters + ---------- + code : int + Numeric code + + Returns + ------- + Tuple + Dimension values + + Raises + ------ + ValueError + If code is not valid + """ + if code not in self._reverse: + valid_codes = sorted(self._reverse.keys()) + raise ValueError( + f"Unknown {self._name} code: {code}. Valid codes are: {valid_codes}" + ) + return self._reverse[code] + + +class PhysicsOptionConfig(BaseModel, ABC): + """Abstract base class for physics option configurations with orthogonal dimensions. + + Provides a consistent interface for physics method configurations that decompose + numeric codes into semantic dimensions. Subclasses implement specific dimension + fields and code mapping logic. + + Attributes + ---------- + ref : Optional[Reference] + Optional reference information for documentation or provenance + + Examples + -------- + Subclasses must implement:: + + @property + def int_value(self) -> int: + ... + + @classmethod + def from_code(cls, code: int) -> Self: + ... + """ + + ref: Optional[Reference] = None + + @property + @abstractmethod + def int_value(self) -> int: + """Get numeric code for Fortran interface. + + Returns + ------- + int + Numeric code understood by the Fortran backend + """ + ... + + def __int__(self) -> int: + """Enable int(config) to get numeric code.""" + return self.int_value + + @classmethod + @abstractmethod + def from_code(cls: type[T], code: int) -> T: + """Create configuration from legacy numeric code. + + Parameters + ---------- + code : int + Legacy numeric code + + Returns + ------- + PhysicsOptionConfig + Configuration instance with appropriate dimension settings + """ + ... + + +class RadiationPhysics(str, Enum): + """Radiation physics model selection. + + Determines which algorithm is used to calculate net all-wave radiation (Q*). + + Attributes: + OBS: Use observed Q* values directly from forcing file + NARP: NARP parameterisation (Offerle et al. 2003, Loridan et al. 2011) + SPARTACUS: SPARTACUS-Surface integration (experimental) + """ + + OBS = "obs" + NARP = "narp" + SPARTACUS = "spartacus" + + +class LongwaveSource(str, Enum): + """Longwave downward radiation (L down) source. + + Determines how incoming longwave radiation is obtained when using + NARP or SPARTACUS physics models. + + Attributes: + OBS: Use observed L down from forcing file + CLOUD: Model L down from cloud cover fraction + AIR: Model L down from air temperature and relative humidity + """ + + OBS = "obs" + CLOUD = "cloud" + AIR = "air" + + +# Net radiation method code mapper +NETRAD_MAPPER = CodeMapper( + forward_map={ + ("obs", None): 0, + ("narp", "obs"): 1, + ("narp", "cloud"): 2, + ("narp", "air"): 3, + ("spartacus", "obs"): 1001, + ("spartacus", "cloud"): 1002, + ("spartacus", "air"): 1003, + }, + name="netradiationmethod", + aliases={ + # Deprecated variants (map to narp equivalents for backward compatibility) + # Surface temperature variants (11-13) and zenith correction variants (100-300) + 11: ("narp", "obs"), + 12: ("narp", "cloud"), + 13: ("narp", "air"), + 100: ("narp", "obs"), + 200: ("narp", "cloud"), + 300: ("narp", "air"), + }, +) + + +class NetRadiationMethodConfig(PhysicsOptionConfig): + """Net radiation method configuration with orthogonal dimensions. + + Decomposes the numeric netradiationmethod into independent dimensions: + + - scheme: The radiation calculation scheme (obs, narp, spartacus) + - ldown: The L↓ source (obs, cloud, air) - required when scheme != obs + + This structure enables cleaner YAML configuration whilst maintaining + full backward compatibility with legacy numeric codes. + + Parameters + ---------- + scheme : RadiationPhysics + Radiation scheme selection + ldown : Optional[LongwaveSource] + L↓ source (required when scheme is narp or spartacus) + ref : Optional[Reference] + Optional reference information (inherited from PhysicsOptionConfig) + + Examples + -------- + Dimension-based form (recommended):: + + config = NetRadiationMethodConfig( + scheme=RadiationPhysics.NARP, + ldown=LongwaveSource.AIR + ) + + From legacy code:: + + config = NetRadiationMethodConfig.from_code(3) + """ + + scheme: RadiationPhysics = Field( + default=RadiationPhysics.NARP, + description="Radiation scheme: obs (forcing), narp, or spartacus", + ) + ldown: Optional[LongwaveSource] = Field( + default=LongwaveSource.AIR, + description="L↓ source: obs, cloud, or air (required when scheme != obs)", + ) + + @model_validator(mode="after") + def validate_ldown_requirement(self) -> "NetRadiationMethodConfig": + """Validate ldown is set appropriately for scheme choice.""" + if self.scheme == RadiationPhysics.OBS: + # L↓ not applicable for observed Q* + if self.ldown is not None: + # Silently clear ldown for obs scheme + object.__setattr__(self, "ldown", None) + else: + # L↓ required for narp/spartacus + if self.ldown is None: + raise ValueError( + f"'ldown' is required when scheme={self.scheme.value}. " + f"Choose from: obs, cloud, air" + ) + return self + + @property + def int_value(self) -> int: + """Get numeric code for Fortran interface. + + Returns + ------- + int + Numeric code (0, 1, 2, 3, 1001, 1002, 1003) + """ + ldown_val = self.ldown.value if self.ldown else None + return NETRAD_MAPPER.to_code(self.scheme.value, ldown_val) + + def model_dump(self, **kwargs) -> Dict[str, Any]: + """Output nested dimension form for JSON/YAML. + + When mode='json', outputs the clean dimension-based form:: + + {"scheme": "narp", "ldown": "air"} + + Otherwise uses default Pydantic serialisation. + """ + if kwargs.get("mode") == "json": + result: Dict[str, Any] = {"scheme": self.scheme.value} + if self.ldown is not None: + result["ldown"] = self.ldown.value + return result + return super().model_dump(**kwargs) + + @classmethod + def from_code(cls, code: int) -> "NetRadiationMethodConfig": + """Create configuration from legacy numeric code. + + Parameters + ---------- + code : int + Legacy numeric code (0, 1, 2, 3, 11-13, 100-300, 1001-1003) + + Returns + ------- + NetRadiationMethodConfig + Configuration with appropriate scheme and ldown settings + """ + scheme_val, ldown_val = NETRAD_MAPPER.from_code(code) + return cls( + scheme=RadiationPhysics(scheme_val), + ldown=LongwaveSource(ldown_val) if ldown_val else None, + ) + + +# ============================================================================= +# Emissions Method Configuration +# ============================================================================= + + +class BiogenicModel(str, Enum): + """Biogenic CO2 flux model selection. + + Determines which photosynthesis model is used to calculate biogenic CO2 fluxes. + + Attributes: + NONE: No biogenic CO2 modelling (QF calculation only) + RECTANGULAR: Rectangular hyperbola photosynthesis model (experimental) + NON_RECTANGULAR: Non-rectangular hyperbola (Bellucco 2017) (experimental) + CONDUCTANCE: Conductance-based photosynthesis (Järvi 2019) (experimental) + """ + + NONE = "none" + RECTANGULAR = "rectangular" + NON_RECTANGULAR = "non_rectangular" + CONDUCTANCE = "conductance" + + +class QFMethod(str, Enum): + """Anthropogenic heat flux (QF) calculation method. + + Determines which algorithm is used to calculate anthropogenic heat flux. + + Attributes: + OBS: Use observed QF values from forcing file + L11: Loridan et al. (2011) SAHP method with air temperature and population density + J11: Järvi et al. (2011) SAHP_2 method with heating/cooling degree days + L11_UPDATED: Modified Loridan method using daily mean air temperature (internal) + J19: Järvi et al. (2019) method with building energy, metabolism, and traffic + J19_UPDATED: As J19 but also calculates CO2 emissions (internal) + """ + + OBS = "obs" + L11 = "L11" + J11 = "J11" + L11_UPDATED = "L11_updated" + J19 = "J19" + J19_UPDATED = "J19_updated" + + +# Emissions method code mapper +# Build forward mapping programmatically from QF values and biogenic multipliers +def _build_emissions_map() -> Dict[Tuple[str, str], int]: + """Build emissions method code mapping.""" + qf_values = {"obs": 0, "L11": 1, "J11": 2, "L11_updated": 3, "J19": 4, "J19_updated": 5} + bio_multipliers = {"none": 0, "rectangular": 1, "non_rectangular": 2, "conductance": 4} + + mapping: Dict[Tuple[str, str], int] = {} + for bio, bio_mult in bio_multipliers.items(): + for qf, qf_val in qf_values.items(): + if bio == "none": + # Base codes 0-5 + mapping[(bio, qf)] = qf_val + elif qf != "obs": + # Biogenic codes: bio_mult * 10 + qf_val (but only 1-5, not 0) + mapping[(bio, qf)] = bio_mult * 10 + qf_val + return mapping + + +EMISSIONS_MAPPER = CodeMapper( + forward_map=_build_emissions_map(), + name="emissionsmethod", +) + + +class EmissionsMethodConfig(PhysicsOptionConfig): + """Emissions method configuration with orthogonal dimensions. + + Decomposes the numeric emissionsmethod into independent dimensions: + + - heat: The anthropogenic heat flux method (obs, L11, J11, L11_updated, J19, J19_updated) + - co2: The biogenic CO2 flux model (none, rectangular, non_rectangular, conductance) + + This structure enables cleaner YAML configuration whilst maintaining + full backward compatibility with legacy numeric codes. + + Parameters + ---------- + heat : QFMethod + Anthropogenic heat flux calculation method + co2 : BiogenicModel + Biogenic CO2 flux model selection + ref : Optional[Reference] + Optional reference information (inherited from PhysicsOptionConfig) + + Examples + -------- + Dimension-based form (recommended):: + + config = EmissionsMethodConfig( + heat=QFMethod.L11, + co2=BiogenicModel.NONE + ) + + From legacy code:: + + config = EmissionsMethodConfig.from_code(1) + """ + + heat: QFMethod = Field( + default=QFMethod.L11, + description="Anthropogenic heat method: obs, L11, J11, L11_updated, J19, or J19_updated", + ) + co2: BiogenicModel = Field( + default=BiogenicModel.NONE, + description="Biogenic CO2 model: none, rectangular, non_rectangular, or conductance", + ) + + @model_validator(mode="after") + def validate_heat_co2_combination(self) -> "EmissionsMethodConfig": + """Validate CO2 models require non-obs heat method.""" + if self.co2 != BiogenicModel.NONE and self.heat == QFMethod.OBS: + raise ValueError( + f"CO2 model '{self.co2.value}' requires a heat calculation method. " + f"'heat: obs' is only valid when 'co2: none'." + ) + return self + + @property + def int_value(self) -> int: + """Get numeric code for Fortran interface. + + Returns + ------- + int + Numeric code (0-5, 11-15, 21-25, 41-45) + """ + return EMISSIONS_MAPPER.to_code(self.co2.value, self.heat.value) + + def model_dump(self, **kwargs) -> Dict[str, Any]: + """Output nested dimension form for JSON/YAML. + + When mode='json', outputs the clean dimension-based form:: + + {"heat": "L11", "co2": "none"} + + Otherwise uses default Pydantic serialisation. + """ + if kwargs.get("mode") == "json": + return {"heat": self.heat.value, "co2": self.co2.value} + return super().model_dump(**kwargs) + + @classmethod + def from_code(cls, code: int) -> "EmissionsMethodConfig": + """Create configuration from legacy numeric code. + + Parameters + ---------- + code : int + Legacy numeric code (0-5, 11-15, 21-25, 41-45) + + Returns + ------- + EmissionsMethodConfig + Configuration with appropriate heat and co2 settings + """ + biogenic, qf = EMISSIONS_MAPPER.from_code(code) + return cls( + heat=QFMethod(qf), + co2=BiogenicModel(biogenic), + ) diff --git a/src/supy/data_model/core/site.py b/src/supy/data_model/core/site.py index 834d25f13..409d0a8dc 100644 --- a/src/supy/data_model/core/site.py +++ b/src/supy/data_model/core/site.py @@ -838,7 +838,7 @@ class GrassProperties(VegetatedSurfaceProperties): and maintenance significantly affect their water use and energy balance. """ - model_config = ConfigDict(title="Grass") + model_config = ConfigDict(title="Grass Surface") alb: FlexibleRefValue(float) = Field( ge=0, le=1, diff --git a/src/supy/data_model/doc_utils.py b/src/supy/data_model/doc_utils.py index c01caad97..cf168ae89 100644 --- a/src/supy/data_model/doc_utils.py +++ b/src/supy/data_model/doc_utils.py @@ -80,6 +80,7 @@ def _discover_models(self) -> None: "supy.data_model.core.profile", "supy.data_model.core.config", "supy.data_model.core.model", + "supy.data_model.core.physics_options", "supy.data_model.core.ohm", "supy.data_model.core.hydro", "supy.data_model.core.human_activity", @@ -199,9 +200,11 @@ def _extract_field( # Extract enum options enum_class = self._get_enum_class(field_info, field_type) - if enum_class: + description = field_info.description or "" + # Try to extract options if enum class found OR description contains Options: + if enum_class or "Options:" in description: options = self._extract_enum_options( - field_info.description or "", enum_class, include_internal + description, enum_class, include_internal ) if options: field_doc["options"] = options @@ -319,25 +322,33 @@ def _extract_constraints( return constraints if constraints else None def _extract_enum_options( - self, description: str, enum_class: Type[Enum], include_internal: bool + self, description: str, enum_class: Optional[Type[Enum]], include_internal: bool ) -> List[Dict[str, Any]]: - """Extract options from enum class and description.""" - if "Options:" not in description: - return [] - + """Extract options from description string or directly from enum class.""" import re options = [] - options_text = description.split("Options:", 1)[1].strip() - for opt_text in options_text.split(";"): - opt_text = opt_text.strip() - if not opt_text: - continue + # Strategy 1: Parse from description if "Options:" present + if "Options:" in description: + options_text = description.split("Options:", 1)[1].strip() + + # Use regex to find option boundaries (NUMBER (NAME) = pattern) + # This handles semicolons inside descriptions correctly + option_pattern = r"(\d+(?:-\d+)?)\s*\(([^)]+)\)\s*=\s*" + matches = list(re.finditer(option_pattern, options_text)) - match = re.match(r"^(\d+(?:-\d+)?)\s*\(([^)]+)\)\s*=\s*(.+)$", opt_text) - if match: - num, name, desc = match.groups() + for i, match in enumerate(matches): + num = match.group(1) + name = match.group(2) + + # Description starts after the match and ends at next option or string end + desc_start = match.end() + if i + 1 < len(matches): + desc_end = matches[i + 1].start() + desc = options_text[desc_start:desc_end].rstrip("; ") + else: + desc = options_text[desc_start:].strip() # Check if internal if not include_internal and self._is_internal_option(num, enum_class): @@ -349,8 +360,58 @@ def _extract_enum_options( "description": desc.strip(), }) + # Strategy 2: Extract directly from enum class if no Options in description + elif enum_class: + # Try to extract descriptions from enum docstring Attributes section + enum_descriptions = self._parse_enum_docstring(enum_class) + + for member in enum_class: + # Skip internal options + if not include_internal and getattr(member, "_internal", False): + continue + + options.append({ + "value": member.value, + "name": member.name, + "description": enum_descriptions.get(member.name, ""), + }) + return options + def _parse_enum_docstring(self, enum_class: Type[Enum]) -> Dict[str, str]: + """Parse enum docstring to extract member descriptions from Attributes section.""" + descriptions: Dict[str, str] = {} + + if not enum_class.__doc__: + return descriptions + + doc = inspect.cleandoc(enum_class.__doc__) + lines = doc.split("\n") + + in_attributes = False + current_name = None + + for line in lines: + stripped = line.strip() + + # Check for Attributes section + if stripped == "Attributes:": + in_attributes = True + continue + + if in_attributes: + # Check for new member definition (NAME: description) + if ":" in stripped and not stripped.startswith(" "): + parts = stripped.split(":", 1) + if len(parts) == 2: + current_name = parts[0].strip() + descriptions[current_name] = parts[1].strip() + # Continuation of previous description + elif current_name and stripped: + descriptions[current_name] += " " + stripped + + return descriptions + def _is_internal_option(self, num: str, enum_class: Optional[Type[Enum]]) -> bool: """Check if an option is marked as internal.""" if not enum_class: diff --git a/src/supy/meson.build b/src/supy/meson.build index e4ae9bf99..3244d4112 100644 --- a/src/supy/meson.build +++ b/src/supy/meson.build @@ -76,6 +76,7 @@ py.install_sources( 'data_model/core/hydro.py', 'data_model/core/model.py', 'data_model/core/ohm.py', + 'data_model/core/physics_options.py', 'data_model/core/profile.py', 'data_model/core/site.py', 'data_model/core/state.py', diff --git a/src/supy/sample_data/sample_config.yml b/src/supy/sample_data/sample_config.yml index ebadb759e..0ae8c4d73 100644 --- a/src/supy/sample_data/sample_config.yml +++ b/src/supy/sample_data/sample_config.yml @@ -15,38 +15,28 @@ model: - "SUEWS" diagnose: 0 physics: + # Multi-dimensional: nested format with scheme + dimension netradiationmethod: - value: 3 + scheme: narp + ldown: air emissionsmethod: - value: 2 - storageheatmethod: - value: 1 - ohmincqf: - value: 0 - roughlenmommethod: - value: 1 - roughlenheatmethod: - value: 2 - stabilitymethod: - value: 3 - smdmethod: - value: 0 - waterusemethod: - value: 0 - rslmethod: - value: 2 - faimethod: - value: 0 - rsllevel: - value: 1 - gsmodel: - value: 2 - snowuse: - value: 0 - stebbsmethod: - value: 0 - rcmethod: - value: 0 + heat: J11 + co2: none + # Single-dimensional: model names or Xyy (author initial + year) + storageheatmethod: ohm + ohmincqf: no + roughlenmommethod: fixed + roughlenheatmethod: K09 + stabilitymethod: CN98 + smdmethod: model + waterusemethod: model + rslmethod: auto + faimethod: provided + rsllevel: basic + gsmodel: W16 + snowuse: no + stebbsmethod: off + rcmethod: off sites: - name: KCL gridiv: 1 diff --git a/test/data_model/test_data_model.py b/test/data_model/test_data_model.py index 4e1b6f931..ee6aba8db 100644 --- a/test/data_model/test_data_model.py +++ b/test/data_model/test_data_model.py @@ -22,7 +22,8 @@ SiteProperties, SUEWSConfig, ) -from supy.data_model.core.model import ModelControl +from supy.data_model.core.model import ModelControl, ModelPhysics +from supy.data_model.core.physics_options import NetRadiationMethodConfig, RadiationPhysics class TestSUEWSConfig(unittest.TestCase): @@ -53,8 +54,8 @@ def test_config_conversion_cycle(self): self.config.model.control.tstep, config_reconst.model.control.tstep ) self.assertEqual( - self.config.model.physics.netradiationmethod.value, - config_reconst.model.physics.netradiationmethod.value, + self.config.model.physics.netradiationmethod.int_value, + config_reconst.model.physics.netradiationmethod.int_value, ) self.assertEqual( self.config.sites[0].properties.lat.value, @@ -788,3 +789,30 @@ def test_update_forcing_with_refvalue_string_critical(self): # Verify that forcing data was loaded assert simulation._df_forcing is not None assert len(simulation._df_forcing) > 0 + + +class TestNetRadiationMethodConfig: + """Essential tests for nested netradiationmethod configuration (GH#972).""" + + @pytest.mark.smoke + def test_roundtrip_preserves_value(self): + """Test DataFrame roundtrip preserves netradiationmethod value.""" + original = ModelPhysics( + netradiationmethod={"scheme": "spartacus", "ldown": "obs"} + ) + df = original.to_df_state(grid_id=1) + reconst = ModelPhysics.from_df_state(df, grid_id=1) + assert reconst.netradiationmethod.int_value == original.netradiationmethod.int_value + + def test_backward_compat_dimension_form(self): + """Test dimension-based form is accepted.""" + physics = ModelPhysics( + netradiationmethod={"scheme": "narp", "ldown": "air"} + ) + assert physics.netradiationmethod.int_value == 3 + assert physics.netradiationmethod.scheme == RadiationPhysics.NARP + + def test_backward_compat_legacy_int(self): + """Test legacy integer form is accepted.""" + physics = ModelPhysics(netradiationmethod=3) + assert physics.netradiationmethod.int_value == 3