mixle.analysis.emissions module

Scope 1/2/3 GHG (carbon) accounting for an operation’s production activity, plus the L6 climate objective + risk terms folded into J’s objective and H’s optimizer (work-plan Sec.7-L).

Maps a production activity schedule – direct fuel combustion and blasting, purchased-grid electricity draw, and upstream reagents / downstream haulage – onto CO2e emissions via GHG-Protocol-style EmissionFactors.

  • emissions_footprint() – Scope 1 (direct combustion/blasting) + Scope 2 (purchased electricity) + Scope 3 (upstream reagents / downstream transport) totals, with an optional Monte-Carlo 90% credible interval when per-factor uncertainties (EmissionFactors.sigma) are supplied, and a content-addressed activity_content_hash in the returned Footprint’s provenance so every number traces back to the exact activity schedule that produced it.

  • transition_risk() – prices a Footprint against a set of carbon-price/policy scenario paths and subtracts the resulting per-scenario carbon cost from a J2 npv_samples distribution, returning an IC-1 DerivedQuantity that carries the carbon-adjusted NPV samples (uncertainty-aware, not just a point estimate) plus a mean-value scenario ranking.

  • climate_terms() – folds a Footprint and an optional water budget into the two numbers J6’s objective (analysis/valuation.py) and H4’s optimizer (stochastic_opt.py) need: a priced carbon cost, and a hard water-feasibility flag (an explicit None/unknown state, not a permissive default, when water evidence is missing or non-finite), plus a shortfall time-fraction downside term – a single-trajectory duration statistic, not a probability.

Emission factors are always supplied by the caller (or an upstream knowledge store) – this module vendors no lifecycle-inventory database; see the work-plan Non-goals for L1.

Repo-boundary note (L6): L2’s WaterBudget (a dataclass in the separate mixle-pde repo) is not a dependency of mixle core and is never imported here; water duck-types against the local WaterBudget structural stand-in below (any object exposing a .shortfall_m3 float and, optionally, .storage/.provenance/.demand_m3 satisfies it), so this module has no runtime dependency on mixle-pde. The stand-in is a real name in THIS module’s namespace – unlike the bare forward-reference string "WaterBudget | None" this replaced, which named an import that never happens and left climate_terms’s annotation unresolvable to typing.get_type_hints().

class EmissionFactors(scope1, scope2, scope3, sigma=None)[source]

Bases: object

Per-activity-key CO2e emission factors, one dict per GHG-Protocol scope.

scope1/scope2/scope3 map an activity key (e.g. "diesel_L", "grid_kWh", "explosives_kg", "transport_t_km") to a CO2e factor expressed per unit of that activity (e.g. kg CO2e per litre of diesel). A key absent from a scope’s dict simply does not contribute to that scope. sigma optionally gives the standard deviation of each factor (not the activity quantity itself), keyed the same way across all three scopes, for Monte-Carlo uncertainty propagation in emissions_footprint(); a key with no entry in sigma is treated as exactly known (std 0).

Parameters:
class Footprint(scope1, scope2, scope3, total, ci=None, provenance=<factory>)[source]

Bases: object

A Scope 1/2/3 CO2e footprint with an optional 90% credible interval and full provenance.

scope1/scope2/scope3/total are in the same physical CO2e units as the emission factors (typically kg CO2e / tCO2e). ci is the (lo, hi) 90% Monte-Carlo interval on total when factor uncertainties were propagated, else None (the default – a caller that only needs climate_terms()’ point costs can construct one without ci/provenance). provenance identifies both inputs to the activity x factors product, plus how the interval was produced: factor_source; the 64-hex activity_content_hash and factor_content_hash (sha256 of the canonical activity / factor-model encodings, the same hashing convention IC-2 uses for field artifacts); factor_keys (the union of activity keys the factor model prices, for a readable diff when the two hashes disagree); the scopes actually included in total; and draws / uncertainty_propagated describing the Monte-Carlo configuration behind ci. Hashing the activity alone was not enough to distinguish two runs: the same schedule costed against a supplier-specific factor set and against a national average is a different footprint with different provenance.

