GhgAccounting

GHG Protocol and ISO 14064-1 aligned greenhouse gas accounting for .NET. Scope 1/2/3 calculation, a versioned and source-cited emission factor catalog, unit conversion, AR5/AR6 GWP set selection, and data-quality scoring. No runtime dependencies.


Keywords
carbon, cbam, co2e, emissions, esg, ghg, ghg-protocol, iso14064, sustainability, carbon-accounting, climate, csharp, dotnet, emissions-calculator, emissions-co2, iso-14064, nuget, scope3, scope3-emissions
License
MIT
Install
Install-Package GhgAccounting -Version 0.4.0

Documentation

GhgAccounting

CI License: MIT

Greenhouse gas accounting for .NET, built to the GHG Protocol Corporate Standard and ISO 14064-1. Scope 1/2/3 calculation, a versioned and source-cited emission factor catalog, unit conversion, explicit AR5/AR6 GWP set selection, and data-quality scoring.

No runtime dependencies. The factor catalog is compiled into the assembly at build time, so consuming this library adds exactly one package to your graph and nothing else.

Status: pre-release (0.1.x)

The AR5 and AR6 GWP sets are verified value by value against the IPCC tables they cite. Three factor sets ship, none of them transcribed by hand: 893 UK DESNZ 2026 factors spanning fuels, electricity, transport, waste, water and materials; 27 US EPA eGRID subregion grid factors; and 30 national grid factors derived from Eurostat, with the method and every input recorded in the set. Coverage outside those three publishers is still thin. Every set exposes its own VerificationStatus at run time, and dotnet pack -p:GhgRequireVerifiedCatalog=true refuses to build a package containing unverified data. See Catalog data.

Why this exists

Carbon accounting is not arithmetic on a spreadsheet. Two teams can start from the same metering data and publish totals that differ by 10% or more, entirely legitimately, because they made different disclosed choices: a different IPCC assessment report, a different calorific basis, location-based instead of market-based electricity. Most tooling hides those choices behind a single getCo2e() call.

This library makes each of them an explicit, typed, documented decision — and refuses to guess when it cannot know.

Install

dotnet add package GhgAccounting

Targets netstandard2.0, net8.0 and net10.0. The netstandard target keeps .NET Framework 4.6.1+ consumers in scope, which matters because a large share of the ERP and finance systems that need an inventory still run there.

Quick start

Build an inventory:

using System;
using GhgAccounting;
using GhgAccounting.Calculation;
using GhgAccounting.Units;

var calculator = new EmissionCalculator(GwpSet.Ar6);

Inventory inventory = calculator.CreateInventory()
    .Add(new Quantity(250_000, Unit.KilowattHour),
         "defra-2026/fuels/gaseous-fuels/natural-gas/kwh-gross-cv")               // Scope 1
    .Add(new Quantity(400_000, Unit.KilowattHour),
         "defra-2026/uk-electricity/electricity-generated/electricity-uk/kwh/kwh") // Scope 2
    .Add(new Quantity(250_000, Unit.KilowattHour),
         "defra-2026/wtt-fuels/gaseous-fuels/natural-gas/kwh-gross-cv")            // Scope 3 cat 3
    .Build();

// There is no inventory.Total. A corporate inventory does not have one.
Quantity locationBased = inventory.TotalWith(Scope2Method.LocationBased).ConvertTo(Unit.Tonne);

Quantity biogenic = inventory.BiogenicCarbon;          // disclosed, never inside a total
double? spread    = inventory.UncertaintyPercentFor(Scope2Method.LocationBased);

foreach (Scope3CategoryTotal category in inventory.Scope3ByCategory)
{
    Console.WriteLine($"Category {category.Category}: {category.Co2e}");
}

Asking for Scope2Method.MarketBased here throws Scope2MethodNotReportedException, and should: a national dataset can only publish the grid average, so a market-based figure has to come from the company's own contracts. Returning zero would silently drop the whole of purchased electricity from the total, and nothing downstream would show it.

Turn it into a disclosure:

using GhgAccounting.Reporting;

InventoryReport report = InventoryReport.For(inventory, Scope2Method.LocationBased);

report.ToMarkdown();   // the disclosure, for a human
report.ToJson();       // the same figures, for an API or a database
report.ToCsv();        // one row per inventory line, for an assurer's spreadsheet

