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.
- Timezone-naive index across DST. A
DatetimeIndexwith notzcannot resolve daylight-saving transitions. The autumn fall-back hour duplicates a local timestamp, so a naiveresampleeither 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. - 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Δtis 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. - Missing hours and leap-year hour count. The denominator
Tis the number of hours in the period. Assuming a fixed8760silently understates a leap year’s8784hours, and treating a month as730hours 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. - 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%.
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:
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:
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.
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
NaNand letnp.nansumtreat 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_hoursandcoverageso 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
Δtvaries,gen_kw.resample("h").mean()(or.sum()for pre-accumulated energy) before the CF call restores a constant interval soΣP·Δtis 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
DataFrameofpower_kwcolumns and call.resample(freq).apply(...)once rather than looping per site; group metadata (each asset’scapacity_mw) in a lookupSeriesand 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:
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.
Related
- Temporal Data Aggregation — the parent workflow whose UTC-and-coverage contract this calculation depends on.
- Resampling Hourly Solar Data to Monthly Averages — the monthly-granularity companion for shaping capacity factor over the year.
- Solar PV Yield Simulation — produces the hourly AC power series this function integrates.
- Grid Capacity Buffer Analysis — where capacity factor and rated power feed interconnection headroom screening.