Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3f37fef
add seasonal adjustment of vegetation albedo alb_id
dayantur Feb 19, 2026
bde81c6
squeezed/optimised code for new seasonal adjustment
dayantur Feb 19, 2026
e0da107
remove test yaml
dayantur Feb 19, 2026
9092226
add minimal tests fro new seasonal adjustments
dayantur Feb 19, 2026
1090b11
Update PHASE_B_DETAILED.md
dayantur Feb 19, 2026
25782f1
Update CHANGELOG.md
dayantur Feb 19, 2026
93b9c68
Merge branch 'master' into dayantur/validator/seasonal-adjustment-of-…
dayantur Mar 11, 2026
a3c7661
Update CHANGELOG.md
dayantur Mar 11, 2026
7cb5acc
Merge branch 'master' into dayantur/validator/seasonal-adjustment-of-…
dayantur Mar 12, 2026
c252bd5
remove duplicate
dayantur Mar 12, 2026
1afbbfa
Update CHANGELOG.md
dayantur Mar 12, 2026
8a7e9fb
typos in PHASE_B_DETAILED.md
dayantur Mar 12, 2026
ccffffc
add guard for surf_key not in initial_states
dayantur Mar 12, 2026
a5a8bd7
split logic into lines
dayantur Mar 12, 2026
c746e67
Update CHANGELOG.md
dayantur Mar 12, 2026
bf123bc
Merge branch 'master' into dayantur/validator/seasonal-adjustment-of-…
dayantur Mar 18, 2026
65112e8
replace strict == with math.isclose
dayantur Mar 18, 2026
fa7a258
refactor _get_range_and_id and _set_alb_id (outside adjust_seasonal_p…
dayantur Mar 18, 2026
5f740e1
refactor _set_alb_id
dayantur Mar 18, 2026
5785f33
dropping the grass assertion for the midseason case.
dayantur Mar 18, 2026
69c4489
Skip albedo adjustment if surface fraction is zero
dayantur Mar 19, 2026
2512f38
Update test_validation.py
dayantur Mar 19, 2026
db4c2c9
Style: add blank lines between top-level helpers, simplify sfr guard
sunt05 Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ EXAMPLES:
- [bugfix] Guard SPARTACUS LW solver NaN from matrix singularity in certain urban canopy geometries (#1212)
- [changes] Removed internal-only parameters (diagnose, dqndt, dqnsdt, dt_since_start, lenday_id, qn_av, qn_s_av, tair_av, tmax_id, tmin_id, tstep_prev, snowfallcum) from sample_config.yml. (PR #1216)

### 19 Feb 2026

- [feature][experimental] Add update logic under seasonal adjustments in phase_b.py to handle different alb_id behaviour across vegetated surface types (PR #1211).
- [doc] Updated PHASE_B_DETAILED.md with new seasonal adjustment logic (PR #1211).

### 18 Feb 2026

- [feature][experimental] Add attribution module for diagnosing T2, q2, and U10 changes by decomposing model output into physical process contributions, with diurnal cycle and heatmap visualisation helpers (#918)
Expand Down
7 changes: 7 additions & 0 deletions src/supy/data_model/validation/pipeline/PHASE_B_DETAILED.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ Phase B makes scientific adjustments that improve model realism without changing
- **Fraction Normalisation**: Adjusts surface fractions to sum to 1.0 by rounding the surface with maximum fraction value
- **Seasonal LAI Adjustments**: Calculates LAI for deciduous trees based on seasonal parameters (laimin, laimax) when surface fraction > 0. When surface fraction is 0, existing lai_id values are preserved and validation is skipped with a warning

### Seasonal Vegetation Albedo Adjustments

- **Season-Aware `alb_id`**: Updates `initial_states.*.alb_id` for grass, dectr, evetr.
- **Summer Regime**: `alb_id(grass) = alb_min(grass)`; `alb_id(dectr/evetr) = alb_max(dectr/evetr)`
- **Winter Regime**: `alb_id(grass) = alb_max(grass)`; `alb_id(dectr/evetr) = alb_min(dectr/evetr)`
- **Transition Seasons**: `alb_id` set to midpoint `(alb_min + alb_max)/2` for grass, dectr, evetr

### STEBBS Method Integration

- **Conditional Logic**:
Expand Down
76 changes: 73 additions & 3 deletions src/supy/data_model/validation/pipeline/phase_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
without duplicating parameter detection or YAML structure validation.
"""

import math
import yaml
import os
import calendar
Expand Down Expand Up @@ -1805,6 +1806,32 @@ def get_season(start_date: str, lat: float) -> str:
return get_season_from_doy(start, lat)


def _get_range_and_id(surf_props: dict, surf_state: dict) -> Tuple[Optional[float], Optional[float], Optional[float]]:
alb_min = get_value_safe(surf_props, "alb_min")
alb_max = get_value_safe(surf_props, "alb_max")
alb_id = get_value_safe(surf_state, "alb_id")
return alb_min, alb_max, alb_id


def _set_alb_id(
initial_states: dict,
surf_key: str,
new_alb_id: Optional[float],
):
if new_alb_id is None:
return False, None, None
if surf_key not in initial_states:
initial_states[surf_key] = {}
surf_state = initial_states[surf_key]
if not isinstance(surf_state, dict):
return False, None, None
old_val = get_value_safe(surf_state, "alb_id")
if old_val is not None and math.isclose(old_val, new_alb_id):
return False, old_val, new_alb_id
surf_state["alb_id"] = {"value": new_alb_id}
return True, old_val, new_alb_id


def adjust_seasonal_parameters(
yaml_data: dict, start_date: str, model_year: int
) -> Tuple[dict, List[ScientificAdjustment]]:
Expand Down Expand Up @@ -1855,8 +1882,8 @@ def adjust_seasonal_parameters(

if sfr > 0:
lai = dectr.get("lai", {})
laimin = lai.get("laimin", {}).get("value")
laimax = lai.get("laimax", {}).get("value")
laimin = get_value_safe(lai, "laimin")
laimax = get_value_safe(lai, "laimax")

if laimin is not None and laimax is not None:
if season == "summer":
Expand All @@ -1871,7 +1898,7 @@ def adjust_seasonal_parameters(
if "dectr" not in initial_states:
initial_states["dectr"] = {}

current_lai = initial_states["dectr"].get("lai_id", {}).get("value")
current_lai = get_value_safe(initial_states["dectr"], "lai_id")
if current_lai != lai_val:
initial_states["dectr"]["lai_id"] = {"value": lai_val}
adjustments.append(
Expand All @@ -1889,6 +1916,49 @@ def adjust_seasonal_parameters(
# Note: When sfr=0, we don't nullify lai_id - we simply skip validation
# The warning "Parameters not checked because surface fraction is 0" covers this

# Seasonal adjustment of vegetation albedo ranges (alb_id only)
vegetated_surfaces = (
("grass", "grass"),
("dectr", "deciduous trees"),
("evetr", "evergreen trees"),
)

for surf_key, label in vegetated_surfaces:
surf_props = land_cover.get(surf_key, {})
# Check surface fraction:
sfr = surf_props.get("sfr", {}).get("value", 0)
if not sfr:
continue # Skip albedo adjustment if surface fraction is zero

surf_state = initial_states.get(surf_key, {})
alb_min, alb_max, alb_id_val = _get_range_and_id(surf_props, surf_state)
if alb_min is None or alb_max is None:
continue
if season in ("summer", "tropical", "equatorial"):
target = alb_min if surf_key == "grass" else alb_max
elif season == "winter":
target = alb_max if surf_key == "grass" else alb_min
else:
if alb_id_val is None:
continue
target = 0.5 * (alb_min + alb_max)
changed, old_val, new_alb_id = _set_alb_id(
initial_states,
surf_key,
target,
)
if changed:
adjustments.append(
ScientificAdjustment(
parameter=f"{surf_key}.alb_id",
site_index=site_idx,
site_gridid=site_gridid,
old_value=str(old_val),
new_value=str(new_alb_id),
reason=f"Set seasonal albedo for {season} on {label} based on (alb_min, alb_max)",
)
)

if lat is not None and lng is not None:
try:
dls = DLSCheck(lat=lat, lng=lng, year=model_year)
Expand Down
120 changes: 119 additions & 1 deletion test/data_model/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@
)
from supy.data_model.core.type import RefValue
from supy.data_model.validation.core.utils import check_missing_params
from supy.data_model.validation.pipeline.phase_b import validate_model_option_samealbedo
from supy.data_model.validation.pipeline.phase_b import validate_model_option_samealbedo, adjust_seasonal_parameters
from supy.data_model.validation.pipeline.phase_b import validate_model_option_rcmethod, validate_model_option_stebbsmethod, adjust_model_option_stebbsmethod
from supy.data_model.validation.pipeline.phase_b import adjust_model_option_rcmethod

import types
import copy

# A tiny “site” stub that only carries exactly the properties our validators look at
class DummySite:
Expand Down Expand Up @@ -1939,6 +1940,123 @@ def test_phase_b_annual_mean_air_temperature_missing_stebbs():
assert len(annual_temp_adjustments) == 0


def _make_minimal_phase_b_yaml_for_albedo(lat: float) -> dict:
"""Build a minimal Phase-B-style YAML dict with vegetation albedo ranges and alb_id."""
return {
"sites": [
{
"name": "TestSite",
"gridiv": 1,
"properties": {
"lat": {"value": lat},
"lng": {"value": 0.0},
"land_cover": {
"grass": {
"sfr": {"value": 0.1},
"alb_min": {"value": 0.10},
"alb_max": {"value": 0.20},
},
"dectr": {
"sfr": {"value": 0.1},
"alb_min": {"value": 0.12},
"alb_max": {"value": 0.30},
},
"evetr": {
"sfr": {"value": 0.1},
"alb_min": {"value": 0.14},
"alb_max": {"value": 0.40},
},
},
},
"initial_states": {
"grass": {"alb_id": {"value": 0.15}},
"dectr": {"alb_id": {"value": 0.18}},
"evetr": {"alb_id": {"value": 0.25}},
},
}
]
}


def _get_phase_b_alb_ids(yaml_data: dict):
site = yaml_data["sites"][0]
ist = site["initial_states"]
return (
ist["grass"]["alb_id"]["value"],
ist["dectr"]["alb_id"]["value"],
ist["evetr"]["alb_id"]["value"],
)


def test_phase_b_seasonal_albedo_summer_updates_alb_id_from_ranges():
"""Summer: grass.alb_id -> alb_min, dectr/evetr.alb_id -> alb_max."""
# Northern Hemisphere, DOY in summer window: 2017-07-15
yaml_data = _make_minimal_phase_b_yaml_for_albedo(lat=51.5)
yaml_before = copy.deepcopy(yaml_data)

updated, adjustments = adjust_seasonal_parameters(
yaml_data, start_date="2017-07-15", model_year=2017
)

grass_id, dectr_id, evetr_id = _get_phase_b_alb_ids(updated)

# Expected from ranges defined above
assert grass_id == pytest.approx(0.10) # alb_min(grass)
assert dectr_id == pytest.approx(0.30) # alb_max(dectr)
assert evetr_id == pytest.approx(0.40) # alb_max(evetr)

# We should have ScientificAdjustment entries for each
params = {a.parameter for a in adjustments}
assert "grass.alb_id" in params
assert "dectr.alb_id" in params
assert "evetr.alb_id" in params

# Land-cover ranges themselves must be unchanged
lc_before = yaml_before["sites"][0]["properties"]["land_cover"]
lc_after = updated["sites"][0]["properties"]["land_cover"]
assert lc_after == lc_before


def test_phase_b_seasonal_albedo_winter_updates_alb_id_from_ranges():
"""Winter: grass.alb_id -> alb_max, dectr/evetr.alb_id -> alb_min."""
# Northern Hemisphere, DOY in winter window: 2017-01-15
yaml_data = _make_minimal_phase_b_yaml_for_albedo(lat=51.5)

updated, adjustments = adjust_seasonal_parameters(
yaml_data, start_date="2017-01-15", model_year=2017
)

grass_id, dectr_id, evetr_id = _get_phase_b_alb_ids(updated)

assert grass_id == pytest.approx(0.20) # alb_max(grass)
assert dectr_id == pytest.approx(0.12) # alb_min(dectr)
assert evetr_id == pytest.approx(0.14) # alb_min(evetr)

params = {a.parameter for a in adjustments}
assert "grass.alb_id" in params
assert "dectr.alb_id" in params
assert "evetr.alb_id" in params


def test_phase_b_seasonal_albedo_midseason_sets_alb_id_midpoint():
"""Spring/fall: alb_id -> (alb_min + alb_max)/2 for dectr/evetr surfaces."""
# Northern Hemisphere, DOY in spring window: 2017-04-01
yaml_data = _make_minimal_phase_b_yaml_for_albedo(lat=51.5)

updated, adjustments = adjust_seasonal_parameters(
yaml_data, start_date="2017-04-01", model_year=2017
)

_ , dectr_id, evetr_id = _get_phase_b_alb_ids(updated)

# Expected midpoints
assert dectr_id == pytest.approx((0.12 + 0.30) / 2) # 0.21
assert evetr_id == pytest.approx((0.14 + 0.40) / 2) # 0.27

params = {a.parameter for a in adjustments}
Comment thread
dayantur marked this conversation as resolved.
assert "dectr.alb_id" in params
assert "evetr.alb_id" in params

# =====================================================================
# Phase A: nlayer dimension validation tests
# =====================================================================
Expand Down
Loading