Computing Capacity Factors from Hourly Generation Timeseries

Scenario: you resample an hourly generation series with gen.resample("YS").mean(), divide by rated power, and the annual capacity factor comes back as 1.34 — or a plausible 0.28 that is quietly 5–8% wrong because the index was timezone-naive across a daylight-saving transition. Both land in the reduction stage of the Temporal Data Aggregation workflow, where a high-frequency power series is collapsed into the single dimensionless number every project-finance model, PPA negotiation, and interconnection study treats as ground truth. A capacity factor greater than one raises no exception; a subtly biased one raises no exception either. Both surface at regulatory review, when rework is most expensive.

Capacity factor is the realised energy over a window divided by the energy the asset would produce running flat-out at its nameplate rating for the whole window:

where is instantaneous power, the sample interval, and the wall-clock length of the period. The arithmetic is a single division. Every production failure lives in the three terms the division consumes — the energy integral, the rated power, and the hour count — not in the ratio itself.

Root-cause analysis

Four independent errors corrupt this calculation, each mapping to a distinct correction stage below.

  1. Timezone-naive index across DST. A DatetimeIndex with no tz cannot resolve daylight-saving transitions. The autumn fall-back hour duplicates a local timestamp, so a naive resample either double-counts that hour’s energy or silently drops one of the pair; the spring forward creates a missing hour that a mean quietly absorbs. Reanalysis and SCADA exports store UTC, but a series localized to a civil timezone — or worse, left naive — shifts every annual boundary by the local offset and leaks generation across the year boundary.
  2. Sum versus mean, and irregular sampling. Energy is an integral, ΣP·Δt, not an average of instantaneous power. Taking .mean() and multiplying by a fixed hour count works only when Δt is perfectly regular; the instant the series has a five-minute burst embedded in hourly data, or gaps that compress the effective interval, the mean-times-hours shortcut diverges from the true Riemann sum.
  3. Missing hours and leap-year hour count. The denominator T is the number of hours in the period. Assuming a fixed 8760 silently understates a leap year’s 8784 hours, and treating a month as 730 hours ignores that February and July differ by three days. When observations are missing, dividing realised energy by the full period still returns a number, but it conflates low output with low coverage.
  4. Mixing kW and kWh, MW and MWh. Power in kW summed over hourly steps yields kWh; rated power quoted in MW must be scaled to the same unit before the ratio. A single unhandled factor of 1000 is the fastest route to a capacity factor of 1340%.
Four capacity-factor failure modes mapped to their corrective stages A left-to-right flow diagram. The left column lists four dashed-border failure nodes: timezone-naive index with DST double-count or gap; mean instead of the energy sum with irregular delta-t; missing hours with an 8760-versus-8784 leap-year error; and kW mixed with kWh causing a factor-of-1000 blow-up. Each maps by an arrow to a solid corrective node in the middle column: localize to a tz-aware UTC index and dedupe DST; integrate energy as the sum of power times delta-t in kWh; derive the hour count from period boundaries so leap years give 8784; and scale units explicitly from kW to MWh against MW rated power. The four corrective stages converge through a shared bus into a single highlighted output node on the right: a capacity-factor table bounded between 0 and 1 with a coverage mask and audit metadata. FAILURE MODE CORRECTION STAGE DEFENSIBLE OUTPUT Timezone-naive index DST double-count or gap Mean, not energy sum irregular Δt biases total Missing / leap hours 8760 vs 8784 kW mixed with kWh factor-of-1000 blow-up tz-aware UTC index localize · dedupe DST Energy integral Σ P·Δt → kWh Boundary hour count leap-aware 8784 Explicit unit scaling kW → MWh vs MW CF table 0 ≤ CF ≤ 1 coverage-masked audited

Pre-flight validation

Surface the broken assumption before the ratio runs. The naive pattern below is exactly what produces a capacity factor above one or a quietly biased total — no timezone, no cadence check, no unit discipline:

python
import pandas as pd

# Flawed: tz-naive index, mean instead of energy, hard-coded 8760, kW/MW mix
gen_kw = pd.read_parquet("plant_generation.parquet")["power_kw"]
annual_mean_kw = gen_kw.resample("YS").mean()
cf = annual_mean_kw / capacity_mw / 8760      # units and hour count both wrong

The pre-flight validator isolates which failure is present so a CI/CD run fails fast with a precise message instead of shipping a poisoned number:

