Skip to content

Reporting year configuration and CSV importer

A reporting year is the unit of configuration for the whole calculator: its steps, and critically its conversion factors. This page covers how a new year is set up — the one-time seeders that built the original 2025/2026 structure, and the CSV importer used to load each year’s factors.

Defra publishes new GHG conversion factors annually, thousands of rows. Hand-entering them in wp-admin is not viable, and a code deploy per year would be worse. The importer takes one canonical CSV containing steps, categories, metrics, fields and factors, and applies it in dependency order with a guard against double-importing a year.

The seeders are a separate, earlier mechanism: they built the initial questionnaire structure from CSVs shipped inside the plugin, and they run once per option flag. They are effectively immutable history now.

PathWhat it does
wp-content/plugins/carboncalc/includes/AdminYearsPage.phpwp-admin → Carbon Calculator → Reporting Years
wp-content/plugins/carboncalc/includes/AdminImporterPage.phpThe Importer screen (cc-importer), AJAX-chunked
wp-content/plugins/carboncalc/includes/ImporterFormat.phpThe canonical CSV contract — columns, phases, version
wp-content/plugins/carboncalc/includes/ImporterEngine.php:18analyze_file() — validation and the year guard
wp-content/plugins/carboncalc/includes/ImporterEngine.php:115process_chunk() — the phase machine
wp-content/plugins/carboncalc/includes/ImporterJob.phpJob state between chunks
wp-content/plugins/carboncalc/includes/Seed/SeedBootstrap.php:9One-time seeders, gated on options
wp-content/plugins/carboncalc/includes/AdminFactorsPage.phpManual factor editing
wp-content/plugins/carboncalc/includes/StepYears.phpPer-year step helpers, canonical_year_id()
wp-content/plugins/carboncalc/includes/AdminSicCodesPage.phpSIC intensity table

One UTF-8 CSV, fixed header, format_version = 1 (ImporterFormat.php:23). Every row carries a record_type — one of step, category, metric, field, metric_factor — and only the columns relevant to that type need values (ImporterFormat::COLUMNS, :28).

Phases run in a fixed order regardless of row order in the file:

step → category → metric → field → metric_factor

Each phase re-scans the file from the top and skips non-matching rows (ImporterEngine::process_chunk(), :160). That is why row order does not matter, and why a large file is read five times.

analyze_file() (ImporterEngine.php:18) refuses the whole file if:

  • the header does not map (ImporterFormat::map_header() returns null),
  • any row has a format_version other than 1,
  • any row has a missing or unknown record_type,
  • there are no metric_factor rows,
  • the metric_factor rows do not all share one reporting_year_label,
  • that label does not already exist in cc_reporting_year,
  • that reporting year already has any cc_metric_factor rows.

That last check is the important one: an import is only ever into an empty factor year. To re-import you must delete the year’s factors first.

Import then proceeds in chunks of 200 matching rows per request (CHUNK_MATCHING_ROWS, :11), tracking a byte offset per phase in the job state so the browser can drive it with repeated AJAX calls without timing out. Errors are collected, capped at the last 40, and the file is deleted and the job removed when the final phase reaches EOF (:199).

Every phase writes with INSERT … ON DUPLICATE KEY UPDATE, so an import is idempotent per row. Steps go through StepYears::upsert_step_definition() so they land against the right year.

SeedBootstrap::run() (Seed/SeedBootstrap.php:9) is called from Seeder::maybe_seed() on both activation and plugins_loaded (carboncalc.php:66, :72). Each block is gated on its own option flag:

Option flagSeeds
carboncalc_seed_version (< 5)Reporting years, only when the table is empty
carboncalc_import_stationary_v1Stationary combustion structure, metrics, fields
carboncalc_import_mobile_v1Mobile combustion (structure + metrics only — resolver-driven)
carboncalc_import_fugitive_v1Fugitive emissions
carboncalc_import_purchased_goods_v1Purchased goods & services (quantity + spend, plus a quantity index)
carboncalc_import_water_v1Water
carboncalc_import_waste_v1Waste (plus a quantity index)
carboncalc_seed_final_calculation_step_v1The final calculation / confirmation step
carboncalc_seed_basic_information_step_v1Basic information step
carboncalc_seed_policies_targets_step_v1Policies & targets step
carboncalc_seed_direct_reporting_step_v1Direct reporting step
carboncalc_seed_scope3_ghg_categories_v1Scope 3 GHG categories and their metric links

