05 — Compare an AMY with a TMY

Learning objectives: compare distributions, monthly conditions, solar totals, degree hours, and missing data without treating a TMY as a historical year.

Data requirement: run notebook 00 to create the AMY. This lesson downloads the linked OneBuilding Gothenburg TMYx archive once and caches its EPW under examples/output/. Network: OneBuilding on the first run. Expected runtime: about two minutes.

An AMY preserves one actual chronological year and is appropriate for calibration against that period. A TMY combines statistically representative months, often from different years, and is intended for typical-performance studies. Their difference is meaningful, but it is not an error score.

This example uses the OneBuilding Gothenburg City Airport TMYx 2004–2018 archive. It is third-party data, not produced or endorsed by SMHI or smhi2epw; check the archive’s accompanying metadata and the OneBuilding terms before reuse. Set SMHI2EPW_TMY_PATH if you prefer a local EPW from another lawful source.

[ ]:
import os
from io import BytesIO
from pathlib import Path
from shutil import copyfileobj
from zipfile import BadZipFile, ZipFile

import matplotlib.pyplot as plt
import pandas as pd
import requests

from smhi2epw import read_epw

TMY_URL = (
    "https://climate.onebuilding.org/WMO_Region_6_Europe/SWE_Sweden/"
    "VG_Vastra_Gotaland/SWE_VG_Goteborg.City.AP.025120_TMYx.2004-2018.zip"
)


def find_epw_file(directory):
    """Return the only EPW below *directory*, or explain the ambiguity."""
    matches = sorted(directory.rglob("*.epw"))
    if len(matches) != 1:
        raise FileNotFoundError(
            f"Expected exactly one extracted EPW in {directory}, found {len(matches)}."
        )
    return matches[0]


def download_onebuilding_epw(url, destination):
    """Download a ZIP safely, extract only its EPW, and return that path."""
    destination.mkdir(parents=True, exist_ok=True)
    existing = sorted(destination.rglob("*.epw"))
    if existing:
        return find_epw_file(destination)

    response = requests.get(url, timeout=120)
    response.raise_for_status()
    try:
        archive = ZipFile(BytesIO(response.content))
    except BadZipFile as error:
        raise ValueError(
            "The downloaded response is not a valid ZIP archive."
        ) from error

    with archive:
        members = [
            member
            for member in archive.infolist()
            if not member.is_dir() and Path(member.filename).suffix.lower() == ".epw"
        ]
        if len(members) != 1:
            raise ValueError(f"Expected one EPW in the archive, found {len(members)}.")
        member = members[0]
        target = destination / Path(member.filename).name
        temporary = target.with_suffix(".epw.part")
        with archive.open(member) as source, temporary.open("wb") as output:
            copyfileobj(source, output)
        temporary.replace(target)

    return find_epw_file(destination)


OUTPUT_DIR = Path("examples/output") if Path("examples").exists() else Path("output")
AMY_PATH = Path(os.environ.get("SMHI2EPW_AMY_PATH", OUTPUT_DIR / "gothenburg_2023.epw"))
tmy_override = os.environ.get("SMHI2EPW_TMY_PATH")
TMY_PATH = (
    Path(tmy_override)
    if tmy_override
    else download_onebuilding_epw(TMY_URL, OUTPUT_DIR / "onebuilding_tmyx")
)
if not AMY_PATH.exists():
    raise FileNotFoundError("Run notebook 00 to create the AMY.")
if not TMY_PATH.exists():
    raise FileNotFoundError(f"TMY EPW not found: {TMY_PATH}")
print(f"Using TMY: {TMY_PATH}")
amy, tmy = read_epw(AMY_PATH), read_epw(TMY_PATH)
[ ]:
def climate_summary(frame):
    """Calculate comparable annual EPW metrics."""
    temperature = frame.dry_bulb
    return pd.Series(
        {
            "mean_temperature_C": temperature.mean(),
            "p10_temperature_C": temperature.quantile(0.10),
            "p90_temperature_C": temperature.quantile(0.90),
            "GHI_kWh_m2": frame.ghi.sum() / 1000,
            "DNI_kWh_m2": frame.dni.sum() / 1000,
            "HDD18_C_h": (18 - temperature).clip(lower=0).sum(),
            "CDD22_C_h": (temperature - 22).clip(lower=0).sum(),
            "missing_percent": 100 * frame.isna().mean().mean(),
        }
    )


comparison = pd.concat(
    {"AMY": climate_summary(amy), "TMY": climate_summary(tmy)}, axis=1
)
comparison["AMY_minus_TMY"] = comparison.AMY - comparison.TMY
comparison.round(2)
[ ]:
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].hist(amy.dry_bulb.dropna(), bins=40, alpha=0.55, label="AMY")
axes[0].hist(tmy.dry_bulb.dropna(), bins=40, alpha=0.55, label="TMY")
axes[0].set(xlabel="Dry-bulb temperature (°C)", ylabel="Hours")
axes[0].legend()
for label, frame in {"AMY": amy, "TMY": tmy}.items():
    monthly = frame.groupby("month").dry_bulb.mean()
    axes[1].plot(monthly.index, monthly, marker="o", label=label)
axes[1].set(xlabel="Month", ylabel="Monthly mean temperature (°C)")
axes[1].legend()
fig.tight_layout()

Key takeaways and exercise

Do not align AMY and TMY rows as if they describe the same timestamp. Compare distributions and calendar categories instead. Check both files’ weather.attrs["location"], headers, source periods, and licensing before interpreting differences. The helper extracts only the .epw member and returns its repository-relative cached path; subsequent runs reuse that file.

Try it: compare the 20 coldest hours and monthly GHI. Which result is more sensitive to a single extreme event, and why might that matter for sizing versus annual energy?