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.
Why it exists
Section titled “Why it exists”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.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/plugins/carboncalc/includes/AdminYearsPage.php | wp-admin → Carbon Calculator → Reporting Years |
wp-content/plugins/carboncalc/includes/AdminImporterPage.php | The Importer screen (cc-importer), AJAX-chunked |
wp-content/plugins/carboncalc/includes/ImporterFormat.php | The canonical CSV contract — columns, phases, version |
wp-content/plugins/carboncalc/includes/ImporterEngine.php:18 | analyze_file() — validation and the year guard |
wp-content/plugins/carboncalc/includes/ImporterEngine.php:115 | process_chunk() — the phase machine |
wp-content/plugins/carboncalc/includes/ImporterJob.php | Job state between chunks |
wp-content/plugins/carboncalc/includes/Seed/SeedBootstrap.php:9 | One-time seeders, gated on options |
wp-content/plugins/carboncalc/includes/AdminFactorsPage.php | Manual factor editing |
wp-content/plugins/carboncalc/includes/StepYears.php | Per-year step helpers, canonical_year_id() |
wp-content/plugins/carboncalc/includes/AdminSicCodesPage.php | SIC intensity table |
How it works
Section titled “How it works”The CSV format
Section titled “The CSV format”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_factorEach 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.
Validation, then import
Section titled “Validation, then import”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_versionother than1, - any row has a missing or unknown
record_type, - there are no
metric_factorrows, - the
metric_factorrows do not all share onereporting_year_label, - that label does not already exist in
cc_reporting_year, - that reporting year already has any
cc_metric_factorrows.
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.
The seeders
Section titled “The seeders”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 flag | Seeds |
|---|---|
carboncalc_seed_version (< 5) | Reporting years, only when the table is empty |
carboncalc_import_stationary_v1 | Stationary combustion structure, metrics, fields |
carboncalc_import_mobile_v1 | Mobile combustion (structure + metrics only — resolver-driven) |
carboncalc_import_fugitive_v1 | Fugitive emissions |
carboncalc_import_purchased_goods_v1 | Purchased goods & services (quantity + spend, plus a quantity index) |
carboncalc_import_water_v1 | Water |
carboncalc_import_waste_v1 | Waste (plus a quantity index) |
carboncalc_seed_final_calculation_step_v1 | The final calculation / confirmation step |
carboncalc_seed_basic_information_step_v1 | Basic information step |
carboncalc_seed_policies_targets_step_v1 | Policies & targets step |
carboncalc_seed_direct_reporting_step_v1 | Direct reporting step |
carboncalc_seed_scope3_ghg_categories_v1 | Scope 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).
Schema migrations
Section titled “Schema migrations”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.
Configuration
Section titled “Configuration”No constants. Everything is options and data:
carboncalc_schema_version,carboncalc_seed_version, and thecarboncalc_import_*_v1/carboncalc_seed_*_v1flags.carboncalc_reporting_years_copied_to_blog6_v1andcarboncalc_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.
Invariants and gotchas
Section titled “Invariants and gotchas”- The year guard blocks re-import. Any existing
cc_metric_factorrow for that year aborts the whole file with theblockederror. Deleting factors is a manual step in the Factors admin screen. cc_reporting_yearmust already contain the label. The importer will not create a year for you.- Steps are per-year; categories, metrics and fields are global. A
fieldormetricrow 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 oncc_stepfirst. - 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 everyplugins_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.
Changing it safely
Section titled “Changing it safely”- New CSV columns: append to
ImporterFormat::COLUMNS, handle them in the relevantupsert_*()inImporterEngine, and bumpImporterFormat::VERSION— the version check atImporterEngine.php:44will then reject old files rather than mis-parsing them. - New
record_type: add toPHASESin dependency order and add acasetoapply_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 aif ($installed_version < N)block inMigrations::run(), and add the column toensure_carbon_site_cc_tables()so fresh installs get it too. Use raw$wpdb->query()for anythingdbDeltacannot do, and always guard with aSHOW COLUMNS/SHOW INDEXexistence 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 runPOST .../calculate—missing_factorsmust 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]].