Dynamic vs Static Baselines for REDD Projects

A baseline is a counterfactual: how much forest would have been lost had the project not existed. Because the counterfactual is unobservable, every methodology substitutes something measurable for it, and the two families of substitute in current use behave completely differently as software. A static baseline is fixed at validation and held for a crediting period. A dynamic baseline is recalculated periodically from observed deforestation in a comparison region, so this year’s issuance depends on data that did not exist when the project was designed. This guide covers what that difference costs a pipeline, within forest carbon baseline and additionality modeling in the spatial modeling and carbon stock validation stack.

The engineering distinction is sharper than the methodological one. A static baseline is a constant the pipeline reads. A dynamic baseline is an output the pipeline computes, from third-party inputs, on a schedule the project does not control, with a result that can revise previously reported figures. Systems built for the first shape rarely survive contact with the second, and the migration between them — which VM0048 forced on a large part of the REDD portfolio — is where most of the pain has been.

A static baseline against a dynamic one over a ten-year crediting period A time series over ten years showing deforestation rate on the vertical axis. A flat horizontal line represents the static baseline, set at validation from the historical reference period and held constant for the whole crediting period. A stepped line represents the dynamic baseline, recalculated every two years from observed deforestation in the jurisdictional comparison region, which falls when regional pressure falls and rises when it rises. A third jagged line is the project's own observed deforestation, well below both. The credited volume is the gap between baseline and observed, shaded, and the two shadings are visibly different in later years: the static baseline keeps crediting at the original level while the dynamic one has fallen with regional pressure, producing far fewer credits from identical project performance. Identical project performance, two baselines, different issuance The gap between the lines is the credit. In year 8 the two disagree by more than half. high 0 yr 0 yr 5 yr 10 deforestation rate static baseline fixed at validation, held for 10 years dynamic baseline recalculated every 2 years from the comparison region project observed unchanged in both cases The project did the same thing either way. What changed was the counterfactual it is measured against — and whether that counterfactual can move after issuance.

Root Cause Analysis

Three structural differences drive everything else, and each one lands somewhere specific in a pipeline.

A static baseline is an input; a dynamic one is a dependency. With a static baseline the deforestation rate to beat is a validated constant, versioned alongside the project design document and read like any other reference value. With a dynamic baseline the pipeline must ingest a jurisdictional activity-data product it does not own, on the publisher’s schedule, in whatever format the publisher chose, with whatever restatements the publisher applies to prior years. That is an integration with an external party, and it needs the treatment integrations get — schema pinning, version capture, availability monitoring, and a defined behaviour when the upstream product is late.

A dynamic baseline makes past results revisable. When the comparison region’s historical rate is restated — because the jurisdiction reprocessed its imagery, or corrected a classification error, or extended its reference period — the baseline for periods already credited changes. Whether that triggers a restatement of issued credits is a methodological question with different answers in different programmes, but the pipeline must at minimum be able to answer what the figure was, what it is now, and why. A pipeline that overwrites the baseline in place cannot answer any of those.

The volatility profile is completely different. A static baseline produces smooth, predictable issuance and a well-known risk: if regional deforestation collapses for reasons unrelated to the project, the project keeps crediting against a counterfactual that no longer describes anything. A dynamic baseline removes that risk and adds another: issuance now varies year to year with regional conditions the project cannot influence, which makes revenue forecasting harder and makes a bad year attributable to a neighbouring jurisdiction’s enforcement campaign.

The failure mode that connects all three is a pipeline that models the baseline as a scalar in a configuration file. That representation is adequate for exactly one of the two families, and converting it later means touching every calculation that read it.

Diagnostic Pipeline / Pre-Flight Validation

Before a period can be credited under either regime, the baseline that will be used has to be resolved, verified as applicable to the period, and frozen. Under a dynamic regime that resolution is the step where most errors originate, because it involves choosing among several vintages of an external product.

from dataclasses import dataclass
from datetime import date

import structlog

log = structlog.get_logger()


@dataclass(frozen=True)
class BaselineVintage:
    """One published version of a baseline rate, with its provenance.

    A dynamic baseline is never a single number — it is a series of vintages,
    and which one applies to a monitoring period is a decision that must be
    recorded rather than implied by whatever was current at run time.
    """
    baseline_id: str
    regime: str                 # static | dynamic
    rate_ha_yr: float
    applies_from: date
    applies_to: date
    source_product: str
    source_version: str
    published_on: date
    supersedes: str | None


