Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Model meets reality: SGP three-case summary

ARM Logo

Model meets reality: SGP three-case summary

Three case days at the ARM SGP site: 2019-05-17, 2018-07-09), 2019-09-29.
Two models: SCREAM (5 km doubly-periodic) and WRF-LASSO (250 m reference LES).
Observations: ARM ARSCL cloud mask, MWR liquid-water path, downwelling shortwave radiation, and rawinsonde soundings.

import os, warnings
from pathlib import Path
from glob import glob

import numpy as np
import pandas as pd
import xarray as xr
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import netCDF4 as nc
from matplotlib.lines import Line2D

warnings.filterwarnings('ignore', category=RuntimeWarning)
os.environ['PROJ_LIB']  = '/opt/conda/share/proj'
os.environ['PROJ_DATA'] = '/opt/conda/share/proj'

SCREAM_ROOT = Path('/data/project/ARM_Summer_School_2026/data/modeling/dpscream')
LASSO_ROOT  = Path('/data/project/ARM_Summer_School_2026/data/modeling/lasso')
SW_PATH     = Path('/data/archive/sgp/sgpqcrad1longE13.c2')

C_SCREAM = '#1f77b4'
C_WRF    = '#ff7f0e'
C_OBS    = '#222222'
C_LCL    = '#2ca02c'
C_CB     = '#9467bd'

CASE_COLOR = {'0517': '#0072B2', '0709': '#D55E00', '0929': '#009E73'}
CASE_ORDER = ['0709', '0517', '0929']
UTC_TO_LST = pd.Timedelta(hours=-5)

# snd_id differs from sim for 0929: only id5 sounding exists on disk
CASES = {
    '0709': {'date': '2018-07-09', 'yyyymmdd': '20180709', 'sim': 8, 'snd_id': 8, 'regime': 'deeper cumulus'},
    '0517': {'date': '2019-05-17', 'yyyymmdd': '20190517', 'sim': 4, 'snd_id': 4, 'regime': 'shallow cumulus'},
    '0929': {'date': '2019-09-29', 'yyyymmdd': '20190929', 'sim': 4, 'snd_id': 5, 'regime': 'fall transition'},
}
for k, v in CASES.items():
    d, s, y = v['yyyymmdd'], v['sim'], v['date']
    base = LASSO_ROOT / d / f'sgplassodiagconfobsmod{s}C1.m1' / 'obs_model'
    v['f_scream']   = SCREAM_ROOT / f'scream_dpxx_LASSO_SGP_{y}.fullfield.AVERAGE.nhours_x1.{y}-43200.nc'
    v['f_obsmodz']  = base / f'sgplassodiagobsmodz{s}C1.m1.{d}.120000.nc'
    v['f_obsmod']   = base / f'sgplassodiagobsmod{s}C1.m1.{d}.120000.nc'
    v['f_mod']      = base / f'sgplassomod{s}C1.m1.{d}.120000.nc'
    v['f_sounding'] = LASSO_ROOT / d / f'{d}_sgplassoconf_id{v["snd_id"]}' / 'input_sounding'
    v['wrfout_glob']= str(LASSO_ROOT / d / f'sgplassodiagraw{s}C1.m1' / 'raw_model' / 'wrfout_d01_*.nc')

plt.rcParams.update({
    'figure.dpi':       130,
    'savefig.dpi':      200,
    'savefig.bbox':     'tight',
    'font.size':        15,
    'axes.titlesize':   14,
    'axes.labelsize':   15,
    'xtick.labelsize':  14,
    'ytick.labelsize':  14,
    'legend.fontsize':  13,
    'figure.titlesize': 17,
    'axes.spines.top':  False,
    'axes.spines.right':False,
    'axes.linewidth':   1.2,
    'lines.linewidth':  2.2,
    'axes.grid':        True,
    'grid.alpha':       0.25,
    'grid.linewidth':   0.7,
})
print('Setup done.')
Setup done.
def open_wrf_vap(path):
    """Open LASSO WRF VAP; reattach `height` dropped by xarray (it is both dim and var)."""
    ds  = xr.open_dataset(path, decode_coords=False, drop_variables=['height'])
    raw = nc.Dataset(path)
    if 'height' in raw.variables:
        ds = ds.assign({'height_km': (raw.variables['height'].dimensions,
                                      raw.variables['height'][:])})
    raw.close()
    return ds


