Source code for supy.util._attribution._u10

"""
U10 (10m wind speed) attribution functions.

Provides functions to decompose U10 differences between scenarios
into physically attributable components using the logarithmic wind profile.
"""

from typing import Literal

import numpy as np
import pandas as pd

from ._core import shapley_forcing_profile
from ._helpers import (
    align_scenarios,
    detect_anomalies,
    extract_suews_group,
    _group_means,
)
from ._physics import cal_u10_profile_components
from ._result import AttributionResult


[docs] def attribute_u10( df_output_A: pd.DataFrame, df_output_B: pd.DataFrame, z_ref: float = 10.0, min_ustar: float = 0.01, ) -> AttributionResult: """ Decompose U10 (10m wind speed) differences between two SUEWS scenarios. The difference in 10m wind speed is attributed to: - Forcing changes (u* - friction velocity / surface stress) - Roughness changes (z0m, zd - surface roughness characteristics) - Stability changes (psi_m - atmospheric stability correction) The attribution uses the logarithmic wind profile: U10 = (u*/k) * [ln((z-d)/z0m) - psi_m(zeta)] Where: - u* = friction velocity (m/s) - k = von Karman constant (0.4) - z = reference height (10m) - d = displacement height (m) - z0m = roughness length for momentum (m) - psi_m = stability correction for momentum - zeta = (z-d)/L = stability parameter Parameters ---------- df_output_A : pd.DataFrame SUEWS output DataFrame for scenario A (reference/baseline) df_output_B : pd.DataFrame SUEWS output DataFrame for scenario B (modified/test) z_ref : float, optional Reference height for wind speed (m). Default 10.0 m. min_ustar : float, optional Minimum friction velocity threshold (m/s). Timesteps with abs(u*) < min_ustar are flagged. Default 0.01. Returns ------- AttributionResult Container with contributions timeseries, summary statistics, and metadata. Notes ----- The Shapley decomposition for the wind profile f = F * (R + S) where: - F = u*/k (forcing) - R = ln((z-d)/z0m) (roughness) - S = -psi_m (stability) Yields exact closure: - Phi_forcing = dF * (P_A + P_B) / 2, where P = R + S - Phi_roughness = dR * (F_A + F_B) / 2 - Phi_stability = dS * (F_A + F_B) / 2 Examples -------- Compare baseline vs. increased roughness scenario: >>> result = attribute_u10(df_output_baseline, df_output_urban) >>> print(result) U10 Attribution Results ======================================== Mean delta_U10: -1.23 m/s Component Breakdown: ---------------------------------------- forcing : -0.42 m/s (34.1%) roughness : -0.65 m/s (52.8%) stability : -0.16 m/s (13.0%) >>> result.plot(kind="bar") # Visualise contributions """ # Required columns for U10 attribution required_cols = {"U10", "UStar", "z0m", "zd", "Lob"} # Extract SUEWS output group with column validation df_A = extract_suews_group(df_output_A, required_cols=required_cols) df_B = extract_suews_group(df_output_B, required_cols=required_cols) # Align indices df_A, df_B, common_idx = align_scenarios(df_A, df_B) # Extract required variables U10_A = df_A["U10"].values U10_B = df_B["U10"].values ustar_A = df_A["UStar"].values ustar_B = df_B["UStar"].values z0m_A = df_A["z0m"].values z0m_B = df_B["z0m"].values zd_A = df_A["zd"].values zd_B = df_B["zd"].values Lob_A = df_A["Lob"].values Lob_B = df_B["Lob"].values # Calculate profile components for each scenario # F = u*/k, R = ln((z-d)/z0m), S = -psi_m F_A, R_A, S_A = cal_u10_profile_components(ustar_A, z0m_A, zd_A, Lob_A, z_ref=z_ref) F_B, R_B, S_B = cal_u10_profile_components(ustar_B, z0m_B, zd_B, Lob_B, z_ref=z_ref) # Shapley decomposition for f = F * (R + S) Phi_F, Phi_R, Phi_S = shapley_forcing_profile(F_A, F_B, R_A, R_B, S_A, S_B) # Build contributions DataFrame contributions = pd.DataFrame( { "delta_total": U10_B - U10_A, "forcing": Phi_F, "roughness": Phi_R, "stability": Phi_S, }, index=common_idx, ) # Add diagnostic columns contributions["flag_low_ustar"] = np.abs(ustar_A) < min_ustar # Store profile components for advanced users contributions["F_A"] = F_A contributions["F_B"] = F_B contributions["R_A"] = R_A contributions["R_B"] = R_B contributions["S_A"] = S_A contributions["S_B"] = S_B # Calculate summary statistics (main components only) main_cols = ["delta_total", "forcing", "roughness", "stability", "flag_low_ustar"] summary = contributions[main_cols].describe().T[["mean", "std", "min", "max"]] # Metadata metadata = { "n_timesteps": len(common_idx), "period_start": str(common_idx.min()), "period_end": str(common_idx.max()), "z_ref": z_ref, "min_ustar_threshold": min_ustar, "n_low_ustar_flagged": int(contributions["flag_low_ustar"].sum()), "unit": "m/s", } return AttributionResult( variable="U10", contributions=contributions, summary=summary, metadata=metadata, )
[docs] def diagnose_u10( df_output: pd.DataFrame, method: Literal["anomaly", "extreme", "diurnal"] = "anomaly", threshold: float = 2.0, z_ref: float = 10.0, min_ustar: float = 0.01, ) -> AttributionResult: """ Automatically identify anomalous U10 values and attribute the causes. This convenience function identifies unusual U10 (10m wind speed) behaviour within a single simulation run and diagnoses the driving factors by comparing aggregate statistics between reference (normal) and target (anomaly) periods. Parameters ---------- df_output : pd.DataFrame SUEWS output DataFrame method : str, optional Detection method: - 'anomaly': Compare timesteps > threshold sigma from daily mean - 'extreme': Compare top/bottom 5% of U10 vs. middle 50% - 'diurnal': Compare afternoon (12:00-15:00) vs. morning (06:00-10:00) Default 'anomaly'. threshold : float, optional Standard deviation threshold for anomaly detection. Default 2.0. z_ref : float, optional Reference height for wind speed (m). Default 10.0 m. min_ustar : float, optional Minimum friction velocity threshold (m/s). Default 0.01. Returns ------- AttributionResult Attribution decomposition with diagnostic interpretation. Examples -------- Quick anomaly diagnosis: >>> result = diagnose_u10(df_output, method="anomaly") >>> print(result) U10 Attribution Results ======================================== Mean delta_U10: +1.85 m/s Component Breakdown: ---------------------------------------- forcing : +0.92 m/s (49.7%) roughness : +0.65 m/s (35.1%) stability : +0.28 m/s (15.1%) Closure residual: 1.23e-15 m/s >>> result.plot() # Visualise decomposition """ # Extract SUEWS output df = extract_suews_group(df_output) if "U10" not in df.columns: raise ValueError( "U10 not found in SUEWS output. " "Ensure SUEWS is configured to output near-surface wind speed." ) u10 = df["U10"] anomaly_mask, normal_mask = detect_anomalies( u10, method=method, threshold=threshold ) # Store group sizes for metadata n_anomaly = anomaly_mask.sum() n_normal = normal_mask.sum() # Extract data for each group df_normal = df.loc[normal_mask] df_anomaly = df.loc[anomaly_mask] # Get mean values for each group output_means = _group_means( df_normal, df_anomaly, ["U10", "UStar", "z0m", "zd", "Lob"] ) U10_A, U10_B = output_means["U10"] ustar_A, ustar_B = output_means["UStar"] z0m_A, z0m_B = output_means["z0m"] zd_A, zd_B = output_means["zd"] Lob_A, Lob_B = output_means["Lob"] # Calculate profile components for each scenario F_A, R_A, S_A = cal_u10_profile_components(ustar_A, z0m_A, zd_A, Lob_A, z_ref=z_ref) F_B, R_B, S_B = cal_u10_profile_components(ustar_B, z0m_B, zd_B, Lob_B, z_ref=z_ref) # Shapley decomposition for f = F * (R + S) Phi_F, Phi_R, Phi_S = shapley_forcing_profile(F_A, F_B, R_A, R_B, S_A, S_B) # Build single-row contributions DataFrame contributions = pd.DataFrame( { "delta_total": U10_B - U10_A, "forcing": Phi_F, "roughness": Phi_R, "stability": Phi_S, }, index=pd.Index(["aggregate"], name="type"), ) # Add flag for low ustar contributions["flag_low_ustar"] = np.abs(ustar_A) < min_ustar # Calculate summary (same as contributions for single-row) main_cols = ["delta_total", "forcing", "roughness", "stability", "flag_low_ustar"] summary = contributions[main_cols].describe().T[["mean", "std", "min", "max"]] # Metadata metadata = { "n_timesteps": 1, # Aggregate comparison "method": method, "threshold": threshold if method == "anomaly" else None, "n_anomaly": int(n_anomaly), "n_reference": int(n_normal), "pct_anomaly": 100 * n_anomaly / len(u10), "z_ref": z_ref, "unit": "m/s", "aggregate_comparison": True, } return AttributionResult( variable="U10", contributions=contributions, summary=summary, metadata=metadata, )