Source CSVs live in wp-content/plugins/carboncalc/data/, numbered 1_–6_. Metrics are seeded against StepYears::canonical_year_id(), falling back to year id 1 (SeedBootstrap.php:31).

CARBONCALC_SCHEMA_VERSION (carboncalc.php:11, currently 22) drives Migrations::maybe_migrate() on plugins_loaded. Migrations::run() issues dbDelta() for every table, then a series of version-gated raw ALTER TABLE/UPDATE blocks for things dbDelta cannot express. See Migrations.php:285 onward; each block is commented with why it exists.

There is also a manual Database Migrations admin screen (AdminMigrationsPage.php) for forcing a run.

No constants. Everything is options and data:

  • carboncalc_schema_version, carboncalc_seed_version, and the carboncalc_import_*_v1 / carboncalc_seed_*_v1 flags.
  • carboncalc_reporting_years_copied_to_blog6_v1 and carboncalc_legacy_cc_user_roles_copied_v1 — one-time multisite data moves (Migrations.php:702, :730).

The importer also offers export helpers: ImporterExampleFromSeed generates a template from the current data, and ImporterSeedCombinedExporter (1,182 lines) exports the whole seeded configuration as one importable CSV — that is how you clone last year’s structure into a new year.

  • The year guard blocks re-import. Any existing cc_metric_factor row for that year aborts the whole file with the blocked error. Deleting factors is a manual step in the Factors admin screen.
  • cc_reporting_year must already contain the label. The importer will not create a year for you.
  • Steps are per-year; categories, metrics and fields are global. A field or metric row in the CSV overwrites the single global definition for every year at once. Only factors and steps are year-isolated.
  • Schema v18 auto-copies steps into empty years (Migrations.php:361): any reporting year with zero step rows inherits the earliest year’s steps. Create the year, then import, or the copy may not be what you expect.
  • Schema v20 exists purely to drop a stray UNIQUE(step_key) index left by the v18 migration, which otherwise blocks copying the same step keys into another year (Migrations.php:452). If you see duplicate-key errors on step import, check the indexes on cc_step first.
  • Multisite migrations loop over the hard-coded blog list [6, 8] (Migrations.php:505, :535, :574). A Carbon site on another blog id never gets the newer columns.
  • The seeders write the same option flags on whichever blog is active. On multisite they can therefore re-run per subsite. Seeder::maybe_seed() runs on every plugins_loaded, so an unset flag means the CSV import re-runs — the writes are upserts, so it is slow rather than destructive.
  • The importer deletes the uploaded file only on successful completion (ImporterEngine.php:199). Abandoned jobs leave the temp file behind.
  • New CSV columns: append to ImporterFormat::COLUMNS, handle them in the relevant upsert_*() in ImporterEngine, and bump ImporterFormat::VERSION — the version check at ImporterEngine.php:44 will then reject old files rather than mis-parsing them.
  • New record_type: add to PHASES in dependency order and add a case to apply_row() (ImporterEngine.php:228). Phase order is the dependency order; getting it wrong produces orphan foreign keys with no error.
  • Do not add new seeders. New configuration should ship as a CSV import, not as code — the seeders exist because the importer did not yet.
  • Any schema change: bump CARBONCALC_SCHEMA_VERSION, add a if ($installed_version < N) block in Migrations::run(), and add the column to ensure_carbon_site_cc_tables() so fresh installs get it too. Use raw $wpdb->query() for anything dbDelta cannot do, and always guard with a SHOW COLUMNS / SHOW INDEX existence check — the block may run more than once.
  • Verify a new year end to end: create the year, export last year’s config with ImporterSeedCombinedExporter, edit the factors, import, then open a new report for that year and run POST .../calculate — missing_factors must be empty.

None. ImporterFormat::map_header() and the analyze_file() validation rules are pure and would be cheap to cover.

Related: [[carbon-report-questionnaire]], [[emissions-calculation-and-submission]], [[carbon-admin-and-debug-tools]].