Modeling Bifacial and Tracker Gains with pvlib
The scenario: a pro forma assumes a 25 percent tracker gain and a 10 percent bifacial gain, adds them, and books 35 percent over a fixed monofacial baseline. The modelled result comes in at 27 percent, because tracking already captures much of what bifaciality would have gained, backtracking gives away some of the tracker benefit to avoid row shading, and the rear-side gain depends on an albedo nobody measured. This page models both properly, and it extends solar PV yield simulation.
Root-cause analysis
Four modelling errors account for the gap between a headline gain and a modelled one.
- Adding gains that are not independent. Tracking raises plane-of-array irradiance by keeping the module normal to the sun; bifaciality collects reflected light on the rear. Both draw partly on the same resource, so the combined gain is materially less than the sum.
- Ignoring backtracking. A single-axis tracker at low sun angles would shade the next row, so it rotates back toward horizontal. That is a deliberate loss of direct gain in exchange for avoiding a larger shading loss, and a model without it over-states morning and evening output.
- Assuming an albedo. Rear irradiance scales almost linearly with ground reflectance, and the plausible range — 0.15 for dark soil to 0.55 for fresh snow or light gravel — is a factor of three. A default of 0.25 is a guess that carries most of the bifacial uncertainty.
- Omitting rear-side losses. Rear irradiance is not free energy: mismatch, structure shading and a lower bifaciality factor mean the module converts it at roughly 70 percent of front efficiency.
Pre-flight validation
Bifacial and tracker models need inputs a fixed monofacial model does not. Assert them before the ModelChain is built, not part-way through an 8,760-hour run.
def preflight_bifacial_tracker(system: dict, weather) -> dict:
"""Refuse to model what the inputs cannot support."""
required_tracker = {"axis_tilt", "axis_azimuth", "max_angle", "gcr", "backtrack"}
required_bifacial = {"bifaciality", "module_height", "pitch", "albedo"}
missing_t = required_tracker - set(system)
if missing_t:
raise ValueError(f"tracker model needs {sorted(missing_t)}")
missing_b = required_bifacial - set(system)
if missing_b:
raise ValueError(f"bifacial model needs {sorted(missing_b)}")
if not 0.0 < system["gcr"] <= 0.6:
raise ValueError(f"ground coverage ratio {system['gcr']} outside a buildable range")
if not 0.05 <= system["albedo"] <= 0.9:
raise ValueError(f"albedo {system['albedo']} outside any measured surface")
if "dni" not in weather or "dhi" not in weather:
raise ValueError("bifacial and transposition models need DNI and DHI, not GHI alone")
return {
"tracker": "single-axis" + (" with backtracking" if system["backtrack"] else ""),
"gcr": system["gcr"],
"albedo": system["albedo"],
"bifaciality": system["bifaciality"],
"albedo_source": system.get("albedo_source", "ASSUMED — measure or cite"),
}
The albedo_source field exists to make an assumption visible. An albedo carried through a model
without a citation is the single largest unquantified term in a bifacial yield estimate.
Fix implementation
import pandas as pd
import pvlib
def model_tracker_bifacial(
weather: pd.DataFrame,
location: pvlib.location.Location,
system: dict,
) -> pd.DataFrame:
"""Front and rear plane-of-array irradiance for a backtracking single-axis array."""
solpos = location.get_solarposition(weather.index)
tracking = pvlib.tracking.singleaxis(
apparent_zenith=solpos["apparent_zenith"],
apparent_azimuth=solpos["azimuth"],
axis_tilt=system["axis_tilt"],
axis_azimuth=system["axis_azimuth"],
max_angle=system["max_angle"],
backtrack=system["backtrack"],
gcr=system["gcr"],
)
poa_front = pvlib.irradiance.get_total_irradiance(
surface_tilt=tracking["surface_tilt"].fillna(0),
surface_azimuth=tracking["surface_azimuth"].fillna(180),
solar_zenith=solpos["apparent_zenith"],
solar_azimuth=solpos["azimuth"],
dni=weather["dni"], ghi=weather["ghi"], dhi=weather["dhi"],
albedo=system["albedo"], model="perez",
)
# Rear irradiance from the infinite-sheds model: it accounts for row geometry,
# ground reflection and the view factor each row actually sees.
sheds = pvlib.bifacial.infinite_sheds.get_irradiance(
surface_tilt=tracking["surface_tilt"].fillna(0),
surface_azimuth=tracking["surface_azimuth"].fillna(180),
solar_zenith=solpos["apparent_zenith"],
solar_azimuth=solpos["azimuth"],
gcr=system["gcr"],
height=system["module_height"],
pitch=system["pitch"],
ghi=weather["ghi"], dhi=weather["dhi"], dni=weather["dni"],
albedo=system["albedo"],
npoints=100,
)
effective = sheds["poa_front"] + system["bifaciality"] * sheds["poa_back"]
return pd.DataFrame({
"tracker_angle": tracking["tracker_theta"],
"surface_tilt": tracking["surface_tilt"],
"poa_front_fixed_ref": poa_front["poa_global"],
"poa_front": sheds["poa_front"],
"poa_back": sheds["poa_back"],
"poa_effective": effective,
"bifacial_ratio": sheds["poa_back"] / sheds["poa_front"].replace(0, pd.NA),
})
infinite_sheds rather than a flat rear-irradiance assumption is what makes the bifacial term
defensible: it accounts for the row pitch, the module height and the view factor each row has of the
ground, all of which change the rear gain by more than the albedo uncertainty does.
Fallback routing and performance tuning
- Model the fixed monofacial baseline in the same run. The gain is a ratio, and computing the baseline separately invites a mismatch in weather, losses or period.
- Sensitivity-test the albedo, always. Running at 0.18 and 0.35 brackets most sites and turns one number into a range that survives review.
- Watch
gcrand pitch together. Raising ground coverage lifts energy per hectare and lowers both the bifacial gain and the tracker gain, because rows shade each other and the ground sooner. - Vectorise over sites, not over hours. pvlib is already vectorised across the time index; the parallelism worth adding is one site per worker.
- Cache the solar position. It depends only on location and time index, so a portfolio at one latitude can share it across every system variant.
Downstream validation
def assert_gains_plausible(result, baseline_kwh: float, *, site_latitude: float) -> None:
"""Bounds that catch a mis-specified tracker or an implausible albedo."""
tracker_gain = result["tracker_only_kwh"] / baseline_kwh - 1.0
bifacial_gain = result["combined_kwh"] / result["tracker_only_kwh"] - 1.0
assert 0.10 <= tracker_gain <= 0.35, (
f"tracker gain {tracker_gain:.1%} outside the plausible 10–35% band — check gcr and max_angle"
)
assert 0.02 <= bifacial_gain <= 0.15, (
f"bifacial gain {bifacial_gain:.1%} outside 2–15% — check albedo, height and pitch"
)
combined = result["combined_kwh"] / baseline_kwh - 1.0
assert combined < tracker_gain + bifacial_gain + 1e-9, (
"combined gain equals the sum of the parts — the two models are not interacting"
)
if abs(site_latitude) > 50:
assert tracker_gain < 0.30, "tracker gain above 30% at high latitude is not credible"
The third assertion is the one that catches the opening scenario directly: if the combined gain equals the sum of the individual gains, the models were run independently and added rather than composed.
Frequently asked questions
Why is the combined gain less than the sum?
Because both technologies harvest partly the same photons. A tracker increases the front-side capture of direct beam irradiance, which reduces the share of total resource left to be reflected and collected on the rear. Modelling them together captures the interaction; adding two independently modelled gains double-counts it, typically by three to six percentage points.
How much does albedo really matter?
It is close to the whole bifacial uncertainty. Rear irradiance scales nearly linearly with ground reflectance, so moving from 0.18 to 0.35 roughly doubles the rear contribution and moves the combined gain by four to five percentage points. Measuring it on site — or citing a defensible surface-specific value — is the highest-value input in the whole bifacial model.
Does backtracking cost energy?
It gives up direct gain at low sun angles and avoids a larger row-to-row shading loss, so the net is positive at any realistic ground coverage. Turning it off in a model produces a higher number and a layout that does not behave that way in the field, which is why the backtrack flag belongs in the pre-flight assertions rather than in a default.
Should the rear-side loss be modelled separately?
Yes, and the bifaciality factor is not the whole story. The module datasheet bifaciality — typically 0.65 to 0.85 — covers the cell response; structure shading, rear mismatch and soiling on the rear surface are additional and site-specific. Folding them into one number hides which is which when the model is later compared with measured output.
How does row pitch interact with the gains?
Directly and in opposite directions. A wider pitch raises both the bifacial gain — more ground is visible to each row — and the tracker gain, because backtracking engages later, while lowering energy per hectare. That trade is the real layout decision, and it is only visible when both models run against a swept pitch rather than a single design point.
Can this be validated against measured output?
Yes, and the useful comparison is per-hour rather than annual. An annual total can match while the diurnal shape is wrong, which usually means the tracker geometry or the backtracking threshold is off. Comparing modelled and measured morning ramp separates a geometry error from an albedo error quickly.
Does module height above ground change the bifacial gain much?
Substantially, and it is the input most often left at a default. Raising modules from one metre to two metres above ground widens the ground area each row sees and typically adds one to two percentage points of rear contribution, because the view factor improves faster than the extra structure shading costs. Beyond about two and a half metres the gain flattens while the racking cost does not, which is why the height belongs in the sweep alongside pitch rather than being fixed early.
Should the tracker and bifacial models be validated separately?
Yes, and in that order. Tracker geometry is deterministic — given a location, a time index and an axis configuration, the tracker angle is a calculation with no free parameters — so it can be checked against measured tracker positions exactly. Only once the geometry matches is a bifacial comparison meaningful, because a rear-side discrepancy and a tracker-angle discrepancy look identical in an energy total. Validating them together leaves two unknowns and one equation.
Related
- Solar PV Yield Simulation — the parent workflow and its loss chain
- Simulating Hourly PV Output with pvlib ModelChain — the chain these irradiance terms feed
- Computing Capacity Factors from Hourly Generation Timeseries — turning the modelled output into a comparable figure
- Zonal Statistics of GHI over Candidate Parcels with rasterstats — sourcing the per-site resource these models consume