Allocating Scope 3 Emissions Across Shared Land Parcels
A single hectare of converted forest usually supplies more than one buyer, and the emission it carries has to be divided among them. That division is not a measurement — it is a rule, applied to a measurement, and the same parcel produces materially different tonnages for the same buyer depending on which rule is chosen. This guide implements the allocation rigorously, within GHG Protocol Scope 3 spatial mapping in the MRV architecture and carbon accounting fundamentals stack, and it assumes the fan-out controls from preventing Scope 3 double-counting in spatial joins are already in place.
The distinction worth holding onto is that double counting and misallocation are different failures with different fixes. Double counting is an arithmetic error — shares that sum above one — and it is caught by an assertion. Misallocation is a methodological error — shares that sum to exactly one under the wrong rule — and no assertion catches it, because the arithmetic is impeccable. The defence against the second is carrying the rule as data so the figure can be recomputed under a different one.
Root Cause Analysis
Three structural features of supply-chain land data make allocation harder than a proportional split.
The denominator is rarely observed. Allocating by purchased mass requires knowing the parcel’s total output, not just your share of it, and a buyer typically knows only its own purchases. Using your own volume over an estimated total introduces an error in the denominator that scales the whole allocation, and — worse — it is invisible in your own books because your shares still sum to one across the buyers you know about. The parcel’s other buyers, whom you cannot see, are precisely what makes the denominator uncertain.
Relationships are intervals, not facts. A supplier relationship that begins in March and a conversion event that occurred in January are not connected in the way a naive join implies, and a relationship ending mid-year leaves a partial-period claim that must be apportioned in time as well as across buyers. Systems that treat the supplier link as a current-state attribute rather than a dated interval silently attribute a full year of a parcel’s emissions to a relationship that lasted four months.
Multi-tier chains compound the ambiguity. Where a parcel supplies a processor that supplies several manufacturers, the allocation happens twice, and the two stages may use different bases — physical mass at the farm gate, economic value at the processor. Composing two allocations is legitimate; composing them without recording both is what makes the resulting figure unreconcilable against either supplier’s own disclosure.
The failure mode common to all three is not an obviously wrong number. It is a number that is internally consistent, sums correctly, and cannot be reproduced by anyone else — including you, six months later.
Diagnostic Pipeline / Pre-Flight Validation
Before allocating, verify that the inputs can support an allocation at all. Three checks matter: that a basis is available for every parcel-buyer pair, that the denominator is observed rather than inferred, and that relationship intervals overlap the emission event.
from dataclasses import dataclass
from datetime import date
import structlog
log = structlog.get_logger()
VALID_BASES = {"area", "mass", "value", "commitment"}
@dataclass(frozen=True)
class SupplyLink:
"""A dated relationship, not a current-state attribute.
Modelling this as an interval is what makes partial-period apportionment
possible; a boolean 'is_supplier' flag cannot express a link that started
after the conversion it is being asked to carry.
"""
parcel_id: str
buyer_id: str
valid_from: date
valid_to: date | None
basis_value: float # the buyer's share numerator, in the basis unit
basis: str
def overlap_days(self, start: date, end: date) -> int:
lo = max(self.valid_from, start)
hi = min(self.valid_to or end, end)
return max(0, (hi - lo).days)
@dataclass(frozen=True)
class AllocationReadiness:
parcel_id: str
basis: str
denominator_observed: bool
links: int
covered_fraction: float
ready: bool
reason: str | None
def assess(parcel_id: str, links: list[SupplyLink], declared_total: float | None,
period_start: date, period_end: date) -> AllocationReadiness:
"""Can this parcel be allocated honestly, and on what basis?"""
if not links:
return AllocationReadiness(parcel_id, "none", False, 0, 0.0, False, "no_links")
bases = {link.basis for link in links}
if len(bases) > 1:
# Mixing bases within one parcel produces shares that sum to one and
# mean nothing — the arithmetic hides the incoherence.
log.error("alloc.mixed_basis", parcel_id=parcel_id, bases=sorted(bases))
return AllocationReadiness(parcel_id, "mixed", False, len(links), 0.0,
False, "mixed_basis")
basis = bases.pop()
if basis not in VALID_BASES:
return AllocationReadiness(parcel_id, basis, False, len(links), 0.0,
False, "unknown_basis")
known = sum(link.basis_value for link in links)
denominator_observed = declared_total is not None
denominator = declared_total if denominator_observed else known
covered = known / denominator if denominator else 0.0
reason = None
if not denominator_observed:
# Our own shares will sum to one regardless — which is exactly why an
# unobserved denominator is dangerous rather than merely uncertain.
log.warning("alloc.denominator_inferred", parcel_id=parcel_id,
known=known,
note="shares will sum to 1 across known buyers and may overstate each")
elif covered > 1.0001:
reason = "shares_exceed_declared_total"
log.error("alloc.over_subscribed", parcel_id=parcel_id,
known=known, declared=declared_total)
period_days = (period_end - period_start).days
if not any(link.overlap_days(period_start, period_end) for link in links):
reason = reason or "no_link_overlaps_period"
return AllocationReadiness(parcel_id, basis, denominator_observed, len(links),
round(covered, 4), reason is None, reason)
The denominator_inferred warning is the one to act on. Shares computed over known buyers always sum to one, so an inferred denominator produces a self-consistent allocation that systematically overstates every known buyer’s share by the fraction attributable to buyers you cannot see. It is not detectable from your own data, which is why it must be recorded as a property of the figure rather than discovered later.
Deterministic Transformation Logic
The allocator applies the basis, apportions in time where relationships are partial, and asserts conservation. It records the basis, the denominator source, and the period fraction on every output row, so the figure can be recomputed under a different rule without re-deriving the measurement.
from dataclasses import dataclass, asdict
from datetime import date
import structlog
log = structlog.get_logger()
TOLERANCE = 1e-6
@dataclass(frozen=True)
class Allocation:
parcel_id: str
buyer_id: str
period_start: str
period_end: str
basis: str
denominator_source: str # observed | inferred
share: float
period_fraction: float
parcel_tco2e: float
allocated_tco2e: float
unallocated_tco2e: float # the share belonging to buyers we cannot see
def allocate(parcel_id: str, parcel_tco2e: float, links: list[SupplyLink],
declared_total: float | None, period_start: date,
period_end: date) -> list[Allocation]:
"""Allocate one parcel's emissions across its buyers for one period.
Two independent factors multiply: the buyer's share of the basis, and the
fraction of the period its relationship actually covered. Collapsing them
into one number loses the ability to explain either.
"""
readiness = assess(parcel_id, links, declared_total, period_start, period_end)
if not readiness.ready:
raise ValueError(f"{parcel_id}: not allocatable ({readiness.reason})")
known = sum(link.basis_value for link in links)
denominator = declared_total if declared_total is not None else known
source = "observed" if declared_total is not None else "inferred"
period_days = max((period_end - period_start).days, 1)
rows: list[Allocation] = []
for link in links:
share = link.basis_value / denominator
fraction = link.overlap_days(period_start, period_end) / period_days
if fraction == 0:
continue # relationship did not cover this period
rows.append(Allocation(
parcel_id=parcel_id, buyer_id=link.buyer_id,
period_start=period_start.isoformat(), period_end=period_end.isoformat(),
basis=readiness.basis, denominator_source=source,
share=round(share, 6), period_fraction=round(fraction, 6),
parcel_tco2e=round(parcel_tco2e, 3),
allocated_tco2e=round(parcel_tco2e * share * fraction, 3),
unallocated_tco2e=0.0,
))
allocated = sum(r.allocated_tco2e for r in rows)
unallocated = parcel_tco2e - allocated
# Conservation: allocated plus unallocated must equal the parcel's emission.
# The unallocated remainder is a real quantity — the share belonging to
# buyers outside our disclosure — not a rounding artefact to be absorbed.
if abs(allocated + unallocated - parcel_tco2e) > TOLERANCE * max(parcel_tco2e, 1):
raise RuntimeError(f"{parcel_id}: allocation not conserved")
rows = [Allocation(**{**asdict(r), "unallocated_tco2e": round(unallocated, 3)})
for r in rows]
log.info("alloc.parcel", parcel_id=parcel_id, buyers=len(rows),
basis=readiness.basis, denominator_source=source,
allocated=round(allocated, 2), unallocated=round(unallocated, 2),
coverage=round(allocated / parcel_tco2e, 4) if parcel_tco2e else 0.0)
return rows
def assert_portfolio_conservation(rows: list[Allocation],
parcel_totals: dict[str, float]) -> dict:
"""Across the portfolio, every parcel's allocations plus its unallocated
remainder must reconstruct its emission exactly once."""
by_parcel: dict[str, float] = {}
for row in rows:
by_parcel.setdefault(row.parcel_id, 0.0)
by_parcel[row.parcel_id] += row.allocated_tco2e
problems = {}
for parcel_id, total in parcel_totals.items():
allocated = by_parcel.get(parcel_id, 0.0)
if allocated - total > TOLERANCE * max(total, 1):
problems[parcel_id] = {"allocated": allocated, "parcel_total": total}
if problems:
log.error("alloc.over_allocated", parcels=len(problems),
example=next(iter(problems.items())))
raise RuntimeError(f"{len(problems)} parcel(s) allocated above their emission")
result = {"parcels": len(parcel_totals), "rows": len(rows),
"allocated_total": round(sum(by_parcel.values()), 2),
"parcel_total": round(sum(parcel_totals.values()), 2)}
log.info("alloc.portfolio_conserved", **result)
return result
The unallocated_tco2e column is the design decision that matters. Most implementations either omit it, implying the known buyers account for the whole parcel, or absorb it proportionally, which silently applies the inferred-denominator error. Carrying it explicitly makes the disclosure gap a number rather than an assumption, and it is the figure a reader most wants when assessing how complete a supply-chain inventory really is.
Compliance Gating & Audit Trail Generation
Four fields turn an allocation into evidence, and all four are cheap to record at the time and impossible to reconstruct later.
The basis and its source. The methodology chooses the basis, so the record should name both the basis applied and the methodology clause requiring it. Where a methodology permits several, the choice and its justification belong in the record because a reviewer will otherwise assume the most favourable one was selected.
The denominator source. Observed versus inferred is the single most informative quality signal in a supply-chain allocation, and it changes how much weight a reader should give the figure. An inventory whose parcels are mostly inferred-denominator is making a much weaker claim than one whose parcels are observed, and the two look identical without the field.
The period fraction. A relationship covering four months of a year carries a third of the year’s emission, and recording the fraction separately from the share is what lets a verifier check the temporal apportionment independently of the allocation basis.
The unallocated remainder, aggregated to the portfolio. This is the disclosure-completeness figure, and under a reasonable-assurance engagement it is the number that determines whether the inventory can be substantiated at all. Route these through the schema contract in the MRV data schema reference and the lineage chain in MRV data lineage and provenance tracking, so a figure can be traced to the specific parcel-buyer rows that produced it.
Production Integration
- Model supplier relationships as dated intervals from the start, never as current-state flags, and store the basis value per relationship rather than per buyer.
- Assess allocatability before allocating, rejecting mixed bases within a parcel and recording where the denominator had to be inferred.
- Apply share and period fraction as separate factors, and store both, so either can be checked without recomputing the other.
- Assert conservation per parcel and across the portfolio, treating over-allocation as a hard failure rather than a warning.
- Carry the unallocated remainder as a column and aggregate it into the completeness statement.
- Recompute under an alternative basis at least once per period as a sensitivity check, and keep the comparison — it is the fastest way to show a reviewer that the chosen basis was not selected for its result.
For scale, allocation is a small join relative to the geospatial work upstream: a portfolio of a hundred thousand parcel-buyer pairs allocates in seconds. The engineering effort belongs in the interval modelling and the conservation assertions, both of which are cheap and both of which are what a verifier actually examines.
Frequently Asked Questions
Which allocation basis should be used?
Whichever the applicable methodology specifies — and where it permits a choice, the one that best reflects the physical relationship between the buyer and the land. Mass is usually the most defensible for agricultural commodities because it tracks what actually left the parcel; economic value is prescribed by some frameworks and is sensitive to price volatility that has nothing to do with emissions; area is appropriate only where the buyer genuinely has a claim on specific hectares. Whatever the choice, record it and be prepared to show the figure under an alternative.
What should happen when a parcel’s total output is unknown?
Allocate on the inferred denominator, mark the row as inferred, and carry the resulting overstatement risk into the disclosure rather than into the number. The alternative — refusing to allocate — removes the parcel from the inventory entirely, which understates rather than overstates and is not obviously better. What is not acceptable is treating an inferred denominator as observed, because the resulting figure is systematically high in a way nothing downstream can detect.
How are multi-tier supply chains handled?
By composing allocations and recording each stage separately. A parcel allocated to a processor by mass, then from processor to manufacturers by value, produces a manufacturer-level figure that depends on both bases — so both must appear in the lineage. Collapsing the two into a single effective share loses the ability to reconcile against either intermediary’s own disclosure, which is exactly the reconciliation a verifier will attempt.
Does a relationship starting after a conversion event carry that event’s emissions?
Under most methodologies, yes, for as long as the amortisation window runs — the emission is attached to the land and to sourcing from it, not to who was buying on the day of conversion. That is why relationships must be intervals: the correct treatment is to apportion the amortised annual figure over the period the relationship covered, which requires knowing both the event date and the relationship dates. A buyer joining in year three of a twenty-year amortisation carries years three onward, not the whole event and not nothing.
How should shares be handled when a buyer’s own records disagree?
Investigate before adjusting, and expect the disagreement to be about the denominator or the period rather than the arithmetic. The most common causes are a different view of the parcel’s total output, a relationship interval recorded differently on the two sides, and one party amortising while the other assigns the whole event to the conversion year. Publishing your basis, denominator source, and period fractions alongside the figure turns that reconciliation from an argument into a comparison of stated assumptions.
Is it double counting if two buyers both report emissions from the same parcel?
Not if their shares sum to at most one — that is correct behaviour, and it is the same structure as two reporting entities both counting a facility under different consolidation approaches. Double counting is shares summing above one across all buyers, which the portfolio conservation assertion catches. What the assertion cannot catch is two buyers each allocating on an inferred denominator, since each sees shares summing to one within its own view; that is the case where an industry-level view, or a supplier publishing its total output, is the only detection available.
How much does the choice of basis actually move a corporate footprint?
Enough to be material for commodity-exposed companies and negligible for most others. Where land-use change dominates a footprint — food, agricultural inputs, forestry products — a switch between mass and value bases can move a category’s total by tens of percent, because the two weight buyers very differently when product mix and price diverge. Running the sensitivity once tells you which regime you are in, and it is worth knowing before a methodology revision forces the change.
Related guides
- GHG Protocol Scope 3 Spatial Mapping — the parent topic and the locatability limits on Scope 3 categories.
- Preventing Scope 3 Double-Counting in Spatial Joins — the fan-out controls this allocation assumes.
- Step-by-Step GHG Protocol Scope 3 Geospatial Calculation — where the parcel emission being allocated comes from.
- Mapping CSRD ESRS E1 Disclosures to Spatial MRV Outputs — where the unallocated remainder becomes a completeness statement.