def to_lst(times):
    return pd.DatetimeIndex(times) + UTC_TO_LST


def hod(times_lst):
    return times_lst.hour + times_lst.minute / 60.0


def scream_z_km(ds):
    """Time-mean domain-mean SCREAM z_mid → km AGL (top-first ordering preserved)."""
    z = ds['z_mid'].mean(['time', 'ncol']).values
    return (z - z.min()) / 1000.0


def scream_z_km_t(ds):
    """Time-resolved domain-mean z_mid → km AGL DataArray (time, lev)."""
    z = ds['z_mid'].mean('ncol')          # (time, lev), metres AMSL
    return (z - z.min('lev')) / 1000.0    # km AGL


def scream_low_cf(ds, z_top_m=5000.0):
    """Max cldfrac_tot_for_analysis below z_top_m metres, averaged over ncol."""
    return (ds['cldfrac_tot_for_analysis']
            .where(ds['z_mid'] < z_top_m)
            .max('lev')
            .mean('ncol')
            .values)


def open_case(short):
    c = CASES[short]
    return dict(
        case=short, date=c['date'], regime=c['regime'],
        scream  = xr.open_dataset(c['f_scream']),
        obsmodz = open_wrf_vap(c['f_obsmodz']),
        obsmod  = open_wrf_vap(c['f_obsmod']),
        mod     = open_wrf_vap(c['f_mod']),
    )


def load_sounding(short):
    snd   = np.loadtxt(CASES[short]['f_sounding'], skiprows=1)
    z_km  = snd[:, 0] / 1000.0   # column 0: height in metres
    theta = snd[:, 1]             # column 1: theta in K
    return theta, z_km


def load_sw_obs(yyyymmdd, resample='10min'):
    d = int(yyyymmdd)
    files = [str(SW_PATH / f'sgpqcrad1longE13.c2.{d}.000000.cdf'),
             str(SW_PATH / f'sgpqcrad1longE13.c2.{d+1}.000000.cdf')]
    sw = xr.open_mfdataset(files).BestEstimate_down_short_hemisp
    sw = sw.isel(time=slice(12 * 60, (24 + 4) * 60)).resample(time=resample).mean()
    return sw.assign_coords(time=sw.time + UTC_TO_LST)


def load_wrf_swdnb(short):
    pat   = CASES[short]['wrfout_glob']
    ds    = xr.open_mfdataset(sorted(glob(pat)), combine='nested', concat_dim='Time')
    times = pd.to_datetime(
        [bytes(t).decode().replace('_', 'T') for t in ds['Times'].values]
    ) + UTC_TO_LST
    return ds['SWDNB'].assign_coords(Time=times).mean(['south_north', 'west_east'])


def fmt_haxis(ax):
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%H'))
    ax.set_xlabel('Local Solar Time [h]')


def nearest_t(times, target_ts):
    return int(np.argmin(np.abs(times - target_ts)))


print('Helpers ready.')
Helpers ready.
BUNDLES = {}
for short in CASE_ORDER:
    b = open_case(short)
    b['t_sc']     = to_lst(b['scream']['time'].values)
    b['t_oz']     = to_lst(b['obsmodz']['time'].values)
    b['t_mod']    = to_lst(b['mod']['time'].values)
    b['t_omd']    = to_lst(b['obsmod']['time'].values)
    b['h_sc']     = hod(b['t_sc'])
    b['h_oz']     = hod(b['t_oz'])
    b['z_sc_km']  = scream_z_km(b['scream'])
    b['z_sc_km_t']= scream_z_km_t(b['scream'])      # (time, lev) DataArray
    b['z_wrf_km'] = b['obsmodz']['height_km'].mean('time').values
    BUNDLES[short] = b
    print(f'  {short}  {b["date"]}  ok')

sw_obs    = {s: load_sw_obs(CASES[s]['yyyymmdd']) for s in CASE_ORDER}
wrf_swdnb = {s: load_wrf_swdnb(s)                 for s in CASE_ORDER}
print('SW radiation loaded.')
  0709  2018-07-09  ok
  0517  2019-05-17  ok
  0929  2019-09-29  ok
SW radiation loaded.

Fig 1 — Cloud-fraction curtains

Time–height fields for the full diurnal cycle (6–23 LST). Columns: SCREAM cldfrac_tot, WRF-LASSO cloud_fraction, ARSCL cloud_fraction_mask_arscl. Shared 0–1 colour scale; white = high fraction, dark blue = none.

