Skip to content

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.

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.

PathWhat it does
wp-content/plugins/carboncalc/includes/Rest.php:927POST /carboncalculator/v1/reports/{id}/calculate
wp-content/plugins/carboncalc/includes/Rest.php:1188POST /carboncalculator/v1/reports/{id}/submit
wp-content/plugins/carboncalc/includes/Rest.php:632POST .../completion — stores completion_pct
wp-content/plugins/carboncalc/includes/Rest.php:2087get_direct_reporting_result_from_meta()
wp-content/plugins/carboncalc/includes/DirectReportingCatalog.phpThe Scope 3 category → metric-key labels for direct entry
wp-content/plugins/carboncalc/includes/TurnoverValidation.phpSanity-checks turnover against emissions

report_calculate() (Rest.php:927):

  1. Resolve company from the LMS session; 401 if none.
  2. resolve_report_request_bundle() — accepts a UUID or a numeric id, and resolves owner vs grantee access.
  3. report_can_edit_row() — must be owner mode, status draft or submitted, and the user must hold a Carbon edit role. 403 otherwise.
  4. 409 locked if the status is anything other than draft/submitted.
  5. Load all cc_report_answer rows with a non-null value. No answers → upsert a zero result and return early (Rest.php:979).
  6. Load cc_metric_factor rows for reporting_year_id restricted to the metric keys actually answered.
  7. Load cc_metric rows for step, scope and category.
  8. 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.
  9. Sum per-step totals for the UI.
  10. Blend direct reporting additively, then total = s1 + s2 + s3.
  11. Upsert cc_report_result (unique on report_id), values passed as strings so wpdb does not reformat the decimals (Rest.php:1149).
  12. 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.

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.

report_submit() (Rest.php:1188):

  • Requires an LMS session with an lms_id and owner-mode edit permission.
  • Already submitted → returns 200 idempotently with the existing timestamps.
  • Anything other than draft → 409 locked.
  • Requires cc_report_meta.final_calculation_confirmed to be 1 or true, else 400 confirmation_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.

No constants. Behaviour is data-driven:

  • cc_metric_factor rows per (metric_key, reporting_year_id) — loaded via the CSV importer, see [[reporting-year-config-and-importer]].
  • cc_metric.scope decides which bucket a metric lands in. NULL scope contributes to total but to no scope — the if ($scope === 1) chain at Rest.php:1093 has no else.

Filters: carboncalc_allow_delete_submitted_report.

  • A missing factor is silent. It appears in missing_factors but 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 check missing_factors is empty after adding metrics.
  • A NULL metric scope inflates total without 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 + s3 after blending (Rest.php:1136), so the blend wins over the accumulated total — but the accumulated per-metric values are already inside the scope buckets.
  • Empty string answers count as 0, not “unanswered” ((float) $v_raw at Rest.php:1073). Only value IS NULL rows are excluded, by the SQL.
  • Submitted reports are still editable. Do not rely on locked_at for data integrity; if you need a hard lock, change report_has_editable_status() and expect the report UI’s “Edit Report” button (page-my-company-emissions.php:117) to need updating too.
  • cc_report_result is upserted on unique report_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_pct is written by a separate endpoint from the front end (Rest.php:632); it is a UI progress indicator, not derived server-side.
  • 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_type rather than special-casing metric keys inside the loop.
  • Adding a scope (there is no Scope 4, but categories change): update the $scope1/2/3 accumulation, cc_report_result columns (schema bump in Migrations.php), the response shape, and the dashboard’s $cc_scoped_emissions array (page-my-company-emissions.php:86).
  • Bump CARBONCALC_SCHEMA_VERSION (carboncalc.php:11) for any schema change and add a gated block in Migrations::run(). Migrations run on plugins_loaded via maybe_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 the items array shows value × factor / 1000. Then confirm cc_report_result matches. Submitting without final_calculation_confirmed must 400.
  • Deliberately not abstracted: results are written as strings. The comment at Rest.php:1149 explains why — wpdb reformats 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]].