The report states the GWP set and Scope 2 method, holds biogenic CO₂ outside the totals, cites each factor set with its share of the total and its verification status, and lists the caveats. The caveats are computed from the inventory, not written by the reporter — an inconvenient one cannot be left out by forgetting to mention it:

- DerivedFactorComponents — Per-gas splits were reconstructed rather than published
  as gas masses in: defra-2026-secr, eurostat-grid-2023
- Scope2NotDualReported — The GHG Protocol Scope 2 Guidance expects both methods;
  only location-based data is present.
- UncertaintyUnavailable — Not every contributing factor publishes an uncertainty,
  so no combined figure is stated.
- RegionMismatch — Factors from more than one region were applied: GB, TR.
  Confirm each was used for activity in its own region.

ToCsv is the audit trail rather than the summary: every line carries its activity figure, the factor applied, the set it came from, its region and data quality, whether its gas split was derived, and what the publisher's own figure would have been. Someone checking a total works down that, not down the disclosure.

All three writers are hand-rolled, for the same reason the catalog is compiled rather than parsed: a reporting library should not oblige its consumers to take a dependency on a serializer. Every number is written with the invariant culture, pinned by tests.

They render nothing regulatory. CBAM and CSRD formats change on their own schedule and stay out of scope; this is the standard's own disclosure content.

Choosing the GWP set:

using GhgAccounting;
using GhgAccounting.Factors;
using GhgAccounting.Units;

// The GWP set is always a caller decision, never a library default.
// One tonne of fugitive fossil methane from a gas network:
var leak = new Quantity(1.0, Unit.Tonne);

Quantity underAr5 = GwpTable.For(GwpSet.Ar5).ToCo2e(leak, GreenhouseGas.MethaneFossil);
Quantity underAr6 = GwpTable.For(GwpSet.Ar6).ToCo2e(leak, GreenhouseGas.MethaneFossil);

// Same activity data, two defensible answers. Which one you publish is a
// disclosure decision, and the standard requires you to state which set you used.
Console.WriteLine($"{underAr5.Value} vs {underAr6.Value} tCO2e");

Every factor carries its provenance, so a report can print the citation next to the number:

EmissionFactor factor = FactorCatalog.Get("defra-2026/fuels/gaseous-fuels/natural-gas/kwh-gross-cv");

Console.WriteLine(factor.Set.Source);        // publisher, document, year
Console.WriteLine(factor.Set.Region);        // where the factor is valid
Console.WriteLine(factor.Basis);             // gross or net calorific value
Console.WriteLine(factor.DataQuality);       // Primary / Secondary / Proxy / Estimated
Console.WriteLine(factor.Set.Verification);  // has anyone checked these numbers?

Conversions that need a substance property are refused rather than guessed:

UnitConverter.Convert(1000, Unit.CubicMetre, Unit.Litre);        // 1_000_000
UnitConverter.Convert(1000, Unit.CubicMetre, Unit.KilowattHour); // throws UnitConversionException

Cubic metres of gas to kilowatt hours depends on the calorific value of the gas actually delivered, which varies by supplier, network and season. That is a factor, not a unit ratio, and silently applying an average is how a gas inventory ends up wrong by an order of magnitude with nothing downstream to flag it.

Design decisions

Factors are stored per gas where a split exists, and as published CO₂e where it does not. A catalog that ships only CO₂e has already baked in an assessment report, so the per-gas form is preferred — it keeps the GWP set a caller decision. But most real datasets publish only aggregates for value-chain categories, and there is no split behind them to recover. Those factors carry a PublishedGwpBasis and refuse to be used under any other set, because re-aggregating an aggregate means inventing the split.

Where a publisher gives the split as CO₂e rather than as gas masses, the masses are divided back out and the factor is marked ComponentsAreDerived. The publisher's own figure is kept alongside on PublishedCo2eKgPerUnit, so a filing that has to reproduce the published total exactly still can. Both numbers appear on the result:

EmissionResult r = calculator.Calculate(activity, factor);
r.Co2e;           // recomputed under the caller's GWP set
r.PublishedCo2e;  // what the publisher would have reported