Z_TOP = 6.0

fig, axes = plt.subplots(3, 3, figsize=(14, 10), sharex=True, sharey=True,
                         gridspec_kw=dict(hspace=0.18, wspace=0.07))

for r, short in enumerate(CASE_ORDER):
    b = BUNDLES[short]
    panels = [
        (b['h_sc'], b['z_sc_km'],
         b['scream']['cldfrac_tot'].mean('ncol').values.T, 'SCREAM'),
        (b['h_oz'], b['z_wrf_km'],
         b['obsmodz']['cloud_fraction'].values.T,          'WRF-LASSO'),
        (b['h_oz'], b['z_wrf_km'],
         b['obsmodz']['cloud_fraction_mask_arscl'].values.T,'ARSCL'),
    ]
    for c, (hh, zz, arr, ttl) in enumerate(panels):
        ax = axes[r, c]
        pc = ax.pcolormesh(hh, zz, arr, cmap='Blues_r', vmin=0, vmax=1, shading='auto')
        ax.set_ylim(0, Z_TOP)
        ax.set_xlim(6, 23)
        ax.set_xticks(np.arange(6, 24, 3))
        ax.grid(False)
        if r == 0:
            ax.set_title(ttl)
        if c == 0:
            ax.set_ylabel(f"{b['date']}\n{b['regime']}\nHeight AGL [km]",
                          color=CASE_COLOR[short])
        if r == 2:
            ax.set_xlabel('LST [h]')

fig.colorbar(pc, ax=axes, shrink=0.6, pad=0.015,
             label='Cloud fraction', ticks=[0, 0.25, 0.5, 0.75, 1.0])
plt.show()
<Figure size 1820x1300 with 10 Axes>

Fig 2 & 3 — Liquid water path and low cloud fraction

Both metrics use one panel per case with three sources in fixed order (SCREAM → WRF-LASSO → ARM obs). SCREAM low cloud fraction is cldfrac_tot_for_analysis.where(z_mid < 5000 m).max(lev).mean(ncol); WRF-LASSO and ARSCL values come from the pre-computed low_cloud_fraction_arscl field in the obsmod VAP.

fig, axes = plt.subplots(1, 3, figsize=(14, 4.8), gridspec_kw=dict(wspace=0.24))

for ax, short in zip(axes, CASE_ORDER):
    b = BUNDLES[short]
    lwp_sc  = b['scream']['LiqWaterPath'].mean('ncol').values * 1000.0
    lwp_wrf = b['mod']['lwp'].values
    lwp_mwr = b['obsmod']['lwp'].isel(source_type=0).values

    ax.plot(b['t_sc'],  lwp_sc,  '-o', color=C_SCREAM, ms=5, label='SCREAM')
    ax.plot(b['t_mod'], lwp_wrf, '--', color=C_WRF,           label='WRF-LASSO')
    ax.plot(b['t_omd'], lwp_mwr, ':^', color=C_OBS, ms=4, lw=1.8, label='ARM MWR')

    ax.set_title(f"{b['date']}  {b['regime']}", color=CASE_COLOR[short])
    fmt_haxis(ax)
    ax.set_ylim(bottom=0)

axes[0].set_ylabel(r'LWP  [g m$^{-2}$]')
axes[2].legend(frameon=False, loc='center left', bbox_to_anchor=(1.0, 0.5))
plt.show()
<Figure size 1820x624 with 3 Axes>
fig, axes = plt.subplots(1, 3, figsize=(14, 4.8), gridspec_kw=dict(wspace=0.24))

for ax, short in zip(axes, CASE_ORDER):
    b = BUNDLES[short]

    lcf_sc  = scream_low_cf(b['scream'])                                        # max(cldfrac_tot_for_analysis, z<5km).mean(ncol)
    lcf_wrf = b['obsmod']['low_cloud_fraction_arscl'].isel(source_type=1).values
    lcf_obs = b['obsmod']['low_cloud_fraction_arscl'].isel(source_type=0).values

    ax.plot(b['t_sc'][:-1],  lcf_sc[:-1],  '-o', color=C_SCREAM, ms=5, label='SCREAM')
    ax.plot(b['t_omd'][:-1], lcf_wrf[:-1], '--', color=C_WRF,          label='WRF-LASSO')
    ax.plot(b['t_omd'][:-1], lcf_obs[:-1], ':^', color=C_OBS, ms=4, lw=1.8, label='ARSCL')

    ax.set_title(f"{b['date']}  {b['regime']}", color=CASE_COLOR[short])
    fmt_haxis(ax)
    ax.set_ylim(0, 1)
    # ax.set_xlim(6, 21)