@dataclass(frozen=True)
class MonitoringPeriod:
    period_id: str
    start: date
    end: date
    observed_loss_ha: float


class BaselineResolutionError(RuntimeError):
    """Raised when no single vintage unambiguously covers the period."""


def resolve_baseline(
    period: MonitoringPeriod,
    vintages: list[BaselineVintage],
    *,
    as_of: date,
) -> BaselineVintage:
    """Pick the vintage that governs a period, as known at a stated date.

    `as_of` is mandatory and is the point of the function. Resolving 'the
    current baseline' is not reproducible — the same call next year returns
    something different. Resolving 'the baseline as known on 2027-03-01'
    returns the same answer forever, which is what an audit needs.
    """
    candidates = [
        v for v in vintages
        if v.published_on <= as_of
        and v.applies_from <= period.start
        and v.applies_to >= period.end
    ]

    if not candidates:
        raise BaselineResolutionError(
            f"no baseline vintage published by {as_of} covers period "
            f"{period.period_id} ({period.start}..{period.end}); the "
            "jurisdictional product may be late — do not fall back to the "
            "previous vintage silently"
        )

    # Latest publication wins; ties are a data error, not a preference.
    latest = max(candidates, key=lambda v: v.published_on)
    same_date = [v for v in candidates if v.published_on == latest.published_on]
    if len(same_date) > 1:
        raise BaselineResolutionError(
            f"{len(same_date)} vintages share publication date "
            f"{latest.published_on} for period {period.period_id}: "
            + ", ".join(v.baseline_id for v in same_date)
        )

    superseded = [v.baseline_id for v in candidates if v.baseline_id != latest.baseline_id]
    log.info(
        "baseline.resolved",
        period=period.period_id,
        baseline=latest.baseline_id,
        regime=latest.regime,
        rate_ha_yr=latest.rate_ha_yr,
        as_of=as_of.isoformat(),
        superseded=superseded,
    )
    return latest


def assert_comparison_region_current(
    vintage: BaselineVintage, period: MonitoringPeriod, *, max_lag_days: int = 550
) -> None:
    """A dynamic baseline built from stale regional data is a static one.

    The whole justification for the dynamic regime is that the counterfactual
    tracks current regional pressure. When the underlying product has not been
    refreshed within roughly the recalculation interval, that justification
    is gone and the fact should surface rather than be absorbed.
    """
    if vintage.regime != "dynamic":
        return

    lag = (period.end - vintage.published_on).days
    if lag > max_lag_days:
        raise BaselineResolutionError(
            f"dynamic baseline {vintage.baseline_id} was published "
            f"{lag} days before the end of period {period.period_id}, "
            f"beyond the {max_lag_days}-day freshness limit — it no longer "
            "reflects current regional pressure"
        )

The mandatory as_of argument is the single most valuable line in this module. It converts every baseline lookup from a query about the present into a query about a stated moment, which is what makes a recomputation two years later reproduce the original number instead of quietly producing a better one.

What each regime demands of the pipeline, side by side Two columns comparing pipeline obligations. Under a static baseline the pipeline reads a validated constant, has no external dependency, produces stable issuance, restates only when the project itself restates, and needs a single frozen reference value under version control. Under a dynamic baseline the pipeline ingests a third-party jurisdictional product on the publisher's schedule, must handle late and restated upstream data, produces issuance that varies with regional conditions outside the project's control, must be able to reproduce any past figure as it stood on a stated date, and needs a full vintage table rather than a constant. A panel notes that the second column is an integration with an external party and should be engineered as one. The same calculation, two very different systems around it Static baseline reads a validated constant no external dependency issuance is smooth and forecastable restates only when the project does storage: one versioned value risk: the counterfactual stops describing reality Dynamic baseline ingests a jurisdictional product handles late and restated upstream data issuance varies with the region must reproduce any figure as-of a date storage: a vintage table risk: a neighbour's enforcement year cuts issuance The right-hand column is an external integration. Engineer it as one, or it will fail like one.

Deterministic Transformation Logic

The crediting calculation itself is short. What makes it defensible is that every input to it is captured at the moment of calculation, so the result can be regenerated later without needing the world to still be in the state it was.

from dataclasses import dataclass, asdict
from datetime import date