python
import pandas as pd


def preflight_generation_series(gen_kw: pd.Series, interval_hours: float = 1.0) -> None:
    """Raise on the exact root cause before any capacity-factor ratio is formed."""
    idx = gen_kw.index
    if not isinstance(idx, pd.DatetimeIndex):
        raise TypeError("Generation series must carry a DatetimeIndex.")
    # Cause 1: a tz-naive index cannot resolve DST duplicates or spring-forward gaps
    if idx.tz is None:
        raise ValueError(
            "Index is timezone-naive; localize to UTC (or the site tz, then convert) "
            "so DST transitions are neither double-counted nor dropped."
        )
    if not idx.is_monotonic_increasing:
        raise ValueError("Index is not monotonic; sort_index() before resampling.")
    if idx.has_duplicates:
        raise ValueError("Duplicate timestamps (DST fall-back?); dedupe before aggregating.")
    # Cause 2: confirm the native cadence matches the Δt used in the energy integral
    step = pd.Timedelta(hours=interval_hours)
    deltas = idx.to_series().diff().dropna()
    off_cadence = int((deltas != step).sum())
    if off_cadence:
        print(f"[preflight] {off_cadence} intervals deviate from {interval_hours}h; "
              "irregular Δt biases a fixed-step sum — reindex to a regular grid first.")
Validation step Diagnostic Expected outcome
Timezone awareness gen_kw.index.tz is not None Index carries UTC (or a convertible tz)
Monotonic, unique idx.is_monotonic_increasing and not idx.has_duplicates No DST-duplicate or out-of-order rows
Regular cadence idx.to_series().diff().value_counts() A single dominant Δt (e.g. 0 days 01:00:00)
Units labelled column named power_kw, rating in capacity_mw kW and MW never mixed into the ratio

Fix implementation

The corrected function normalises to UTC, integrates energy as ΣP·Δt in explicit units, derives each period’s hour count from its own calendar boundaries so leap years resolve to 8784, and masks periods whose coverage falls below a threshold rather than reporting them as low output. Parameter choices are justified for energy use: freq="YS" gives year-start annual periods for the headline number, interval_hours makes the sample step explicit in the integral, and max_gap_frac=0.05 refuses a capacity factor when more than 5% of expected hours are missing.

python
import numpy as np
import pandas as pd


def _expected_hours(period_starts: pd.DatetimeIndex, freq: str) -> pd.Series:
    """Wall-clock hours in each period, from its own boundaries — leap-safe."""
    offset = pd.tseries.frequencies.to_offset(freq)
    ends = period_starts + offset          # next boundary handles 8784 h leap years
    hours = (ends - period_starts) / pd.Timedelta(hours=1)
    return pd.Series(hours, index=period_starts, name="expected_hours")


def capacity_factor(
    gen_kw: pd.Series,
    capacity_mw: float,
    freq: str = "YS",
    interval_hours: float = 1.0,
    max_gap_frac: float = 0.05,
) -> pd.DataFrame:
    """Capacity factor per period from an hourly generation series in kW.

    CF = actual_energy / (rated_power * hours). Energy is a Riemann sum
    ΣP·Δt (kW·h → kWh → MWh); the denominator uses each period's true hour
    count so leap years and unequal months stay correct.
    """
    preflight_generation_series(gen_kw, interval_hours=interval_hours)

    # Cause 1: UTC has no DST, so every period has an unambiguous, gap-free hour span.
    gen_kw = gen_kw.tz_convert("UTC").sort_index()

    grouped = gen_kw.resample(freq)
    # Cause 2 & 4: energy = Σ power·Δt in kWh, then kWh → MWh. Never a bare mean.
    energy_mwh = grouped.apply(
        lambda p: float(np.nansum(p.to_numpy())) * interval_hours
    ) / 1_000.0
    observed_hours = grouped.count() * interval_hours

    # Cause 3: hours per period from calendar boundaries, not a hard-coded 8760.
    expected_hours = _expected_hours(energy_mwh.index, freq)
    coverage = observed_hours / expected_hours

    # Denominator: MW rated × hours = MWh at continuous rated output (unit-matched).
    denom_mwh = capacity_mw * expected_hours
    cf = energy_mwh / denom_mwh

    out = pd.DataFrame({
        "energy_mwh": energy_mwh,
        "expected_hours": expected_hours,
        "observed_hours": observed_hours,
        "coverage": coverage,
        # Cause 3: withhold CF where coverage is too low to be defensible.
        "capacity_factor": cf.where(coverage >= (1.0 - max_gap_frac)),
    })
    out.attrs.update({
        "capacity_mw": capacity_mw,
        "frequency": freq,
        "interval_hours": interval_hours,
        "max_gap_frac": max_gap_frac,
        "energy_convention": "sum(power_kw * interval_hours) / 1000 -> MWh",
        "time_reference": "UTC",
    })
    return out