axes[0].set_ylabel('Low cloud fraction (<5 km)')
axes[2].legend(frameon=False, loc='center left', bbox_to_anchor=(1.0, 0.5))
plt.show()
<Figure size 1820x624 with 3 Axes>

Fig 4 — Surface downwelling shortwave radiation

SCREAM outputs hourly averages; WRF wrfout files are also hourly. QCRAD observations are resampled to 10-minute means. Clouds suppress SW↓ relative to clear-sky — deviations from the smooth clear-sky curve diagnose cloud radiative effect.

def load_sw_obs_raw(yyyymmdd):
  """Raw 1-minute QCRAD obs with no temporal averaging."""
  d = int(yyyymmdd)
  files = [str(SW_PATH / f'sgpqcrad1longE13.c2.{d}.000000.cdf'),
           str(SW_PATH / f'sgpqcrad1longE13.c2.{d+1}.000000.cdf')]
  sw = xr.open_mfdataset(files).BestEstimate_down_short_hemisp
  sw = sw.isel(time=slice(12 * 60, (24 + 4) * 60))
  return sw.assign_coords(time=sw.time + UTC_TO_LST)


def load_wrf_swdnbc(short):
  """WRF clear-sky SWDNB domain mean, times in LST."""
  pat   = CASES[short]['wrfout_glob']
  ds    = xr.open_mfdataset(sorted(glob(pat)), combine='nested', concat_dim='Time')
  times = pd.to_datetime(
      [bytes(t).decode().replace('_', 'T') for t in ds['Times'].values]
  ) + UTC_TO_LST
  return ds['SWDNBC'].assign_coords(Time=times).mean(['south_north', 'west_east'])


wrf_swdnbc = {s: load_wrf_swdnbc(s) for s in CASE_ORDER}

fig, axes = plt.subplots(1, 3, figsize=(14, 4.8), gridspec_kw=dict(wspace=0.24))

for ax, short in zip(axes, CASE_ORDER):
  b = BUNDLES[short]

  # raw obs background: 1-minute, alpha=0.2
  sw_raw_obs = load_sw_obs_raw(CASES[short]['yyyymmdd'])
  ax.plot(sw_raw_obs.time.values, sw_raw_obs.values,
          color=C_OBS, alpha=0.2, lw=0.6, zorder=1)

  # domain means
  sw_sc_all = b['scream']['SW_flux_dn_at_model_bot']
  t_sc_sw = b['t_sc'] - pd.Timedelta(hours=1)   # shift to start of averaging window

  ax.plot(t_sc_sw, sw_sc_all.mean('ncol').values,
          '-o', color=C_SCREAM, ms=5, lw=2.2, zorder=4, label='SCREAM')
  ax.plot(wrf_swdnb[short].coords['Time'].values, wrf_swdnb[short].values,
          '--', color=C_WRF, lw=2.2, zorder=4, label='WRF all-sky')
  ax.plot(wrf_swdnbc[short].coords['Time'].values, wrf_swdnbc[short].values,
          ':', color=C_WRF, lw=2.0, zorder=3, label='WRF clear-sky')
  ax.plot(sw_obs[short].time.values, sw_obs[short].values,
          color=C_OBS, lw=2.2, zorder=4, label='Observation (10 min)')

  ax.set_title(f"{b['date']}  {b['regime']}", color=CASE_COLOR[short])
  fmt_haxis(ax)
  ax.set_ylim(bottom=0)

axes[0].set_ylabel(r'SW$\downarrow$  [W m$^{-2}$]')
axes[2].legend(frameon=False, loc='center left', bbox_to_anchor=(1.0, 0.5))
plt.show()
<Figure size 1820x624 with 3 Axes>

Fig 5 — Potential temperature profiles

Three time snapshots (07, 12, 17 LST) for each case. Solid blue = SCREAM domain mean, solid orange = WRF-LASSO. The initial sounding (ARM rawinsonde) is shown only at 07 LST (dashed black). Horizontal lines mark the observed LCL (dashed green) and cloud-base height (dotted purple) at each snapshot time.