They differ slightly for DESNZ fuels, because DESNZ applies the non-fossil methane potential of 28 to fossil fuels while AR5 publishes 30 for them. Roughly 0.01% — small, real, and disclosed rather than reconciled away.

A derived factor says so, shows its working, and knows when to stop. Some countries have no published grid factor at all. One can be computed from published statistics — and then the set records the method, every input value, and the endpoint each came from, so a reader can redo the arithmetic without trusting this repository.

But a derivation should not invent its own convention where a standard already has one. National inventories report public electricity and heat together, so the heat has to be allocated out. That split uses the efficiency method the GHG Protocol's own CHP guidance names as its preferred approach: fuel is attributed in proportion to what each output would have consumed produced separately, at that guidance's recommended efficiencies of 35% for electricity and 80% for heat. Electricity therefore carries its thermodynamic penalty instead of being weighed against heat joule for joule.

The importer still measures what the choice is worth, but between published parameter sets rather than against an invented alternative: substituting the IEA's reference efficiencies of 40% and 90% moves every country by under 0.5%. Each factor's note carries its own figure, along with what the energy content method — permitted by the guidance as an alternative — would have given, which for district-heating countries differs by up to 17%. Where the published sets disagree by more than 2% a factor is marked Proxy, and above 5% it is not published.

This replaced an earlier rule of this repository's own devising, which excluded twenty countries. That rule was measuring the wrong thing: how far an absurd alternative would move the answer, rather than how far the defensible ones disagree.

Fossil and biogenic methane are different gases. AR6 gives them 29.8 and 27.0; AR5 gives 30 and 28. A single Methane member would force the library to pick one silently.

Missing data throws instead of returning zero. A gas quietly valued at zero drops out of the total without leaving a trace in the report. GetGwp raises GasNotCoveredException; TryGetGwp exists for callers that want to handle it.

There is no Inventory.Total. A corporate inventory has a location-based total and a market-based total, and which one a company leads with is a disclosure decision. TotalWith(Scope2Method) forces that choice to be made at the call site, and the two figures can never be accidentally summed.

Uncertainty is null unless every contributing factor declares one. Combining only the lines that happen to publish an uncertainty understates the real spread, and nothing in the output would reveal it. Where all lines do declare one, they are combined in quadrature weighted by contribution, following the IPCC error propagation approach for sums.

The catalog is JSON in the repository and C# in the assembly. JSON is what makes a factor change reviewable — a pull request shows the old value, the new value and the citation side by side. Compiled C# is what makes it cheap at run time: static arrays, no parser, no embedded resource, no start-up cost, no dependency. A source generator does the translation, and maps every enum-valued field by name, so a typo in the data is a compiler error rather than a wrong number.

Verification status is part of the public API. A compliance report generator must be able to refuse data nobody has checked. Status is readable at run time, surfaced as a build warning (GHG006), and turned into a hard error (GHG005) when packing for release.

Catalog data

Every shipped set records its publisher, the exact document, the publication year, the redistribution licence, and whether the values have been checked against that source.

Set Source Year Licence Status
Ar5 IPCC AR5 WG1 Ch.8, Appendix 8.A, Table 8.A.1 2013 Factual constants, reproduced with attribution verified
Ar6 IPCC AR6 WG1 Ch.7, Table 7.15 and Supplementary Table 7.SM.7 2021 Factual constants, reproduced with attribution verified
defra-2026 UK DESNZ conversion factors 2026, flat file (revised 31 July 2026) 2026 Open Government Licence v3.0 verified
egrid-2023 US EPA eGRID2023 Rev. 2, subregion annual total output rates 2025 US Government work, public domain verified
eurostat-grid-2023 Derived: Eurostat env_air_gge CRF 1.A.1.a ÷ nrg_bal_c, 30 countries 2023 Eurostat reuse policy, with acknowledgement verified
example-fuels, example-value-chain None — synthetic values authored for this repository MIT, same as the code 🚫 placeholder

The DESNZ and eGRID sets are generated, not transcribed. tools/defra-import/import_defra.py reads the published spreadsheet, pins its SHA-256 so an older download cannot quietly produce a different catalog, and refuses to emit anything it cannot map. Re-running it against next year's publication is how the set gets updated, and because the output is committed, one year diffs cleanly against the next.