The record is frozen: a reported footprint is evidence, and rebinding total or scope2 after construction would leave the numbers, the interval and the provenance describing three different computations. ci is normalized to a (lo, hi) tuple and provenance to a dict this record owns, so a later edit to the caller’s own dict cannot retroactively rewrite what a footprint claims it was computed from.

That copy is DEEP (MXR-080-1900). dict(provenance) detaches only the top level, so a nested container – provenance["scopes"] as a list, a nested factor-model dict – stayed aliased to the caller’s own object and could be rewritten after the footprint was reported. Deliberately NOT checked here: that total equals scope1 + scope2 + scope3. The producer always makes it so, but the record is documented as constructible directly for callers that only need climate_terms()’ point costs, and the test-suite already builds partially-specified footprints; a sum invariant would refuse states the library legitimately accepts.

Parameters:
emissions_footprint(activity, factors, *, scopes=(1, 2, 3), n=0, rng=None)[source]

Scope 1/2/3 CO2e footprint of a production activity schedule.

Each activity key (e.g. diesel_L, grid_kWh, explosives_kg, transport_t_km) is priced by the corresponding factor in whichever of factors.scope1/scope2/scope3 includes that key: Scope 1 is direct combustion/blasting, Scope 2 is purchased electricity, Scope 3 is upstream reagents plus downstream transport. scopes selects which of the three scopes are actually included in the returned footprint (a scope not requested reports 0.0 and does not contribute to total – e.g. scopes=(1, 2) for a Scope-1/2-only disclosure).

If n > 0 and factors.sigma is supplied, each priced factor is treated as Normal(mean=factor, std=sigma.get(key, 0.0)) and resampled n times (factors with no sigma entry stay fixed); the resulting distribution of total yields a 90% credible interval in ci. Without both a positive n and a non-empty sigma, ci is None – the point total is still returned, just without an uncertainty band.

provenance always fingerprints both factors of the activity x factors product – activity_content_hash AND factor_content_hash (64-hex sha256 over the canonical encodings, IC-2’s hashing convention) – alongside factor_keys, scopes, draws and uncertainty_propagated. A downstream carbon-cost or transition-risk term (L3/L6) can therefore be traced back to the exact activity schedule and the exact factor table it was priced with; recording only the activity meant a footprint restated against a revised or supplier-specific factor set was indistinguishable from the original by its provenance alone.

scopes must not contain duplicate entries (each GHG-Protocol scope may appear at most once) – a duplicate raises rather than being silently summed once on the point-total path and once per occurrence on the Monte-Carlo path, which previously left the point estimate and the credible interval describing two different footprints. activity values must be finite and nonnegative; factors (scope1/scope2/scope3) values must be finite; factors.sigma values, when supplied, must be finite and nonnegative – a negative sigma is rejected rather than silently treated as an exactly-known (zero-uncertainty) factor. n must be an exact non-Boolean nonnegative integer; zero explicitly requests the point estimate without an interval. All of this is validated before either the point-total or the Monte-Carlo path runs.

Parameters:
Return type:

Footprint

class TransitionRiskResult(samples, prior_dominated, scenario_mean, ranking, carbon_cost, provenance)[source]

Bases: object

The carbon-adjusted NPV distribution across carbon-price/policy scenarios (L3).

Satisfies the frozen mixle.reason.posterior_protocol.DerivedQuantity structural protocol – samples, prior_dominated, credible_interval – so a carbon-adjusted value can flow anywhere a DerivedQuantity is expected (J5 tail risk, J2 re-valuation). samples is shaped (n, k): the n baseline npv_samples draws, each re-priced under every one of the k carbon-price scenarios (one column per scenario) – the re-ranking below stays uncertainty-aware rather than collapsing straight to a point estimate. prior_dominated is always False: there is no prior/regulariser here, the distribution’s width is set entirely by npv_samples.