@dataclass(frozen=True)
class CreditingResult:
    """A crediting calculation with every input that produced it.

    Everything needed to reproduce the number is inside this record. Nothing
    in it is a reference to mutable external state, which is what allows a
    verifier in 2033 to check a 2027 figure.
    """
    period_id: str
    baseline_id: str
    baseline_regime: str
    baseline_rate_ha_yr: float
    baseline_as_of: date
    source_product: str
    source_version: str
    observed_loss_ha: float
    period_years: float
    avoided_loss_ha: float
    carbon_density_tco2e_ha: float
    gross_tco2e: float
    leakage_deduction_tco2e: float
    uncertainty_deduction_tco2e: float
    net_tco2e: float


def compute_crediting(
    period: MonitoringPeriod,
    vintage: BaselineVintage,
    *,
    as_of: date,
    carbon_density_tco2e_ha: float,
    leakage_rate: float,
    uncertainty_rate: float,
) -> CreditingResult:
    """Avoided loss against the resolved baseline, with deductions applied.

    Negative avoided loss is preserved rather than clamped: a period where
    the project lost more forest than the baseline predicted is real
    information, and clamping it to zero converts a bad period into a
    neutral one, which compounds across a crediting period.
    """
    years = (period.end - period.start).days / 365.25
    expected_loss_ha = vintage.rate_ha_yr * years
    avoided_ha = expected_loss_ha - period.observed_loss_ha

    gross = avoided_ha * carbon_density_tco2e_ha
    leakage = max(0.0, gross) * leakage_rate
    uncertainty = max(0.0, gross - leakage) * uncertainty_rate

    return CreditingResult(
        period_id=period.period_id,
        baseline_id=vintage.baseline_id,
        baseline_regime=vintage.regime,
        baseline_rate_ha_yr=vintage.rate_ha_yr,
        baseline_as_of=as_of,
        source_product=vintage.source_product,
        source_version=vintage.source_version,
        observed_loss_ha=period.observed_loss_ha,
        period_years=round(years, 4),
        avoided_loss_ha=round(avoided_ha, 3),
        carbon_density_tco2e_ha=carbon_density_tco2e_ha,
        gross_tco2e=round(gross, 2),
        leakage_deduction_tco2e=round(leakage, 2),
        uncertainty_deduction_tco2e=round(uncertainty, 2),
        net_tco2e=round(gross - leakage - uncertainty, 2),
    )


def diff_against_prior(
    current: CreditingResult, prior: CreditingResult
) -> dict[str, tuple[object, object]]:
    """Field-level differences between two runs of the same period.

    Under a dynamic regime this runs on every recalculation and its output is
    the restatement note. An empty dict means the period is unchanged; a dict
    containing only baseline fields means the project's own data is stable
    and the movement came from upstream.
    """
    if current.period_id != prior.period_id:
        raise ValueError("cannot diff crediting results for different periods")

    a, b = asdict(prior), asdict(current)
    return {k: (a[k], b[k]) for k in a if a[k] != b[k]}

The diff_against_prior helper does more work than its size suggests. Under a dynamic regime, every recalculation potentially moves a previously reported figure, and the difference between “our monitoring changed” and “the jurisdiction restated its history” is the first question anyone will ask. Producing that answer mechanically from the two records removes the investigation entirely.

Compliance Gating & Audit Trail Generation

Both regimes need the crediting result persisted immutably per period per run, but the dynamic regime needs three additional things.

A record of every recalculation, not just the latest. The set of CreditingResult records for a period, ordered by baseline_as_of, is the restatement history, and it is what a verifier reads to understand why an issued volume differs from a reported one.

The upstream product’s own version and publication date, captured at ingestion rather than looked up later. Jurisdictional products are frequently republished under the same name, and a version string captured after the fact describes what is available now rather than what was used.

An explicit decision when the upstream product is late. The resolution function above refuses rather than falling back, and the operational counterpart is a documented choice: delay the monitoring report, or issue against the prior vintage with a stated note. Either is defensible; making the choice implicitly in code is not, because the pipeline then quietly credits against a baseline the methodology may no longer consider valid.

Production Integration

Projects converting from static to dynamic baselines — the direction the market has moved — should expect the work to land in ingestion and storage rather than in the crediting arithmetic. The calculation barely changes. What changes is that a constant becomes a table with validity intervals, every read acquires an as_of, and the pipeline grows a dependency whose availability it does not control.

The pattern that works is to treat the jurisdictional baseline exactly as versioning emission factor databases for reproducible MRV treats factor tables: append-only vintages, validity intervals, mandatory as-of resolution, and a refusal to interpolate across a gap. The problems are the same problems, and a project that has already solved them for emission factors has most of the machinery.