Categories are imported or excluded by decision, never by omission: the importer fails if DESNZ publishes a category it has not been told about, so a new one cannot silently go missing. Seven are excluded with their reasons recorded in the set — bioenergy because DESNZ still reports it on an AR4 basis, refrigerants and hotel stays because neither has a single basis, and the SECR kWh series because they convert distance to energy rather than to emissions.

Scope 3 category numbers are assigned where the mapping is unambiguous. Freight, delivery and managed-asset factors ship without one: whether they are upstream or downstream depends on where the reporting company sits in the chain, and no publisher can know that. They surface through the report's UncategorisedScope3 caveat rather than being guessed into a bucket.

verified means every value was checked against the cited table by a named reviewer on a recorded date; the method is written into each file. placeholder means the numbers are invented and exist only so the pipeline has something to compile, and they are never valid for reporting.

Synthetic sets live under data/examples/ rather than data/factors/, so the distinction is visible in the directory tree and not only in a status field. They are excluded from the build entirely when packing for release — invented numbers can never reach verified, so shipping them is not something the gate should have to catch.

What verification actually caught

The AR6 set originally cited Table 7.SM.7 for all its values. It shouldn't have: that table publishes a single methane GWP of 27.9 with no fossil / non-fossil split, because it deliberately excludes the carbon content of the methane so that users can do their own carbon budgeting. The 29.8 and 27.0 values this library ships come from Table 7.15, the headline metrics table.

That distinction also flipped a flag. AR5 Table 8.A.1 states that climate-carbon feedbacks are included for CO2 only, so Ar5.IncludesClimateCarbonFeedback is false. AR6 changed approach and includes carbon cycle responses in its headline metrics, so Ar6.IncludesClimateCarbonFeedback is true. Every number was right; the description of what those numbers were was not.

Because the AR6 set draws on two tables, each value records its own SourceTable — a set-level citation would have misstated where half the numbers came from.

Planned sources

Datasets are only added once their redistribution terms are confirmed to allow it:

Publisher Coverage Terms
UK DESNZ, remaining categories Transport, waste, water, material use Open Government Licence v3
US EPA eGRID, grid loss US transmission and distribution, Scope 3 cat. 3 US public domain
European Environment Agency European grid intensity EEA reuse policy
National inventories Türkiye and other non-EU grids Varies; checked per source

IEA emission factor data is deliberately not on this list. It is a commercially licensed product, and embedding its values in a redistributable package is not something an MIT licence can cover.

Standards coverage

Requirement Standard Where it appears in the API
Emissions classified into Scope 1, 2 and 3 GHG Protocol Corporate Standard, operational boundaries Scope
Scope 2 reported by both location-based and market-based methods GHG Protocol Scope 2 Guidance (2015) Scope2Method, dual factors per grid
Scope 3 split across the fifteen defined categories GHG Protocol Scope 3 Standard (2011) EmissionFactor.Scope3Category
CO₂e aggregated using a disclosed set of 100-year GWPs IPCC AR5 / AR6 via GHG Protocol GwpSet, GwpTable
Biogenic CO₂ reported separately from the scope totals GHG Protocol; ISO 14064-1 EmissionFactor.BiogenicCarbonKg
Data quality distinguished between primary and secondary sources ISO 14064-1 inventory quality management DataQuality
Uncertainty recorded per factor ISO 14064-1 uncertainty assessment EmissionFactor.UncertaintyPercent
Every reported figure traceable to its factor source ISO 14064-1 reporting and verification CatalogSource, FactorSet.Verification

Clause-level citations are deliberately absent. ISO 14064-1:2018 is a paywalled document, and quoting sub-clause numbers from secondary sources is exactly the kind of unverified claim this project refuses to make elsewhere. They will be added once checked against a purchased copy of the standard text.

Deliberately out of scope