Beyond the protocol, scenario_mean (per-scenario mean carbon-adjusted NPV), ranking (scenario indices sorted best -> worst by scenario_mean), and carbon_cost (the priced-and-discounted carbon cost subtracted from each scenario) carry the scenario-level comparison transition_risk() exists to produce.

Construction validates samples: non-empty and finite (no NaN/Inf) – defense-in-depth so invalid state can never flow downstream to a caller even if some upstream pushforward (here, transition_risk()) fails to validate its own inputs, the same guard applied to carcinogenic_risk.RiskQuantity and the _SampleDerivedQuantity carriers elsewhere in mixle.analysis.

The record is frozen and owns its arrays (validated copies, write-locked), so ranking and scenario_mean cannot drift out of agreement with the samples they were derived from: an unfrozen record let a caller reorder the ranking or overwrite a scenario mean while every summary method kept reporting off the original draws.

Freezing only stopped the drift AFTER construction, though. Construction itself accepted any finite scenario_mean and any permutation ranking, so a record could be born already contradicting its own draws – scenario_mean reporting one ordering of the scenarios while credible_interval(), computed off samples, reported another (MXR-080-1900). Two agreement invariants now close that, both holding exactly for what transition_risk() produces:

  • scenario_mean must equal the per-scenario (column) mean of samples, to a tolerance scaled by the magnitude of the draws – summation order, not disagreement, is all that is allowed to separate them.

  • scenario_mean[ranking] must be non-increasing, i.e. the ranking really does run best -> worst. Ties may be broken in any order, since np.argsort gives no stable tie order and demanding a particular one would refuse rankings the producer itself emits.

Additionally, when provenance carries the module’s own "carbon_cost" key (the doubled representation transition_risk() writes), it must agree with the carbon_cost field – the two used to be free to disagree about the very cost the summaries were priced from.

Deliberately NOT checked: that samples[:, j] + carbon_cost[j] recovers one common baseline NPV across scenarios. It is true of transition_risk()’s output, but the record is also constructed directly with independent samples/carbon_cost in this package’s own tests, so requiring it would reject states the library legitimately produces.

Parameters:
credible_interval(level)[source]

Per-scenario central level interval of the carbon-adjusted NPV, each shape (k,).

level goes through the shared mixle.analysis interval contract (MXR-080-1580).

Parameters:

level (float)

Return type:

tuple[ndarray, ndarray]

transition_risk(footprint, carbon_price_paths, *, npv_samples, discount=None)[source]

Carbon-adjusted NPV distribution + scenario ranking under a set of carbon-price paths (L3).

For each of the k scenarios in carbon_price_paths (a (k,) flat price or a (k, t) per-period path – see _coerce_price_paths()), the priced carbon cost is footprint.total * sum_t(price[t] * discount[t]) (discount defaults to all-ones, i.e. no discounting, when omitted – pass period discount factors, e.g. 1 / (1 + r) ** t, to match a J2 monte_carlo_npv DCF). That per-scenario carbon cost is subtracted from every draw of the baseline npv_samples (a J2 NPVDistribution.samples-shaped (n,) array), yielding an (n, k) carbon-adjusted value distribution: one re-priced NPV distribution per scenario, still carrying the original valuation uncertainty.

Scenarios are ranked by mean carbon-adjusted NPV (scenario_mean, descending: best scenario first) so a high-carbon-price/policy scenario reliably re-ranks below a low-price one, with the gap between any two scenarios scaling linearly in footprint.total – a bigger footprint pays proportionally more carbon cost under the same price paths. The returned TransitionRiskResult satisfies IC-1’s DerivedQuantity protocol so the re-ranking is always inspectable with a credible interval, not just a point estimate; it feeds J5 tail risk and J2 re-valuation directly.

