agentsclimarketplace

Run2 netcdf glm matching

Skill cxcscmu/SkillLearnBench/skills/b2-self-feedback-claude-haiku-4-5/temperature-simulation/run2_netcdf-glm-matching

Advanced matching of time-varying GLM simulation depths with observation dataFrom its SKILL.md

Install
npx -y skills add cxcscmu/SkillLearnBench --skill run2_netcdf-glm-matching

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

6.2 KB, ~1.5k tokens by cl100k_base, as published. Nobody here has run it

Time-Varying Depth Matching for GLM Netcdf

Problem

GLM simulations output time-varying depth coordinates (z variable). The water column structure changes with time due to mixing and stratification. Naive depth matching fails because:

  • Depths aren't fixed - they evolve with simulation
  • Multiple simulation layers may round to the same integer depth
  • Some time steps have many active layers, others have few
  • Masked/NaN values need careful handling

Solution: Temporal Matching

Load Data with Proper Type Handling

import netCDF4 as nc
import numpy as np
import pandas as pd

ds = nc.Dataset(output_file, 'r')

# Convert masked arrays to regular arrays with NaN for missing values
def safe_load(var):
    data = var[:]
    if hasattr(data, 'mask'):
        return np.ma.filled(data, np.nan)
    return np.array(data)

temp_data = safe_load(ds.variables['temp'])  # Shape: (time, z, lat, lon)
z_data = safe_load(ds.variables['z'])        # Shape: (time, z, lat, lon) - TIME VARYING!
time_data = safe_load(ds.variables['time'])

ds.close()

Convert Time to Datetime

# Get time units from netCDF attributes
ds = nc.Dataset(output_file, 'r')
units_str = ds.variables['time'].units  # e.g., "hours since 2009-01-01 12:00:00"
ds.close()

# Parse reference date and convert
ref_date_str = units_str.replace('hours since ', '')
ref_date = pd.to_datetime(ref_date_str)
time_datetime = ref_date + pd.to_timedelta(time_data, unit='h')

Efficient Temporal Matching

def match_observations_to_simulation(obs_df, temp_data, z_data, time_datetime):
    """
    Match observations to simulation by exact datetime and rounded depth.

    obs_df: DataFrame with columns [datetime, depth, obs_temp] (at least)
    z_data: (time, z_layers, lat, lon) - time-varying depths
    temp_data: (time, z_layers, lat, lon) - simulation temperatures
    time_datetime: datetime array for time dimension
    """

    # Ensure observations have required columns
    obs_df['depth_rounded'] = obs_df['depth'].round().astype(int)

    # Get unique observation times to avoid processing unnecessary timesteps
    obs_times_set = set(obs_df['datetime'].unique())

    matched_records = []

    for t_idx, t_val in enumerate(time_datetime):
        # Skip if no observations at this time
        if t_val not in obs_times_set:
            continue

        # Get observations at this time
        obs_at_time = obs_df[obs_df['datetime'] == t_val]

        # Extract profiles at this timestep (assuming single lat/lon)
        z_profile = z_data[t_idx, :, 0, 0]      # Shape: (z_layers,)
        temp_profile = temp_data[t_idx, :, 0, 0]  # Shape: (z_layers,)

        # Round depths for matching
        z_rounded = np.round(z_profile).astype(int)

        # For each observation at this time
        for _, obs_row in obs_at_time.iterrows():
            obs_depth_rounded = obs_row['depth_rounded']
            obs_temp = obs_row['obs_temp']

            # Find simulation layers at this rounded depth
            matching_indices = np.where(z_rounded == obs_depth_rounded)[0]

            # Use first valid match (could also use closest actual depth)
            for z_idx in matching_indices:
                sim_temp = temp_profile[z_idx]

                # Only include if temperature is valid (not NaN)
                if not np.isnan(sim_temp):
                    matched_records.append({
                        'datetime': t_val,
                        'depth_rounded': obs_depth_rounded,
                        'obs_temp': float(obs_temp),
                        'sim_temp': float(sim_temp),
                        'z_actual': float(z_profile[z_idx]),
                        'z_idx': z_idx
                    })
                    break  # Use first match, don't double-count

    return pd.DataFrame(matched_records)

Quality Checks

Verify Coverage

def check_matching_coverage(merged_df):
    """Ensure adequate data coverage for metrics"""

    print(f"Total matched pairs: {len(merged_df)}")
    print(f"Unique datetimes: {merged_df['datetime'].nunique()}")
    print(f"Unique depths: {sorted(merged_df['depth_rounded'].unique())}")

    # Check for sufficient deep samples
    deep = merged_df[merged_df['depth_rounded'] >= 13]
    print(f"\nDeep water (≥13m): {len(deep)} pairs")

    # Check for sufficient summer samples
    merged_df['month'] = merged_df['datetime'].dt.month
    summer = merged_df[(merged_df['month'] >= 6) & (merged_df['month'] <= 9)]
    print(f"Summer (Jun-Sep): {len(summer)} pairs")

    summer_deep = summer[summer['depth_rounded'] >= 13]
    print(f"Summer deep: {len(summer_deep)} pairs")

    if len(deep) < 500:
        print("WARNING: Limited deep samples for reliable annual_deep metric")
    if len(summer_deep) < 200:
        print("WARNING: Limited summer deep samples")

Handle Edge Cases

Multiple Matching Layers

# When multiple sim layers round to same integer depth,
# options include:
# 1. Use first match (current approach)
# 2. Use closest actual depth
# 3. Average matching layers

# Option 2: Use closest actual depth to observation
matching_indices = np.where(z_rounded == obs_depth_rounded)[0]
if len(matching_indices) > 0:
    # Find which one is closest to obs depth
    actual_depths = z_profile[matching_indices]
    closest_idx = matching_indices[np.argmin(np.abs(actual_depths - obs_depth_rounded))]
    sim_temp = temp_profile[closest_idx]

Handling NaN Values

# After matching, before RMSE computation:
# Remove any remaining invalid pairs
merged = merged.dropna(subset=['obs_temp', 'sim_temp'])

# Verify no NaN values remain
if merged['sim_temp'].isna().any() or merged['obs_temp'].isna().any():
    print(f"WARNING: {merged['obs_temp'].isna().sum()} NaN obs, {merged['sim_temp'].isna().sum()} NaN sim")

Key Differences from Simple Matching

  • Temporal iteration: Process one timestep at a time, skip unused times
  • Depth time-variance: Use z_data[t_idx] for each time, not a constant depth array
  • Memory efficiency: Don't create full (time × depth) cartesian product
  • Quality control: Explicit checks for sufficient data coverage

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.