SNAP_HOURS  = [8, 13, 18]       # LST (UTC-5); avoids WRF's NaN-height first time step at 07:00
SNAP_LABELS = ['08 LST', '13 LST', '18 LST']
THETA_MIN, THETA_MAX, Z_MAX = 290, 325, 5.0

fig, axes = plt.subplots(3, 3, figsize=(14, 14), sharey=True, constrained_layout=True)

for row, short in enumerate(CASE_ORDER):
    b    = BUNDLES[short]
    sc   = b['scream']
    wrf  = b['mod']
    diag = b['obsmod']

    z_sc_t   = b['z_sc_km_t']                             # DataArray (time, lev) km AGL
    theta_sc = sc['PotentialTemperature'].mean('ncol')     # (time, lev)

    theta_snd, z_snd = load_sounding(short)
    mask_snd = z_snd < Z_MAX

    for col, hr in enumerate(SNAP_HOURS):
        ax = axes[row, col]
        # Target in LST -- no UTC_TO_LST here; b['t_*'] are already in LST
        ts = pd.Timestamp(f"{b['date']} {hr:02d}:00")

        i_sc  = nearest_t(b['t_sc'],  ts)
        i_mod = nearest_t(b['t_mod'], ts)
        i_omd = nearest_t(b['t_omd'], ts)

        # SCREAM profile
        z_sc_prof     = z_sc_t.isel(time=i_sc).values
        theta_sc_prof = theta_sc.isel(time=i_sc).values
        mask_sc = (z_sc_prof >= 0) & (z_sc_prof < Z_MAX)

        # WRF profile -- use height_km at the selected time step, mask NaN
        z_wrf_prof     = wrf['height_km'].isel(time=i_mod).values
        theta_wrf_prof = wrf['potential_temp'].isel(time=i_mod).values
        mask_wrf = np.isfinite(z_wrf_prof) & (z_wrf_prof >= 0) & (z_wrf_prof < Z_MAX)

        ax.plot(theta_sc_prof[mask_sc], z_sc_prof[mask_sc],
                color=C_SCREAM, lw=3.0, label='SCREAM')
        ax.plot(theta_wrf_prof[mask_wrf], z_wrf_prof[mask_wrf],
                color=C_WRF, lw=3.0, label='WRF-LASSO')

        if col == 0:
            ax.plot(theta_snd[mask_snd], z_snd[mask_snd],
                    '--', color=C_OBS, lw=2.5, label='Sounding')

        lcl = float(diag['lcl'].isel(source_type=0).isel(time=i_omd).values) / 1000.0
        cb  = float(diag['cloud_base_height'].isel(source_type=0).isel(time=i_omd).values) / 1000.0
        if 0.05 < lcl < Z_MAX:
            ax.axhline(lcl, ls='--', lw=2.0, color=C_LCL, alpha=0.85)
        if 0.05 < cb  < Z_MAX:
            ax.axhline(cb,  ls=':',  lw=2.2, color=C_CB,  alpha=0.85)

        ax.set_xlim(THETA_MIN, THETA_MAX)
        ax.set_ylim(0, Z_MAX)
        ax.grid(which='major', ls='--', lw=0.7, alpha=0.35)

        if row == 0:
            ax.set_title(SNAP_LABELS[col])
        if col == 0:
            ax.set_ylabel(f"{b['date']}\n{b['regime']}\nHeight AGL [km]",
                          color=CASE_COLOR[short])
        if row == 2:
            ax.set_xlabel(r'$\theta$  [K]')

legend_handles = [
    Line2D([0],[0], color=C_SCREAM, lw=3.0,          label='SCREAM'),
    Line2D([0],[0], color=C_WRF,    lw=3.0,          label='WRF-LASSO'),
    Line2D([0],[0], color=C_OBS,    lw=2.5, ls='--', label='Sounding (08 LST only)'),
    Line2D([0],[0], color=C_LCL,    lw=2.0, ls='--', label='Obs LCL'),
    Line2D([0],[0], color=C_CB,     lw=2.2, ls=':',  label='Obs cloud base'),
]
axes[0, 0].legend(handles=legend_handles, frameon=True, facecolor='white',
                  edgecolor='lightgray', fontsize=13)
plt.show()
<Figure size 1820x1820 with 9 Axes>