One further consideration applies to projects running both regimes at once, which is more common than it sounds during a transition. Keep the two paths structurally identical — same result record, same resolution function, with the regime as a field rather than a branch — so that comparing what a period would have earned under each is a query rather than a parallel implementation. That comparison is usually the first thing a project developer asks for, and building it in costs nothing at the start.

A restatement, and how the audit record separates upstream movement from project movement A timeline showing one monitoring period calculated three times. The first calculation in March 2027 uses baseline vintage one and reports a net volume. The second calculation in March 2029 uses vintage two, published after the jurisdiction reprocessed its historical imagery, and reports a lower net volume; the field-level difference shows only baseline fields changing, so the note reads upstream restatement, project data unchanged. The third calculation in March 2031 uses vintage two still but with corrected project monitoring, and the difference shows only observed loss changing, so the note reads project restatement, baseline unchanged. A panel notes that the distinction is produced mechanically by diffing the two stored records, not by investigation. One period, three calculations, two different reasons for moving 2027-03 · first calculation baseline vintage v1 observed loss 412 ha net 184,200 tCO₂e reported and issued 2029-03 · recalculated baseline vintage v2 observed loss 412 ha — same net 141,800 tCO₂e diff: baseline fields only 2031-03 · recalculated baseline vintage v2 — same observed loss 438 ha net 130,100 tCO₂e diff: project fields only Which one moved is answered by diffing two stored records. Without both records stored in full, the same question takes a week and ends in an assertion nobody can check.

Frequently Asked Questions

Does a dynamic baseline eliminate over-crediting?

It removes one specific mechanism of it — a baseline drifting away from regional reality over a long fixed period — and leaves others intact. It does nothing about carbon density estimates, nothing about leakage, and nothing about whether the comparison region is genuinely comparable to the project area. Choosing the comparison region is in fact where the discretion moves to: a region with high deforestation pressure produces a high baseline and generous crediting, and that choice is now the lever it was previously not worth pulling.

How should a pipeline behave when the jurisdictional product is late?

Refuse to resolve rather than falling back to the prior vintage, and let the delay surface as an operational decision. Falling back looks harmless and is the failure this whole design is built to prevent: it produces a number that appears current, was computed against stale inputs, and carries no marker distinguishing it from a properly resolved one. If the programme permits issuing against the prior vintage, that is a decision to record explicitly on the result, not a default in the resolution code.

What happens to credits already issued when the baseline is restated downward?

That depends on the programme rather than on the engineering, and the answers range from no adjustment for issued vintages, through adjustment of future issuance to compensate, to formal cancellation obligations in the strictest cases. What the pipeline owes regardless is the ability to state both figures and the reason for the difference. Projects that cannot do that end up negotiating from a position where the buyer’s analyst has reconstructed the discrepancy and the project has not.

Is a static baseline still defensible for new projects?

For most forest projects in programmes that have adopted VM0048 or its equivalents, the choice has effectively been made. Static baselines remain in use for project types where no jurisdictional comparison data exists at usable quality, and for shorter crediting periods where drift has less room to accumulate. Where a static baseline is used, expect the validation to scrutinise the reference period selection heavily, since it is now the only defence against the drift a dynamic baseline handles automatically.

How much does issuance actually vary under a dynamic regime?

Enough to matter for financing. Regional deforestation rates commonly move by tens of percent between recalculation cycles in response to enforcement, commodity prices, and weather, and the baseline moves with them while the project’s own performance may be flat. A project modelling revenue should model the baseline as a distribution rather than a line, and should be explicit that a well-performing project can see issuance fall for reasons entirely outside its control.

Should the comparison region be re-selected at each recalculation?

No — re-selecting it is exactly the discretion the regime is meant to remove. Fix the comparison region at validation, document the selection criteria, and recalculate only the rate observed within it. A pipeline that stores the region’s geometry with the same vintage discipline as the rate makes this checkable, and makes an attempt to quietly redraw the region visible as a geometry change rather than as a rate change.

Can the two regimes be compared retrospectively to justify a choice?

Yes, and it is worth doing before committing. Run both against the project’s actual history: the static baseline as it would have been fixed at validation, and the dynamic one as it would have been recalculated. The difference in cumulative issuance over five or ten years is usually large and often surprising in direction. Keeping the regime as a field rather than a branch, as described above, makes this a query over stored results rather than a modelling exercise.