Model Calibration Skill

SkillMonitoring & ops

Calculating RMSE metrics and parameter calibration for lake model validation

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Model Calibration Skill skill

What this skill tells your AI

The instructions your AI receives, as published by cxcscmu/skilllearnbench in skills/b1-one-shot-claude-haiku-4-5/temperature-simulation/model-calibration/SKILL.md and read by ahel’s review.

Overview

Model calibration involves adjusting parameters to minimize differences between simulations and observations. RMSE (Root Mean Squared Error) is a standard metric for measuring model performance.

RMSE Calculation

Basic Formula

RMSE = sqrt(mean((simulated - observed)^2))

Implementation

import numpy as np
import pandas as pd

def calculate_rmse(simulated, observed):
    """Calculate RMSE between matched pairs"""
    if len(simulated) == 0:
        return np.nan
    residuals = simulated - observed
    rmse = np.sqrt(np.mean(residuals**2))
    return rmse

def calculate_metrics(sim_temps, obs_temps, depths=None, dates=None):
    """
    Calculate multiple RMSE metrics

    Parameters:
    - sim_temps: matched simulated temperatures
    - obs_temps: matched observed temperatures
    - depths: depth of each match (for deep water filtering)
    - dates: datetime of each match (for seasonal filtering)

    Returns:
    - overall_rmse: RMSE of all matched pairs
    - annual_deep_rmse: RMSE of pairs at depths >= 13m
    - summer_deep_rmse: RMSE of summer (Jun-Sep) pairs at depths >= 13m
    """

    overall_rmse = calculate_rmse(sim_temps, obs_temps)

    # Annual deep water (depths >= 13m)
    if depths is not None:
        deep_mask = np.array(depths) >= 13
        annual_deep_rmse = calculate_rmse(
            sim_temps[deep_mask],
            obs_temps[deep_mask]
        )
    else:
        annual_deep_rmse = np.nan

    # Summer deep water (Jun-Sep, depths >= 13m)
    if dates is not None and depths is not None:
        summer_mask = (np.array([d.month for d in dates]) >= 6) & \
                     (np.array([d.month for d in dates]) <= 9)
        deep_mask = np.array(depths) >= 13
        combined_mask = summer_mask & deep_mask
        summer_deep_rmse = calculate_rmse(
            sim_temps[combined_mask],
            obs_temps[combined_mask]
        )
    else:
        summer_deep_rmse = np.nan

    return {
        'overall_rmse': overall_rmse,
        'annual_deep_rmse': annual_deep_rmse,
        'summer_deep_rmse': summer_deep_rmse
    }

Matching Observations to Simulation

Critical: Use exact datetime + rounded-depth matching (no interpolation)

def match_obs_to_sim(obs_df, sim_temps, sim_z, sim_dates, round_depth=1):
    """
    Match observations to simulation output

    obs_df: DataFrame with columns [datetime, depth, temp]
    sim_temps: [time, depth] array
    sim_z: depth values
    sim_dates: datetime for each time step
    round_depth: rounding for depth matching

    Returns: matched simulation temps, observed temps, and metadata
    """
    import pandas as pd

    obs_df['depth_rounded'] = (obs_df['depth'] / round_depth).round() * round_depth

    matched_sim = []
    matched_obs = []
    matched_depths = []
    matched_dates = []

    for idx, row in obs_df.iterrows():
        obs_datetime = pd.Timestamp(row['datetime'])
        obs_depth = row['depth_rounded']
        obs_temp = row['temp']

        # Find exact datetime match
        time_matches = [i for i, d in enumerate(sim_dates)
                       if d == obs_datetime]

        # Find exact depth match
        depth_matches = [i for i, z in enumerate(sim_z)
                        if z == obs_depth]

        # Need both to match
        if time_matches and depth_matches:
            t_idx = time_matches[0]
            z_idx = depth_matches[0]
            sim_temp = sim_temps[t_idx, z_idx]

            matched_sim.append(sim_temp)
            matched_obs.append(obs_temp)
            matched_depths.append(obs_depth)
            matched_dates.append(obs_datetime)

    return (np.array(matched_sim), np.array(matched_obs),
            matched_depths, matched_dates)

Parameter Tuning Strategy

1. Sensitivity Analysis

Run model with one parameter varied at a time to identify most influential parameters.

2. Systematic Search

For 5 parameters with small ranges, can do grid search:

  • Test 3-5 values per parameter
  • Evaluate all combinations
  • Select best performers

3. Iterative Refinement

  • Start with literature values
  • Adjust parameters that have largest impact on target RMSE
  • Focus on worst-performing seasonal/depth bins

4. Constraint Checking

Always verify final parameters are within published ranges:

def validate_parameters(params):
    ranges = {
        'Kw': (0.1, 0.5),
        'coef_mix_hyp': (0.3, 0.7),
        'wind_factor': (0.7, 1.3),
        'lw_factor': (0.7, 1.3),
        'ch': (0.0005, 0.002)
    }
    for param, (min_val, max_val) in ranges.items():
        if not (min_val <= params[param] <= max_val):
            raise ValueError(f"{param} out of range: {params[param]}")
    return True

Evaluation Thresholds

For Lake Mendota:

  • Overall RMSE < 1.60°C
  • Annual deep (≥13m) RMSE < 1.55°C
  • Summer deep (Jun-Sep, ≥13m) RMSE < 1.70°C

Model is successful when ALL three thresholds are met.

Signals

GitHub stars
83
Forks
5
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
model-calibration
Source
github.com/cxcscmu/skilllearnbench