Fig 6 — WRF-LASSO thermodynamic curtains

Time–height evolution of potential temperature (top row) and water-vapour mixing ratio (bottom row) from WRF-LASSO. The diurnal boundary-layer growth is clearest in θ; the moistening aloft during convective periods appears in qᵥ.

vars_cfg = [
    {'var': 'potential_temp',           'vmin': 290, 'vmax': 320, 'cmap': 'RdYlBu_r',
     'label': r'$\theta$  [K]'},
    {'var': 'water_vapor_mixing_ratio', 'vmin': 0,   'vmax': 20,  'cmap': 'YlGnBu',
     'label': r'$q_v$  [g kg$^{-1}$]'},
]

fig, axes = plt.subplots(2, 3, figsize=(14, 8), sharex=False, sharey=True,
                         gridspec_kw=dict(hspace=0.22, wspace=0.08))

for row, cfg in enumerate(vars_cfg):
    for col, short in enumerate(CASE_ORDER):
        b   = BUNDLES[short]
        mod = b['mod']
        ax  = axes[row, col]

        z   = mod['height_km'].mean('time').values   # km from the same mod file
        t   = b['t_mod']
        arr = mod[cfg['var']].values.T               # (height, time)

        # qv may be in kg/kg depending on VAP version; convert to g/kg
        if cfg['var'] == 'water_vapor_mixing_ratio' and arr.max() < 1.0:
            arr = arr * 1000.0

        good = pd.notnull(t)
        pc = ax.pcolormesh(t[good], z, arr[:, good],
                           cmap=cfg['cmap'], vmin=cfg['vmin'], vmax=cfg['vmax'],
                           shading='auto')
        ax.set_ylim(0, 5)
        ax.grid(False)
        ax.xaxis.set_major_formatter(mdates.DateFormatter('%H'))

        if row == 0:
            ax.set_title(f"{b['date']}  {b['regime']}", color=CASE_COLOR[short])
        if col == 0:
            ax.set_ylabel('Height AGL [km]')
        if row == 1:
            ax.set_xlabel('LST [h]')

    fig.colorbar(pc, ax=axes[row, :], shrink=0.8, pad=0.02, label=cfg['label'])

plt.show()
<Figure size 1820x1040 with 8 Axes>

Fig 7 & 8 — Cloud-water budget diagnostics

Fig 7 normalises all cloud-occurrence metrics to the LASSO reference. A bar at 1 means model = LASSO. Blue bars use SCREAM’s internal cloud label (cldfrac_tot); orange bars count SCREAM cells that actually carry real condensate (qc + qr > 1×10⁻⁷ kg/kg).

Fig 8 tests the Zhang et al. (2024 GRL) mechanism: afternoon (12–18 LST) SCREAM columns, each plotted as boundary-layer height vs column LWP. A positive slope means taller BL → more condensate.

WATER_THRESH = 1e-7
CF_THRESH    = 0.01

rows = []
for short in CASE_ORDER:
    b  = BUNDLES[short]
    sc = b['scream']
    oz = b['obsmodz']

    cf_label = sc['cldfrac_tot'].mean('ncol').values
    cf_water = ((sc['qc'] + sc['qr']) > WATER_THRESH).mean('ncol').values
    cf_lasso = oz['cloud_fraction'].values

    rows.append(dict(
        case=short,
        occ_label = float((cf_label >= CF_THRESH).mean()),
        occ_water = float((cf_water >= CF_THRESH).mean()),
        occ_lasso = float((cf_lasso >= CF_THRESH).mean()),
    ))

m = pd.DataFrame(rows).set_index('case').loc[CASE_ORDER].reset_index()
m['ratio_label'] = m['occ_label'] / m['occ_lasso']
m['ratio_water'] = m['occ_water'] / m['occ_lasso']

fig, ax = plt.subplots(figsize=(11, 5.5), constrained_layout=True)
xpos = np.arange(3)
w    = 0.36
b1 = ax.bar(xpos - w/2, m['ratio_label'], w, color=C_SCREAM, label='SCREAM cloud label (cldfrac_tot) / LASSO')
b2 = ax.bar(xpos + w/2, m['ratio_water'], w, color=C_WRF,    label='SCREAM real water (qc+qr > 1e-7) / LASSO')
ax.axhline(1.0, color='k', ls='--', lw=1.2, label='SCREAM = LASSO')