Deriving expected_hours from period_start + offset is the load-bearing detail: adding a YS offset to 2020-01-01 lands on 2021-01-01, a span of exactly 8784 hours, while 2021 returns 8760. The same boundary arithmetic keeps a monthly (freq="MS") run correct across February and the 31-day months, so the denominator never has to know how long a period “should” be. Because the series is in UTC, that span is pure wall-clock hours with no DST discontinuity to reconcile — the whole reason the Temporal Data Aggregation contract insists on a UTC index before any reduction.

Fallback routing & performance tuning

For real SCADA and metered series where gaps, unit ambiguity, and partial periods are the norm rather than the exception, layer these policies on top of the core function:

  • Gap-filling policy, declared not implicit. For short outages (a few consecutive missing hours), interpolate power before integrating only if the plant physics justify it — otherwise leave gaps as NaN and let np.nansum treat them as zero energy, which is conservative. Record which policy ran; a filled hour and a genuine zero are not the same evidentiary claim.
  • Partial-period handling at series edges. The first and last periods are usually incomplete. The coverage mask already withholds their capacity factor, but expose observed_hours and coverage so a caller can report a partial-year CF with an explicit caveat rather than a silently deflated number.
  • Reindex irregular data to a regular grid. If Δt varies, gen_kw.resample("h").mean() (or .sum() for pre-accumulated energy) before the CF call restores a constant interval so ΣP·Δt is a valid integral. Choose the reindex reducer to match whether the raw column is instantaneous power or accumulated energy.
  • Vectorize across an asset fleet. For a portfolio, pass a wide DataFrame of power_kw columns and call .resample(freq).apply(...) once rather than looping per site; group metadata (each asset’s capacity_mw) in a lookup Series and broadcast the division. This is the same batching discipline that keeps grid capacity buffer analysis tractable when scoring every interconnection candidate on a corridor.
  • Feed, don’t re-derive. When a physical model already exists, integrate its hourly output directly — the AC power series from a PV yield simulation run drops straight into capacity_factor() without re-implementing the energy sum.

Downstream validation

Before a capacity factor reaches a finance model or a resource-assessment report, gate it with an assertion suitable for a CI/CD pipeline. This catches the sign errors, unit mixes, and hour-count drift that produce a number outside the physically possible range:

python
def assert_capacity_factor(cf_table: pd.DataFrame) -> None:
    """CI/CD gate: fail the build if the capacity-factor table is not defensible."""
    cf = cf_table["capacity_factor"].dropna()
    assert (cf >= 0.0).all(), "negative capacity factor — sign or unit error"
    assert (cf <= 1.0).all(), (
        "capacity factor > 1.0 — kW/kWh mix, wrong rated power, or double-counted DST hour"
    )
    # Observed samples can never exceed the hours in the period (duplicate timestamps).
    assert (cf_table["observed_hours"] <= cf_table["expected_hours"] + 1e-6).all(), \
        "observed hours exceed the period length — duplicate or DST-collided timestamps"
    # Leap-year hour count: any full annual period must be exactly 8760 or 8784 hours.
    full_year = cf_table["expected_hours"] > 8000
    annual_hours = cf_table.loc[full_year, "expected_hours"].round()
    assert annual_hours.isin([8760, 8784]).all(), (
        "near-annual period with a non-8760/8784 hour count — calendar boundary drift"
    )

The capacity_factor > 1.0 assertion is the single most valuable line: it is physically impossible for real generation and therefore an unambiguous signal that units were mixed or a DST hour was double-counted, exactly the class of error that never raises on its own. Logging coverage alongside the pass/fail record keeps the result auditable — an independent engineer reviewing the interconnection or project-finance package can see how many hours were measured versus assumed. Pin pandas in pyproject.toml so a change to resampling or offset semantics cannot silently shift the hour count between runs, and route the same series through resampling hourly solar data to monthly averages when the monthly shape of the capacity factor, not just its annual value, is what the study needs.