Named here so nobody has to read the source to find out:

  • Organizational boundary consolidation. Equity share versus operational control changes which entities are in the inventory at all. That is a corporate structure question, not a calculation, and it belongs above this library.
  • Scope 3 spend-based modelling (EEIO). Input-output tables are large, national, annually revised datasets with their own licensing. A future separate package.
  • Product carbon footprints and LCA. ISO 14067 and ISO 14040/44 model a product life cycle, not a corporate reporting year. Different standard, different data model.
  • Target setting and pathways. SBTi validation, 1.5°C alignment and scenario analysis operate on a completed inventory. This library produces the input.
  • Carbon credits, offsets and removals. Distinct accounting rules; conflating them with gross emissions is a reporting error, so the type system will not allow it.
  • CBAM and CSRD report rendering. Regulatory output formats change on their own schedule and do not belong in a calculation engine.
  • Currency, spend and financial data. No monetary units, by design.
  • Lifecycle grid intensity datasets. Several widely used open datasets publish a national "CO₂ intensity of electricity" that is not a Scope 2 location-based factor. Ember's, for instance, attributes 12.8 gCO₂/kWh to wind, 47.6 to solar and 4.9 to nuclear — technologies with no combustion at all — which only makes sense as a lifecycle figure, and it counts biogenic CO₂ from bioenergy inside the intensity where the GHG Protocol requires it outside the scope totals. Against DESNZ for the same grid and year the gap is 66%. Convenient global coverage is not worth shipping a number under a label it does not fit, so grid factors come from sources that publish on the right basis.

Implementation status

Area State
GWP sets (AR5 / AR6), explicit selection, CO₂e conversion
Unit and dimension layer, cross-dimension refusal
Factor catalog model, provenance, verification gate
Compile-time catalog generator with build diagnostics
Scope 1/2/3 calculation engine and inventory aggregation
Scope 2 dual-reporting result type
Biogenic carbon reported outside the scope totals
Uncertainty propagation and data-quality breakdown
Disclosure report with computed caveats and source citations
Markdown, JSON and CSV report output, no serializer dependency
AR5 and AR6 GWP sets verified against the IPCC tables
UK DESNZ 2026: 893 factors, machine-generated, categories excluded by decision
US EPA eGRID 2023: 27 subregion grid factors, published per gas
30 national grid factors derived from Eurostat, by the GHG Protocol's CHP method

Repository layout

data/
  gwp/            GWP sets, one file per IPCC assessment report
  factors/        Emission factor sets, one file per publisher-year
  examples/       Synthetic sets for tests. Excluded from release builds.
  schema/         JSON Schema for both catalog shapes
src/
  GhgAccounting/            The shipping library. Zero PackageReference entries.
  GhgAccounting.Generators/ Build-time source generator. Never shipped.
tests/
  GhgAccounting.Tests/      Runs against net8.0 and net10.0
tools/
  defra-import/                Turns the published DESNZ spreadsheet into catalog JSON
  egrid-import/                Turns the published EPA eGRID workbook into catalog JSON
  eurostat-import/             Derives a national grid factor from Eurostat series

Build and test

Requires the .NET 10 SDK.

dotnet build GhgAccounting.slnx
dotnet test  GhgAccounting.slnx

The net8.0 test leg rolls forward onto the .NET 10 runtime locally, so a fresh clone is green with a single SDK installed. CI installs the real 8.0 runtime so that leg executes on .NET 8 for real.

To check the release gate:

dotnet pack src/GhgAccounting/GhgAccounting.csproj -c Release -p:GhgRequireVerifiedCatalog=true

This now succeeds: both GWP sets are verified, and the synthetic factor sets are dropped from the build rather than shipped. Adding an unverified set to data/gwp/ or data/factors/ makes it fail again.

Contributing catalog data

  1. Add or edit a JSON file under data/, matching the schema in data/schema/.
  2. Record the exact source: publisher, document and table, publication year, URL, redistribution licence. A set with no clear licence will not be merged.
  3. Leave verification.status at needs-review until every value has been checked against the primary source, then set it to verified with verifiedBy and verifiedOn filled in.
  4. Never change a factor's value under its existing id. Publish a new id, so a restated inventory is distinguishable from an unchanged one.

Licence

MIT for the code — see LICENSE.

Emission factor and GWP data is not covered by that licence. It is reproduced from third-party publications and stays subject to its publishers' terms, which NOTICE sets out per set. The DESNZ factors contain public sector information licensed under the Open Government Licence v3.0. Each catalog file also records its own source and licence in its source block, and those records compile into the library, so a consumer can read the provenance of any figure at run time rather than taking it on trust.