labels = [f"{s}\n{CASES[s]['date']}" for s in CASE_ORDER]
ax.set_xticks(xpos)
ax.set_xticklabels(labels)
ax.set_ylabel('Cloud-occurrence ratio  (SCREAM / LASSO)')
ax.legend(frameon=False, bbox_to_anchor=(1.0, 0.5))

for bar, val in list(zip(b1, m['ratio_label'])) + list(zip(b2, m['ratio_water'])):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.08,
            f'{val:.2f}', ha='center', va='bottom', fontsize=13)

plt.show()
<Figure size 1430x715 with 1 Axes>
from scipy.stats import pearsonr

fig, ax = plt.subplots(figsize=(10, 5.5), constrained_layout=True)

for short in CASE_ORDER:
    b     = BUNDLES[short]
    sc    = b['scream']
    t_lst = b['t_sc']
    hour  = t_lst.hour + t_lst.minute / 60.0
    mask  = (hour >= 12) & (hour <= 18)

    pblh = sc['pbl_height'].isel(time=mask).values.flatten()
    lwp  = sc['LiqWaterPath'].isel(time=mask).values.flatten() * 1000.0
    good = np.isfinite(pblh) & np.isfinite(lwp)
    pblh, lwp = pblh[good], lwp[good]

    r, _ = pearsonr(pblh, lwp)
    col   = CASE_COLOR[short]
    ax.scatter(pblh, lwp, s=14, alpha=0.4, color=col, edgecolors='none',
               label=f"{CASES[short]['date']}  r = {r:+.2f}")
    if len(pblh) > 2:
        slope, intercept = np.polyfit(pblh, lwp, 1)
        xl = np.array([pblh.min(), pblh.max()])
        ax.plot(xl, slope * xl + intercept, '-', color=col, lw=2.2)

ax.set_xlabel('SCREAM boundary-layer height  [m AGL]')
ax.set_ylabel(r'SCREAM column LWP  [g m$^{-2}$]')
ax.set_ylim(bottom=0)
ax.legend(frameon=False)
plt.show()
<Figure size 1300x715 with 1 Axes>

Fig 9 — Model vs. observation scatter: boundary-layer state and low cloud fraction

Each point is one matched time step across all three cases (CASE_COLOR not used here — cases are pooled). SCREAM (blue circles) vs. WRF-LASSO (orange triangles) on the same axes; 1:1 line dashed black. Unit conversions applied: SCREAM RH × 100 (fraction → %).

def scream_bl_mean(ds, varname):
    z  = ds['z_mid']
    da = ds[varname]
    if 'pbl_height' in ds:
        mask = z <= ds['pbl_height']
    else:
        mask = z <= 1000.0
    return da.where(mask).mean(dim=['ncol', 'lev'], skipna=True).values


def align_pairs(t_obs, y_obs, t_mod, y_mod, tol='45min'):
    obs_s = pd.Series(np.asarray(y_obs, float), index=pd.DatetimeIndex(t_obs)).sort_index()
    mod_s = pd.Series(np.asarray(y_mod, float), index=pd.DatetimeIndex(t_mod)).sort_index()
    obs_m = obs_s.reindex(mod_s.index, method='nearest', tolerance=pd.Timedelta(tol))
    good  = obs_m.notna() & mod_s.notna()
    return obs_m[good].values, mod_s[good].values


SCATTER_VARS = [
    ('temperature_boundary_layer', 'BL temperature [K]'),
    ('rh_boundary_layer',          'BL RH [%]'),
    ('low_cloud_fraction_arscl',   'Low cloud fraction'),
]

SC_UNIT = {
    'temperature_boundary_layer': (1.0,   0.0),
    'rh_boundary_layer':          (100.0, 0.0),
    'low_cloud_fraction_arscl':   (1.0,   0.0),
}

