Emissions calculation and submission
Turns a report’s stored answers into Scope 1, 2 and 3 totals in tonnes of CO2 equivalent, then locks the report on submission. This is the number the whole product exists to produce, and the one a company may publish.
Why it exists
Section titled “Why it exists”Emissions are activity data multiplied by a conversion factor. The factors are published annually (Defra / UK Government GHG conversion factors) and differ per reporting year, so the calculation must always use the factor set for the report’s own year — recalculating a 2024 report must not silently apply 2026 factors.
Some companies already have externally audited totals and do not want to re-enter activity data. “Direct reporting” lets them supply scope totals directly, and those blend additively with anything they did calculate.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/plugins/carboncalc/includes/Rest.php:927 | POST /carboncalculator/v1/reports/{id}/calculate |
wp-content/plugins/carboncalc/includes/Rest.php:1188 | POST /carboncalculator/v1/reports/{id}/submit |
wp-content/plugins/carboncalc/includes/Rest.php:632 | POST .../completion — stores completion_pct |
wp-content/plugins/carboncalc/includes/Rest.php:2087 | get_direct_reporting_result_from_meta() |
wp-content/plugins/carboncalc/includes/DirectReportingCatalog.php | The Scope 3 category → metric-key labels for direct entry |
wp-content/plugins/carboncalc/includes/TurnoverValidation.php | Sanity-checks turnover against emissions |
How it works
Section titled “How it works”Calculate
Section titled “Calculate”report_calculate() (Rest.php:927):
- Resolve company from the LMS session; 401 if none.
resolve_report_request_bundle()— accepts a UUID or a numeric id, and resolves owner vs grantee access.report_can_edit_row()— must beownermode, statusdraftorsubmitted, and the user must hold a Carbon edit role. 403 otherwise.- 409
lockedif the status is anything other than draft/submitted. - Load all
cc_report_answerrows with a non-null value. No answers → upsert a zero result and return early (Rest.php:979). - Load
cc_metric_factorrows forreporting_year_idrestricted to the metric keys actually answered. - Load
cc_metricrows for step, scope and category. - For each answer:
- Skip direct-reporting keys (blended separately).
- Cast value to
float; empty is treated as 0. - No factor for that key → push to
missing_factors, skip. - No metric definition → push to
missing_metrics, skip. kg = value × factor;tco2e = kg / 1000. Add to the scope bucket.
- Sum per-step totals for the UI.
- Blend direct reporting additively, then
total = s1 + s2 + s3. - Upsert
cc_report_result(unique onreport_id), values passed as strings sowpdbdoes not reformat the decimals (Rest.php:1149). - Set
cc_report.emissions_basis = 'calculated'.
Response includes scope1/2/3, total, missing_factors,
missing_metrics, step_totals, direct_reporting_added, and a per-metric
items array with the factor and both kg and tonne values — that array is
what the report UI shows as the calculation breakdown.
Factors are stored as kg CO2e per entered unit. The /1000 at
Rest.php:1088 is the only place that conversion happens.
Direct reporting
Section titled “Direct reporting”Stored as cc_report_meta keys direct_reporting_enabled (yes/no),
direct_scope1_tco2e, direct_scope2_tco2e, direct_scope3_tco2e, plus
per-Scope-3-category keys from DirectReportingCatalog::scope3_metric_key_labels().
The short-report flow writes only flags to meta, so
merge_direct_reporting_answers_into_meta() (Rest.php:2050) overlays the
same keys found in cc_report_answer before the totals are read. Values are
already in tCO2e — no factor, no division.
Schema v17 (Migrations.php:336) added cc_report.emissions_basis and
backfilled estimated for every report whose meta had
direct_reporting_enabled = 'yes'. Note that report_calculate()
unconditionally sets the column back to calculated at Rest.php:1167
even when direct values were blended in — the direct_reporting_added flag
in the response is the reliable signal, not the column.
Submit
Section titled “Submit”report_submit() (Rest.php:1188):
- Requires an LMS session with an
lms_idand owner-mode edit permission. - Already
submitted→ returns 200 idempotently with the existing timestamps. - Anything other than
draft→ 409locked. - Requires
cc_report_meta.final_calculation_confirmedto be1ortrue, else 400confirmation_required(Rest.php:1241). This is the T&C / accuracy checkbox. - Sets
status = submitted,completion_pct = 100,submitted_at,locked_at,submitted_by,updated_by.
“Locked” is advisory. report_has_editable_status() (Rest.php:244)
allows both draft and submitted, so a submitted report remains
editable and recalculable by an owner with an edit role. locked_at is
recorded but nothing enforces it. Deletion of a submitted report is gated
by the filter carboncalc_allow_delete_submitted_report, which defaults to
true (Rest.php:269) — the comment says that default is for debugging.
Configuration
Section titled “Configuration”No constants. Behaviour is data-driven:
cc_metric_factorrows per(metric_key, reporting_year_id)— loaded via the CSV importer, see [[reporting-year-config-and-importer]].cc_metric.scopedecides which bucket a metric lands in.NULLscope contributes tototalbut to no scope — theif ($scope === 1)chain atRest.php:1093has no else.
Filters: carboncalc_allow_delete_submitted_report.
Invariants and gotchas
Section titled “Invariants and gotchas”- A missing factor is silent. It appears in
missing_factorsbut the metric contributes zero and the request still returns 200. If a reporting year’s factors were never imported, every report for that year totals zero and looks legitimately calculated. Always checkmissing_factorsis empty after adding metrics. - A
NULLmetric scope inflatestotalwithout appearing in any scope. The dashboard shows scope bars summing to less than the total. - Direct reporting is additive, not a replacement. A company that
enters activity data and direct totals gets both counted.
total = s1 + s2 + s3after blending (Rest.php:1136), so the blend wins over the accumulatedtotal— but the accumulated per-metric values are already inside the scope buckets. - Empty string answers count as 0, not “unanswered” (
(float) $v_rawatRest.php:1073). Onlyvalue IS NULLrows are excluded, by the SQL. - Submitted reports are still editable. Do not rely on
locked_atfor data integrity; if you need a hard lock, changereport_has_editable_status()and expect the report UI’s “Edit Report” button (page-my-company-emissions.php:117) to need updating too. cc_report_resultis upserted on uniquereport_id, so there is exactly one result row per report — no history. Year-over-year charts are built by joining reports across years, not from result history.completion_pctis written by a separate endpoint from the front end (Rest.php:632); it is a UI progress indicator, not derived server-side.
Changing it safely
Section titled “Changing it safely”- New factor maths (a metric whose factor is not simply “kg per unit”)
does not fit the current loop. The loop is deliberately one line of
arithmetic — if you need per-metric formulas, add a resolver keyed on
cc_metric.input_typerather than special-casing metric keys inside the loop. - Adding a scope (there is no Scope 4, but categories change): update the
$scope1/2/3accumulation,cc_report_resultcolumns (schema bump inMigrations.php), the response shape, and the dashboard’s$cc_scoped_emissionsarray (page-my-company-emissions.php:86). - Bump
CARBONCALC_SCHEMA_VERSION(carboncalc.php:11) for any schema change and add a gated block inMigrations::run(). Migrations run onplugins_loadedviamaybe_migrate(), so a bump deploys itself on the next request. - Verify by hand: create a draft report, answer one metric with a known
factor,
POST .../calculate, and check theitemsarray showsvalue × factor / 1000. Then confirmcc_report_resultmatches. Submitting withoutfinal_calculation_confirmedmust 400. - Deliberately not abstracted: results are written as strings. The comment
at
Rest.php:1149explains why —wpdbreformats floats.
None. The calculation loop is the single highest-risk untested code in the codebase: a factor-unit mistake produces plausible numbers that a client may publish.
Related: [[carbon-report-questionnaire]], [[reporting-year-config-and-importer]], [[company-emissions-dashboard]], [[report-sharing]].