Validated before any ranking happens: carbon_price_paths must have at least one scenario and at least one period, with every price finite and nonnegative; discount, when supplied, must be finite and nonnegative; footprint.total must be finite; and npv_samples must be a non-empty, finite, one-dimensional (n,) array (a multi-dimensional array is rejected, not silently flattened). Letting any of these through used to allow NaN scenario means and a meaningless ranking to construct successfully, failing only later when a caller requested a credible interval.

Parameters:
Return type:

DerivedQuantity

class WaterBudget(*args, **kwargs)[source]

Bases: Protocol

Structural stand-in for L2’s WaterBudget (mixle-pde) – see the module’s repo-boundary note.

Never constructed here; a real WaterBudget (or anything shaped like one) is passed in and duck-typed via getattr(water, ..., default) throughout this module, so a caller-supplied object satisfying only shortfall_m3 (the one required field) still works – the optional fields default to absent, matching the “or None if absent” fallbacks already documented on _water_demand_m3() and _shortfall_time_fraction().

climate_terms(footprint, water, *, carbon_price, water_limit_m3=None)[source]

Fold an emissions footprint and a water budget into J6’s carbon cost + H4’s water constraint.

Returns {"carbon_cost": float, "water_feasible": bool | None, "water_binding": bool | None, "shortfall_time_fraction": float | None}:

  • carbon_cost = footprint.total * carbon_price – the priced carbon term J6’s objective (analysis/valuation.py) subtracts alongside capex_opex’s cost roll-up. Always a definite float; it does not depend on water evidence.

  • water_binding is set when the L2 WaterBudget.shortfall_m3 > 0 (the routed water balance already ran dry at some point over the plan) OR when a caller-supplied water_limit_m3 is given and the budget’s demand exceeds it; water_feasible is its negation – a hard constraint H4 (stochastic_opt.py) folds into a per-option cost (derating or dropping the option) rather than into the mean-grade objective. Either can come back None (unknown) instead of a definite bool – see below.

  • shortfall_time_fraction is the fraction of the water budget’s own per-step storage trajectory sitting at a binding zero (_shortfall_time_fraction()) – a deterministic duration/occupancy statistic over ONE trajectory, reported independently of water_limit_m3 even for options that are feasible on average but fragile. This is NOT a probability: it carries no uncertainty information. A genuine cross-realization probability requires an explicit ensemble of trajectories (_shortfall_probability_over_ensemble()), which is not available from a single water object.

Missing or non-finite water evidence is an explicit UNKNOWN state, never a permissive pass:

  • When water is None (no water budget available for this option – e.g. an inland option with no catchment exposure), water_feasible, water_binding, and shortfall_time_fraction are all None: climate risk for that option is carbon-only, but the water terms are reported as “not evaluated”, not silently “known feasible”.

  • When shortfall_m3, the water-limit demand, or water_limit_m3 itself is non-finite (NaN/inf) or negative, that check contributes None (unknown) rather than silently comparing and passing as non-binding. All three are physical volumes in m3 and cannot be negative under the documented contract, so a negative one is malformed evidence rather than evidence of comfortable headroom (or, for a negative demand/limit, of a violation). The two binding checks combine via three-valued (Kleene) OR: a confirmed binding signal from either check always wins, an unknown never gets rounded down to “feasible”.

  • When a water object is supplied but carries no readable shortfall – the attribute is absent, None, a Boolean, or non-numeric – the shortfall check likewise contributes None. This is the case the promise above used to skip (MXR-080-1900): the water is None branch abstained correctly, but a present-but-empty water object took getattr(..., shortfall_m3, 0.0)’s numeric default and reported water_feasible=True, a definite all-clear on a hard constraint from an object holding no water evidence at all. shortfall_time_fraction reports None in the same case rather than a confident 0.0 (“never short over the whole trajectory”) – unless a real storage trajectory is present, which is genuine evidence and is still used.

Parameters:
  • footprint (Footprint)

  • water (WaterBudget | None)

  • carbon_price (float)

  • water_limit_m3 (float | None)

Return type:

dict