forked from Urban-Meteorology-Reading/SUEWS
-
Notifications
You must be signed in to change notification settings - Fork 10
Feat: Sub-options for model physics options #972
Copy link
Copy link
Labels
1-featureNew functionalityNew functionality2-infra:data-modelPydantic data models & validationPydantic data models & validation2-infra:inputInput data handling and validationInput data handling and validation3-P2Medium priorityMedium priority4-in-progressBeing worked onBeing worked on
Description
Proposed feature
Allow model physics options to be specified as a nested mapping with sub-options while keeping legacy scalar form supported for backward compatibility.
Why
- Makes intent explicit in YAML (which implementation of a model option is being selected)
- Improves docs and validation by naming the chosen family alongside the numeric code
Netradiationmethod example
N.B: The following is not written in the stone and is actually just a first draft proposal of how this new feature could be implemented.
New structure of the YAML:
# nested form:
netradiationmethod:
spartacus:
value: 1001
# legacy scalar (still accepted):
netradiationmethod:
value: 3
New NetRadiationMethod(Enum):
class NetRadiationMethod(Enum):
OBSERVED = 0
LDOWN_OBSERVED = 1
LDOWN_CLOUD = 2
LDOWN_AIR = 3
LDOWN_SURFACE = 11
LDOWN_CLOUD_SURFACE = 12
LDOWN_AIR_SURFACE = 13
LDOWN_ZENITH = 100
LDOWN_CLOUD_ZENITH = 200
LDOWN_AIR_ZENITH = 300
LDOWN_SS_OBSERVED = 1001
LDOWN_SS_CLOUD = 1002
LDOWN_SS_AIR = 1003
_FORCING = {0}
_NARP = {1, 2, 3, 11, 12, 13, 100, 200, 300}
_SPARTACUS = {1001, 1002, 1003}
def __int__(self):
return self.value
@property
def family(self) -> str:
v = int(self)
if v in self._FORCING:
return "forcing"
if v in self._NARP:
return "narp"
if v in self._SPARTACUS:
return "spartacus"
return "legacy"
@property
def is_forcing(self) -> bool:
return int(self) in self._FORCING
@property
def is_narp(self) -> bool:
return int(self) in self._NARP
@property
def is_spartacus(self) -> bool:
return int(self) in self._SPARTACUS
New minimal nested container
class NetRadiationMethodConfig(BaseModel):
"""
Canonical nested structure. Only these two shapes are allowed:
- legacy scalar: {'value': 3} (will be converted to nested by validator)
- nested: {'narp': {'value': 3}} (or 'forcing' / 'spartacus')
"""
forcing: Optional[dict] = None
narp: Optional[dict] = None
spartacus: Optional[dict] = None
def selected(self) -> Tuple[str, Optional[NetRadiationMethod]]:
"""
Return (family_name, NetRadiationMethod enum | None).
Raises ValueError for invalid inner shapes (e.g. nested dict as value).
"""
for name in ("forcing", "narp", "spartacus"):
block = getattr(self, name)
if block is not None:
if not isinstance(block, dict) or "value" not in block:
raise ValueError(f"netradiationmethod.{name} must be a dict with a scalar 'value'")
val = block["value"]
if isinstance(val, dict):
raise ValueError(f"netradiationmethod.{name}.value must be a scalar (int/Enum), not a dict")
if val is None:
return name, None
return name, NetRadiationMethod(int(val))
raise ValueError("netradiationmethod must provide exactly one of: forcing, narp, spartacus")
Changes in ModelPhysics
#
netradiationmethod: NetRadiationMethodConfig = Field(
default_factory=NetRadiationMethodConfig,
description="Nested netradiationmethod: supply exactly one of forcing/narp/spartacus.",
)
Legacy not-nested form
This probably needs to be refactor into a general top-level helper function to be called for each model option.
# --- Minimal validator to accept legacy scalar and convert it to the nested canonical form ---
@field_validator("netradiationmethod", mode="before")
def _coerce_netrad_field(cls, v: Any) -> Any:
"""
Accept only:
- legacy scalar: {'value': N} (or plain int/Enum) -> converted to nested {'narp'|'forcing'|'spartacus': {'value': N}}
- nested canonical: {'narp': {'value': N}} (or 'forcing'/'spartacus') -> accepted as-is
Reject double-wrapped or other shapes.
"""
if v is None:
return v
# If already nested canonical form with one of the family keys, validate inner shape quickly
if isinstance(v, dict) and any(k in v for k in ("forcing", "narp", "spartacus")):
# ensure exactly one family key present and inner 'value' is scalar
keys = [k for k in ("forcing", "narp", "spartacus") if k in v]
if len(keys) != 1:
raise ValueError(f"netradiationmethod must provide exactly one of: forcing, narp, spartacus (got {keys})")
inner = v[keys[0]]
if not isinstance(inner, dict) or "value" not in inner:
raise ValueError(f"netradiationmethod.{keys[0]} must be a dict with key 'value'")
if isinstance(inner["value"], dict):
raise ValueError(f"netradiationmethod.{keys[0]}.value must be a scalar, not a dict")
return v
# Legacy: {'value': N}
if isinstance(v, dict) and "value" in v:
val = v["value"]
else:
# allow plain Enum/int
try:
from enum import Enum as _Enum
if isinstance(v, _Enum):
val = int(v.value)
else:
val = int(v)
except Exception:
val = None
if val is None:
raise ValueError("netradiationmethod must be either {'value': N} or {'narp'|'forcing'|'spartacus': {'value': N}}")
n = int(val)
# map numeric to family
if n in NetRadiationMethod._FORCING:
return {"forcing": {"value": n}}
if n in NetRadiationMethod._NARP:
return {"narp": {"value": n}}
if n in NetRadiationMethod._SPARTACUS:
return {"spartacus": {"value": n}}
# default policy: unknown numeric codes -> place in 'narp'
return {"narp": {"value": n}}
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
1-featureNew functionalityNew functionality2-infra:data-modelPydantic data models & validationPydantic data models & validation2-infra:inputInput data handling and validationInput data handling and validation3-P2Medium priorityMedium priority4-in-progressBeing worked onBeing worked on