# collect per-case paired data
case_pairs = {}
for short in CASE_ORDER:
    b  = BUNDLES[short]
    sc = b['scream']

    sc_vals = {
        'temperature_boundary_layer': scream_bl_mean(sc, 'T_mid'),
        'rh_boundary_layer':          scream_bl_mean(sc, 'RelativeHumidity'),
        'low_cloud_fraction_arscl':   scream_low_cf(sc),
    }

    case_pairs[short] = {}
    for varname, _ in SCATTER_VARS:
        y_obs = b['obsmod'][varname].isel(source_type=0).values
        y_wrf = b['obsmod'][varname].isel(source_type=1).values
        t_omd = b['t_omd']

        good_wrf = np.isfinite(y_obs) & np.isfinite(y_wrf)
        scale, offset = SC_UNIT[varname]
        sc_conv = sc_vals[varname] * scale + offset
        sc_obs, sc_mod = align_pairs(t_omd, y_obs, b['t_sc'], sc_conv)

        case_pairs[short][varname] = {
            'wrf': (y_obs[good_wrf], y_wrf[good_wrf]),
            'sc':  (sc_obs, sc_mod),
        }


def _scatter_panel(axes_row, model_key, title):
    for ax, (varname, xlabel) in zip(axes_row, SCATTER_VARS):
        for short in CASE_ORDER:
            obs, mod = case_pairs[short][varname][model_key]
            ax.scatter(obs, mod, color=CASE_COLOR[short], alpha=0.55, s=22,
                       label=CASES[short]['date'])

        all_v = np.concatenate([np.concatenate(case_pairs[s][varname][model_key])
                                 for s in CASE_ORDER])
        lo, hi = np.nanmin(all_v), np.nanmax(all_v)
        pad = 0.05 * (hi - lo)
        ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], 'k--', lw=1.2)
        ax.set_xlim(lo - pad, hi + pad)
        ax.set_ylim(lo - pad, hi + pad)
        ax.set_xlabel(f'ARM obs  {xlabel}')
        ax.set_ylabel(f'Model  {xlabel}')

    axes_row[0].set_title(title, fontsize=15, weight='semibold')
    axes_row[2].legend(frameon=False, loc='center left',
                       bbox_to_anchor=(1.0, 0.5), fontsize=12)


fig_sc, ax_sc = plt.subplots(1, 3, figsize=(14, 5.2), gridspec_kw=dict(wspace=0.32))
_scatter_panel(ax_sc, 'sc', 'DP-SCREAM vs ARM observations')
plt.show()

fig_wrf, ax_wrf = plt.subplots(1, 3, figsize=(14, 5.2), gridspec_kw=dict(wspace=0.32))
_scatter_panel(ax_wrf, 'wrf', 'WRF-LASSO vs ARM observations')
plt.show()
<Figure size 1820x676 with 3 Axes>
<Figure size 1820x676 with 3 Axes>
# pooled across all cases, both models on one figure
def scatter_metrics(obs, mod, ax, color, prefix=''):
    good = np.isfinite(obs) & np.isfinite(mod)
    if good.sum() < 2:
        return
    bias = np.mean(mod[good] - obs[good])
    r    = np.corrcoef(obs[good], mod[good])[0, 1]
    label = f'{prefix}  bias={bias:.2g}  r={r:.2f}'
    ax.text(0.03, 0.97 if prefix.startswith('S') else 0.89,
            label, transform=ax.transAxes, va='top', fontsize=10, color=color)


fig, axes = plt.subplots(1, 3, figsize=(14, 5.2), gridspec_kw=dict(wspace=0.30))

for ax, (varname, xlabel) in zip(axes, SCATTER_VARS):
    for key, col, marker, label in [('sc',  C_SCREAM, 'o', 'SCREAM'),
                                     ('wrf', C_WRF,    '^', 'WRF-LASSO')]:
        obs = np.concatenate([case_pairs[s][varname][key][0] for s in CASE_ORDER])
        mod = np.concatenate([case_pairs[s][varname][key][1] for s in CASE_ORDER])
        ax.scatter(obs, mod, color=col, marker=marker, alpha=0.55, s=22, label=label)
        scatter_metrics(obs, mod, ax, col, prefix=label)

    all_v = np.concatenate([np.concatenate(case_pairs[s][varname][k])
                             for s in CASE_ORDER for k in ('sc', 'wrf')])
    lo, hi = np.nanmin(all_v), np.nanmax(all_v)
    pad = 0.05 * (hi - lo)
    ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], 'k--', lw=1.2)
    ax.set_xlim(lo - pad, hi + pad)
    ax.set_ylim(lo - pad, hi + pad)
    ax.set_xlabel(f'ARM obs  {xlabel}')
    ax.set_ylabel(f'Model  {xlabel}')

axes[0].legend(frameon=False, loc='lower right')
plt.show()
<Figure size 1820x676 with 3 Axes>