From be62e02cd26cb05e0ca19a29ec08339163fde005 Mon Sep 17 00:00:00 2001 From: repro Date: Thu, 13 Aug 2026 11:53:37 +0200 Subject: [PATCH 1/6] Add PLAN --- PLAN.md | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..fc30fb0b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,376 @@ +# #858 — v2 pathway metadata schema + ACE/IEA migration + +## Context + +#858 is the foundation ticket of epic #860 (best-effort / inheritance search). The epic's goal: when a +user narrows a search (e.g. "Solar power in Thailand"), show the most specific value each pathway +actually has, falling back to a broader scope rather than dropping the pathway. That requires +keyFeature values to carry the scope they apply to — today each is a single flat scalar (or flat array) +per pathway, with no scope at all. + +#858 delivers the data-model half: a `pathwayMetadata.v2.json` where each of the 11 keyFeatures is an +array of `{sector, geography, value}` entries, plus the new `coreDrivers`, `dependencies`, and +`pathwayDescription` sections. The resolver that ranks scopes is #869; the scope badges are #859. +Neither is in scope here — #858 lands the shape and the search/render changes that shape forces. + +**Scope decided with the user, narrower than the issue text:** + +- **Data migration covers 7 files** — the 4 ACE and 3 IEA files, edited in place. The other 49 move in a + follow-up PR. +- **No runtime v1→v2 conversion.** The loader validates against v2 only, so the 49 un-migrated files + simply don't load. If a compatibility shim turns out to be wanted, that's a later decision. +- **#801's NGFS region work is dropped** and handed to a separate thread (see *Handoffs*). + +### Consequence to be aware of + +Loading v2 only means **the app goes from 56 pathways to 7** until the follow-up PR lands. Search, +comparison, and the step-by-step guide will all be working off ACE + IEA alone. That's fine on a +feature branch but would look broken if deployed, so the follow-up shouldn't lag far behind. + +Two things make this cheaper than it sounds. `validateDataCollect` already filters entries to the one +schema `$id` it's handed, so "only load v2" is a one-line pointer change in +`src/data/pathwayMetadata.ts` rather than new machinery. And the test suite doesn't hardcode the corpus +size — the only real-data assertion, `PathwaySearch.test.tsx:46`, compares rendered cards to +`pathwayMetadata.length`, so it stays green at 7. The 2108 baseline should hold. + +The one thing worth adding: the drop is currently **silent**. Have the loader log how many documents +were skipped for an unrecognised `$schema`, so 49 missing pathways can't be mistaken for a data bug. + +## Findings that change #858 as written + +1. **`expertOverview` is a 3-section markdown document, identical in structure across all 56 files.** + The migration is a *decomposition*, not a copy: + + | Section | n | median | max | v2 destination | + |---|---|---|---|---| + | `#### Pathway Description` | 56 | 1343 | 2727 | `pathwayDescription` | + | `#### Core Drivers` | 55 | 1184 | 2137 | `coreDrivers` — prose, needs a human | + | `#### Application to Transition Assessment` | 56 | 1860 | 2655 | **nothing in v2** | + + #858's 2500-char cap on `pathwayDescription` is **correct** — it applies to the section, not the + whole 4.4 KB `expertOverview`. All 7 in-scope files land between 730 and 1467 chars. + +2. **The third section has no home.** Per your call, v2 gains a nullable `transitionAssessment` + (string, max 2500) so the migration orphans no authored content. Flag on #858 as a scope addition. + +3. **`coreDrivers` cannot be codemodded.** The `#### Core Drivers` prose uses loose italic sub-labels + (`*Policy:*`, `*Emissions goals:*`, `*Technology Deployment & Technology costs:*`) that don't map + cleanly onto the 7 required fields. Per #858, scaffold all 7 as `null`; keep the prose in + `transitionAssessment` so nothing is lost pending hand-authoring. + +4. **`ACE-CNS-2024.json` has a malformed heading.** Its "Core Drivers" heading lost its `####` markers, + so it reads as one 2727-char Pathway Description. Restore the markers and the description measures + 1204 chars. Only file of 56 affected. + +5. **v2's keyFeature `geography` must not be a closed enum.** #858 says `geography ∈ geographyItem + values`, but `geographyItem.v1.json`'s enum holds only 8 region labels, while `geography.regions` + keys are free-form by #783's design — IEA's own files already use `Asia Pacific`, `Eurasia`, + `Middle East`, and `Central and South America`, none of them in that enum. A closed enum would + reject any future finer-scope entry on an IEA pathway. + +6. **`pathwayOverview` has zero production readers.** Authored in 5 files and typed, never rendered. + For the 2 in-scope IEA files that have it (493 and 419 chars), prepend it as the lead paragraph of + `pathwayDescription`; both stay well under 2500. + +7. **`npm run json:check` does not exist.** The real gate is `npm run schema` (= `schema:check` + type + regen + docs regen). `src/data/README.md:15` is stale. + +8. **TypeScript will not catch this migration.** `keyof PathwayMetadataType["keyFeatures"]` is unchanged + by the shape change, so `KeyFeatures.tsx:241`'s `rawValue` flows into `Array.isArray` / `typeof` + guards that silently degrade to "No information" rather than erroring. Expect near-zero compile + errors and real runtime breakage — the tests below are the actual safety net. + +## Commit sequence + +Your order works, with one correction and one insertion. + +**Correction to commit 1:** it must *not* repoint `PathwayMetadataType`. If it does, every consumer +starts reading v1 data through v2 array types immediately and commit 1 is broken on its own. Keep it +purely additive — v2 schema and types land alongside v1, nothing consumes them yet. + +**Insertion:** the loader pointer flip is what makes v2 shape reach the consumers, so it belongs at the +head of commit 3, not in commit 2. Commit 2 then stays green: the 7 files validate against v2 via +`schema:check` but aren't loaded yet, so the app still runs on the 49 v1 files. + +### 1. Schema + generated types (additive, green) + +New `src/schema/pathwayMetadata.v2.json` and the two new common subschemas; register them in +`src/schema/common/index.ts`; regenerate `src/types/**` and `public/schema/*.html`. `v1` untouched and +still loadable. `PathwayMetadataType` still points at v1. No behaviour change. + +### 2. Migrate the 7 ACE/IEA data files (green) + +The 7 files get `$schema` → v2, decomposed `expertOverview`, wrapped keyFeatures, scaffolded +`coreDrivers`/`dependencies`. Plus v2 fixtures under `testdata/valid/`, keeping at least one fixture on +v1 so `schema:check` covers coexistence. App unchanged — still loading v1. + +### 3. Loader flip + search semantics + +`src/data/pathwayMetadata.ts` points at v2; `PathwayMetadataType` → `PathwayMetadataV2`; both facet arms +and option-building in `searchUtils.ts` move to scoped entries. **This is where the corpus drops to 7.** + +Intermediate state to accept knowingly: between commits 3 and 4, keyFeature pills render "No +information" for all 7 pathways, because `KeyFeatures.tsx` is still reading arrays as scalars. It +degrades silently rather than crashing, which is exactly finding 8 — worth a line in the commit message +so it doesn't read as a regression to a reviewer bisecting. + +### 4. Rendering + +`KeyFeatures.tsx` reads through the widest-entry helper; `PathwayDetailPage.tsx` swaps `expertOverview` +→ `pathwayDescription`. Pills come back. This is the commit that restores parity. + +## Schema design (`src/schema/pathwayMetadata.v2.json`) + +`$id: http://pathways.rmi.org/schema/pathwayMetadata.v2.json`. Copy v1, then: + +**keyFeatures** — each of the 11 fields becomes an array of scoped entries. Extract one reusable scoped- +entry shape (the `value` differs per field, the scope doesn't): + +```jsonc +{ "type": "array", "uniqueItems": true, "items": { + "type": "object", "additionalProperties": false, + "required": ["sector", "geography", "value"], + "properties": { + "sector": { "$ref": ".../common/scopeSector.v2.json" }, + "geography": { "$ref": ".../common/scopeGeography.v2.json" }, + "value": { /* per-field: v1's enum verbatim, or array-of-enum for the 2 array fields */ } + } } } +``` + +- All 11 fields stay in `required`, with no `minItems` — an empty array is the legal "absent at every + scope" state that lets #869's resolver fall through to "No information". +- **Two fields keep array values**: `policyTypes` and `newTechnologiesIncluded` (both `minItems: 1` + arrays in v1). The codemod must nest, not splat: one entry whose `value` is the whole v1 array. + `emissionsScope` is a `$ref` scalar and behaves like the other 8. +- `"No information"` is **already** in every v1 enum except `policyTypes` (which has `"None"`). Add + `"No information"` to `policyTypes`' item enum so #858's terminate-the-fallback semantics work + uniformly across all 11. + +**Two new common subschemas** — both must be added to `src/schema/common/index.ts` by hand or AJV fails +with "no schema with key or ref"; typegen auto-discovers, so only AJV needs the manual step: + +- `scopeSector.v2.json` — `sector.v1.json#/$defs/displayName`'s 15 members **+ `"cross-sector"`**. +- `scopeGeography.v2.json` — an **open** string: `"Global"`, `"cross-region"`, any `countryCode.v1` + member, or any author-defined region label. Carries `geographyItem`'s non-enum guards + (`minLength: 1`, non-blank pattern, `not` 3-letter) instead of a closed enum, per finding 5. + +**New top-level fields**, as #858 specifies except where noted: + +- `pathwayDescription`: `["string","null"]`, max 2500. Replaces `expertOverview` and `pathwayOverview`, + both **removed**. Root `required` swaps `expertOverview` → `pathwayDescription`. +- `transitionAssessment`: `["string","null"]`, max 2500. **Addition beyond #858** (finding 2). +- `coreDrivers`: object, `additionalProperties: false`, all 7 required and nullable + (`["string","null"]`, max 500): `policies`, `emissionsTargets`, `technologyCosts`, + `investmentChange`, `macroeconomicDrivers`, `behavioralShifts`, `otherDrivers`. +- `dependencies`: array of `{dependency_name, dependency_description (≤500), sector, evidence_type}`, + all required, `additionalProperties: false`, enums per #858. + +**Cross-field constraint is not expressible in draft-07.** #858 requires an entry's `sector`/ +`geography` to be declared in the pathway's own `sectors`/`geography`. That needs sibling data with +dynamic keys — AJV's `$data` can't compute the union either. Implement as a structural check in +`scripts/schema-check-files.ts` alongside AJV so `npm run schema:check` still enforces it. Note AJV runs +`strict: true`: any new keyword must be registered (as `tsType` already is) or `addSchema` throws. + +## Search-facet semantics (the #858 gap) + +`emissionsTrajectory` and `policyAmbition` are keyFeature fields **and** search facets read as scalars. +Both option-building (`searchUtils.ts:86,91`) and matching (`:438–500`) break on arrays — and break +*silently*: `buildOptionsFromValues` would label entries `"[object Object]"`, and `concrete.includes(v)` +against an array is always false, so **selecting either facet would return zero pathways**. + +Per your steer, matching respects the user's selected scope. Implement containment without pre-empting +#869's cost model: + +- **Option building** — `entries.flatMap(e => e.value)` (flatten twice for the two array-valued fields). + Values are unchanged from v1, so the dropdowns and `StepByStepGuide`'s hardcoded remap categories + keep working. +- **Matching** — a pathway matches if it has an entry whose scope **contains** the active + sector/geography filter and whose value is selected: + - *sector* — entry sector equals the selected sector, or is `"cross-sector"` **and** the selected + sector is one the pathway declares (per Jacob on #869: `"cross-sector"` is the union of the + pathway's own sectors, not a universal match). + - *geography* — the selected region's ISO set ⊆ the entry's ISO set, reusing + `selectedGeographyToISO` (`src/utils/filterRegions.ts`) and `pathwayISOCoverage` + (`src/utils/geographyUtils.ts:36`). `"Global"` contains everything. + - With no sector/geography filter active this degenerates to "any entry matches", preserving today's + behaviour. +- **No cost model, no ranking, no fallback ordering** — those are #869. This decides inclusion only. + +The four near-identical ~30-line single-valued-token blocks (`pathwayType`, `emissionsTrajectory`, +`policyAmbition`, `dataAvailability`) should collapse into one `scopedFacet()` helper rather than being +edited in parallel. `ABSENT_FILTER_TOKEN` maps to an empty entries array. + +**One semantic question stays open for Jacob** — draft comment at the end. + +## Rendering + +`KeyFeatures.tsx` is the only place values are interpreted (`ComparisonKeyFeatures.tsx` imports its +`GROUPS`/`FeatureItem`, so one fix covers both). Add a small provisional helper — e.g. +`src/utils/keyFeatureValues.ts` — that picks the widest-scope entry, and use it at +`KeyFeatures.tsx:241` so all four branches (`single-select`, `multi-select`, `sentiment`, `neutral`) +receive the shape they get today. Views stay pixel-identical; #859 replaces the helper with the +resolver's `{value, scope, isExact}` and adds badges. Mark it explicitly provisional, referencing #869. + +`PathwayDetailPage.tsx:257–265` swaps `pathway.expertOverview` → `pathwayDescription`. Two copy +decisions ride along: the "Expert Overview" `

` and the matching user-facing prose at +`ResourcesMethodologyPage.tsx:161–176`. Renaming is #859's call — keep current wording and note it, so +this PR carries no visible copy change. + +## Files to change + +**Commit 1 — schema + types** +- new `src/schema/pathwayMetadata.v2.json`; new `src/schema/common/scopeSector.v2.json` and + `scopeGeography.v2.json`; register both in `src/schema/common/index.ts`. +- `src/schema/pathwayMetadata.v1.json` — untouched. +- `src/types/index.ts` — export `PathwayMetadataV2`; **leave `PathwayMetadataType` on v1**. +- Regenerate, never hand-edit: `src/types/**/*.d.ts`, `public/schema/*.html`. CI's `types-check` and + `schema-docs` jobs re-run both generators and fail on any diff, so both must be regenerated and left + in the tree. +- `scripts/schema-check-files.ts` — the cross-field structural check. + +**Commit 2 — data** +- `src/data/asean-centre-for-energy/ACE-{ATS,BAS,CNS,RAS}-2024.json` — all `cross-sector` / + `South East Asia`. Fix ACE-CNS's `####` heading first. +- `src/data/iea/IEA-{APS,NZE,STEPS}-2024.json` — all `cross-sector` / `Global`. APS and STEPS also fold + `pathwayOverview` into `pathwayDescription`. +- `testdata/valid/` — v2 fixtures, at least one left on v1. `pathwayMetadata_standard.json` is spread + ~20× in `validateData.test.tsx`, and `_full`/`_sample_01..04` feed `searchUtils`, `PathwayCard`, and + `SearchSection` tests, so decide per fixture rather than migrating all 7. +- `scripts/codemod-v1-to-v2.ts` — one-shot dev tool doing the section split and keyFeature wrap. Not a + runtime path, and not needed for correctness at 7 files, but the other 49 are a re-run and doing 7 × + (3-section split + 11 wraps) by hand is where transcription errors come from. Push back if you'd + rather not carry it. + +**Commit 3 — loader flip + search** +- `src/data/pathwayMetadata.ts` — point at v2; log the skipped-document count. +- `src/types/index.ts` — `PathwayMetadataType = PathwayMetadataV2`; keep v1 exported for the migration + window; re-check `Sector`/`Metric`/`PathwayType` (indexed accesses, should follow) and `Geography`. +- `src/utils/searchUtils.ts` — options at `:86,91`; matching at `:438–500`; extract `scopedFacet()`. +- `src/components/StepByStepGuide.tsx` — verify only; its remap categories key off values, not shapes. + +**Commit 4 — rendering** +- new `src/utils/keyFeatureValues.ts`; `src/components/KeyFeatures.tsx:241`. + `ComparisonKeyFeatures.tsx` needs no change. +- `src/pages/PathwayDetailPage.tsx:257–265`. +- `src/utils/tooltipUtils.ts:64` — `keyof`-derived, should survive; verify. + +**Tests, spread across commits 2–4** +- `src/utils/validateData.test.tsx:95` — `REQ` array: `expertOverview` → `pathwayDescription`. + Assertions match on `instancePath` regexes, so renamed fields break them. +- `src/components/KeyFeatures.test.tsx:6–18` + override casts at `:87,102,153` — the only inline + all-11-field fixture; assertions are on rendered strings and Tailwind classes. +- `src/pages/ComparisonPage.test.tsx:27,44` and `PathwaySearch.test.tsx:68,81,92,103,114` — + `keyFeatures: { emissionsTrajectory: "foo" }` stubs. +- **New tests**, since TypeScript won't catch this class of bug (finding 8): the section splitter + (including ACE-CNS's malformed heading), a v1/v2 coexistence case in `validateData.test.tsx`, and a + scope-containment table test for the two facet arms. + +**Docs** +- `src/data/README.md` — v2 authoring shape; fix the stale `expertOverview` R example at `:93`, the + stale `pbtar_schema.json` link, and `npm run json:check` at `:15`. + +## Verification + +Per commit: + +```bash +npm run schema:check +``` + +```bash +npm run schema && git status --short +``` + +The second regenerates types and docs; a `git status` clean but for intended files is what CI's +`types-check` / `schema-docs` jobs assert. Note `schema:generate:docs` builds a Python venv and +`pip install json-schema-for-humans` — if that can't run offline I'll say so rather than leave +`public/schema/` stale. + +```bash +npm test -- --run +``` + +Baseline is 2108 passing. A lone `ComparisonPage.test.tsx` timeout is flaky under full-suite +parallelism — re-run alone to confirm: + +```bash +npx vitest run src/pages/ComparisonPage.test.tsx +``` + +After commit 4, check the app on the 7-pathway corpus: + +```bash +npm run dev +``` + +Spot-check an IEA detail page (`Global` scope) and an ACE one (`South East Asia`), then the Policy +Ambition dropdown and the guide's Emissions Trajectory step — those two facets are the silent-failure +sites, and "returns zero pathways" is what a regression there looks like. + +## Handoffs — issues to point other threads at + +- **NGFS region memberships** → **#801**. The 7 `src/data/ngfs/NGFS-*-2024.json` files each carry the + same 8 region labels with empty ISO arrays; they're the only empty memberships left in `src/data`. + Context for that thread: commit `56079cb` converted the flat geography array to + `{global, regions, country}` and dropped the memberships, but nothing is unrecoverable — each file + still holds the complete 143-code `country` list. The 8 labels are World Bank groupings plus "South + East Asia", and the 7 WB regions partition those 143 codes **exactly** (zero unassigned, zero + overlap), with South East Asia = ASEAN ∩ list = 9 codes, a clean subset of East Asia and Pacific. So + the memberships are derivable and self-checking. NGFS's own Phase V publication reports on model + regions (REMIND/GCAM/MESSAGE), not these labels, so the labels look RMI-authored. +- **The remaining 49 data files** → follow-up PR on **#858**, or a new child of #860. A re-run of the + codemod over the rest. Worth prioritising, since until it lands the app shows 7 pathways. +- **`coreDrivers` / `dependencies` content authoring** → its own ticket. The codemod only scaffolds + `null`/`[]`; the `#### Core Drivers` prose exists in 55 files but doesn't map mechanically onto the 7 + fields. + +## Draft comment for Jacob (facet scope semantics) + +~~~~ +**Question on #858 / #869: what should a keyFeature *facet* match when the value is scoped?** + +Context: in v2, `keyFeatures.emissionsTrajectory` and `keyFeatures.policyAmbition` become arrays of +`{sector, geography, value}`. Both are also **search facets**. Today each is one scalar per pathway, so +"does this pathway match `Significant decrease`?" has one answer. In v2 it can have several. + +Take a pathway covering Power and Steel that holds: + +- `{sector: "Power", geography: "Global", value: "Significant decrease"}` +- `{sector: "Steel", geography: "Global", value: "Minor decrease"}` + +A user filters **sector = Steel** and ticks **emissionsTrajectory = Significant decrease**. + +We've implemented the scope-respecting reading: the pathway is **excluded**, because the only entry +whose scope contains "Steel" says `Minor decrease`. Containment is on both axes — an entry matches a +sector query if it's that sector or `"cross-sector"` (and, per your note on #869, `"cross-sector"` only +counts when the queried sector is one the pathway actually declares); geography matches when the +query's ISO set is a subset of the entry's. With no sector/geography filter active this degenerates to +"any entry matches", so today's behaviour is unchanged. + +The alternative would be to include the pathway whenever *any* entry has the ticked value, and let +ranking push the non-matching scope down. That never hides a pathway, but it does mean a facet can +return pathways whose value at the user's scope is different from what they ticked. + +**Two things to confirm:** + +1. Is the scope-respecting reading right — a facet filters on the value at the user's scope, and a + pathway holding that value only at some *other* scope is excluded? +2. When the user's scope has no entry at all and the chain falls back to a broader one, should the + facet match on the **fallback** value? (My assumption: yes — the fallback is what we display, so it + should be what we filter on. This is also where #869's cost model starts to matter for the facets, + not just for ranking.) + +For reference, #858 as written doesn't mention the facets, and these two fields are the only +keyFeatures that are also facets — so whatever we pick is a small, contained change. +~~~~ + +## Also worth flagging on #858 itself + +- `pathwayDescription`'s 2500 cap is right, but only because it applies to the `#### Pathway + Description` section — the whole `expertOverview` runs 4.4 KB median. Worth stating on the issue so + the follow-up PR doesn't mis-scope it. +- `#### Application to Transition Assessment` (all 56 files, median 1860 chars) has no destination in + #858's field list; this plan adds `transitionAssessment`. +- The `geography ∈ geographyItem values` bullet is wrong for author-defined region labels (finding 5). +- The cross-field "declared in the pathway's own sectors/geography" constraint can't live in draft-07; + it becomes a structural check in `schema-check-files.ts`. From 0d1313adef1c0e9db2a64a8e8801ca8663e1d386 Mon Sep 17 00:00:00 2001 From: repro Date: Thu, 13 Aug 2026 12:26:45 +0200 Subject: [PATCH 2/6] feat(schema): add pathwayMetadata.v2 with scoped keyFeatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for epic #860 (best-effort / inheritance search): each of the 11 keyFeatures fields becomes an array of {sector, geography, value} entries so a pathway can hold different values for different parts of its coverage, and the #869 resolver can serve the most specific value for a search scope and fall back to broader ones. Also adds coreDrivers, dependencies, pathwayDescription and transitionAssessment. Additive only. v1 stays present and loadable, PathwayMetadataType still points at v1, and no data file changes here — the loader is repointed at v2 in a later commit once data carries the v2 $schema. Nothing consumes v2 yet. --- public/schema/pathwayMetadata.v2.html | 26649 +++++++++++++++++++++ public/schema/scopeGeography.v2.html | 357 + public/schema/scopeSector.v2.html | 136 + scripts/schema-check-files.ts | 27 +- src/schema/common/index.ts | 9 + src/schema/common/scopeGeography.v2.json | 21 + src/schema/common/scopeSector.v2.json | 27 + src/schema/pathwayMetadata.v2.json | 608 + src/types/common/scopeGeography.v2.d.ts | 12 + src/types/common/scopeSector.v2.d.ts | 26 + src/types/index.ts | 13 +- src/types/pathwayMetadata.v2.d.ts | 506 + src/utils/validateScopes.test.ts | 224 + src/utils/validateScopes.ts | 129 + 14 files changed, 28742 insertions(+), 2 deletions(-) create mode 100644 public/schema/pathwayMetadata.v2.html create mode 100644 public/schema/scopeGeography.v2.html create mode 100644 public/schema/scopeSector.v2.html create mode 100644 src/schema/common/scopeGeography.v2.json create mode 100644 src/schema/common/scopeSector.v2.json create mode 100644 src/schema/pathwayMetadata.v2.json create mode 100644 src/types/common/scopeGeography.v2.d.ts create mode 100644 src/types/common/scopeSector.v2.d.ts create mode 100644 src/types/pathwayMetadata.v2.d.ts create mode 100644 src/utils/validateScopes.test.ts create mode 100644 src/utils/validateScopes.ts diff --git a/public/schema/pathwayMetadata.v2.html b/public/schema/pathwayMetadata.v2.html new file mode 100644 index 00000000..fd0fd215 --- /dev/null +++ b/public/schema/pathwayMetadata.v2.html @@ -0,0 +1,26649 @@ + + + + + + + + + + + + + Pathway Metadata + + +
+ + +
+ + +

Pathway Metadata

+ Type: object
+

+ A schema for the pathway metadata dataset in TPR. v2 of #858: each + keyFeatures field carries an array of {sector, geography, value} entries + instead of a bare value, so the #869 resolver can serve the most + specific value a pathway holds for a given search scope and fall back to + broader scopes. Also adds coreDrivers, dependencies, pathwayDescription + and transitionAssessment, and removes expertOverview and + pathwayOverview. v1 documents remain valid against + pathwayMetadata.v1.json; validateData routes each document by its own + $schema. +

+
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

+ URI of the schema that validates this document (see + https://json-schema.org/). +

+
+ +

+ Must be at most 1000 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

The unique identifier for a pathway.

+
+ +

+ Must be at most 100 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Label

+ Type: object
+

Name of the pathway.

+ + No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Full name or label

+ +

+ Must be at most 200 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Short name, acronym, or abbreviation

+
+ +

+ Must be at most 20 characters long +

+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Brief description of the pathway.

Must match regular expression: \.$ + +

+ Must be at most 100 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Publication

+ Type: object
+

Bibliographic information about the report or dataset.

+
+ + No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: object
+

Title of the report or publication.

+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

Optional subtitle of the publication.

+
+ +

+ Must be at most 300 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of string
+

List of authors or contributors.

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: string
+ +

+ Must be at most 100 characters + long +

+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+

+ Publisher or organization responsible for the + publication. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+

+ 😅 ERROR in schema generation, a referenced schema + could not be loaded, no documentation here + unfortunately 🏜️ +

+
+
+
+ +
+
+

+ +

+ +
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "ASEAN Centre for Energy" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "ACE" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Indonesia Just Energy Transition + Partnership Secretariat" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "JETP ID" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Center for Global Sustainability + and Institute for Essential Services + Reform" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "CGS, IESR" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Electricity and Renewable Energy + Authority in Viet Nam, Danish Energy + Agency" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "VN EREA, DEA" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "European Commission, Joint + Research Centre" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "JRC" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Institute for Sustainable Futures, + University of Technology + Sydney" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "UTS ISF" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "International Energy Agency" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "IEA" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Network of Central Banks and + Supervisors for Greening the + Financial System" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "NGFS" +
+
+
+
+
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ +
+

+ The following properties are required: +

+
    +
  • + short +
  • +
+
+
+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Philippines Department of + Energy" +
+
+
+
+
+
+ + Type: object
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "Sustainable Development Solutions + Network and ClimateWorks + Centre" +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "UN SDSN, CW" +
+
+
+
+
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ +
+

+ The following properties are required: +

+
    +
  • + short +
  • +
+
+
+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: const
+ Specific value: + "TransitionZero" +
+
+
+
+
+
+
+ +
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

Allowed full publisher names.

+
+ +
+

Must be one of:

+
    +
  • + "ASEAN Centre for Energy" +
  • +
  • + "Indonesia Just Energy Transition + Partnership Secretariat" +
  • +
  • + "Center for Global Sustainability and + Institute for Essential Services Reform" +
  • +
  • + "Electricity and Renewable Energy + Authority in Viet Nam, Danish Energy + Agency" +
  • +
  • + "European Commission, Joint Research + Centre" +
  • +
  • + "Institute for Sustainable Futures, + University of Technology Sydney" +
  • +
  • + "International Energy Agency" +
  • +
  • + "Network of Central Banks and + Supervisors for Greening the Financial + System" +
  • +
  • + "Philippines Department of Energy" +
  • +
  • + "Sustainable Development Solutions + Network and ClimateWorks Centre" +
  • +
  • + "TransitionZero" +
  • +
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

+ Allowed short publisher names or acronyms. +

+
+ +
+

Must be one of:

+
    +
  • + "ACE" +
  • +
  • + "IEA" +
  • +
  • + "CGS, IESR" +
  • +
  • + "JETP ID" +
  • +
  • + "JRC" +
  • +
  • + "NGFS" +
  • +
  • + "UN SDSN, CW" +
  • +
  • + "UTS ISF" +
  • +
  • + "VN EREA, DEA" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Year of publication.

+
+ +

+ Value must be greater or equal to 1900 and + lesser or equal to 2100 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Month of publication (1–12).

+
+ +

+ Value must be greater or equal to 1 and + lesser or equal to 12 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Day of publication (1–31).

+
+ +

+ Value must be greater or equal to 1 and + lesser or equal to 31 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

City where the publication was published.

+
+ +

+ Must be at most 100 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

+ Digital Object Identifier for the publication. +

Must match regular expression: + ^10\.[^\s/]+/.+$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

International Standard Book Number, if applicable.

+
+ +

+ Must be at most 32 characters long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

+ International Standard Serial Number, if applicable. +

Must match regular expression: + ^[0-9]{4}-[0-9]{3}[0-9X]$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

License under which the document is published

+
+ +

+ Must be at most 100 characters long +

+
+
+
+
+ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

Type of the pathway pathway.

+
+
+

Must be one of:

+
    +
  • "Normative"
  • +
  • "Exploratory"
  • +
  • "Predictive"
  • +
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

+ Year by which net zero is reached in the pathway. If Pathway + does not reach net zero, this field should be omitted. +

+
+ +

+ Value must be greater or equal to 2030 and lesser + or equal to 2100 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Year from which the model starts.

+
+ +

+ Value must be greater or equal to 1900 and lesser + or equal to 2030 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: integer
+

Year in which the model ends.

+
+ +

+ Value must be greater or equal to 2030 and lesser + or equal to 2100 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: number
+

+ Modeled temperature increase expected by the pathway (in degrees + Celsius). +

+
+ +

+ Value must be greater or equal to 0.5 and lesser + or equal to 3 and a multiple of + 0.1 +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Geography

+ Type: object
+

Geographical areas that the pathway covers.

+
+ + No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: boolean
+

True if the pathway covers the entire world.

+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+

+ Map from an author-defined region label (as named in the + publication) to the ISO-3166-1 alpha-2 country codes in + that region. Member arrays may be empty when the + publication does not provide a mapping. +

+
+ +
+
+
+

+ +

+
+ +
+
+

+ Each additional property must conform to the + following schema +

+ + + Type: array
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+

+ 😅 ERROR in schema generation, a referenced + schema could not be loaded, no documentation + here unfortunately 🏜️ +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array
+

+ Standalone ISO-3166-1 alpha-2 country codes covered by + the pathway. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+

+ 😅 ERROR in schema generation, a referenced schema + could not be loaded, no documentation here + unfortunately 🏜️ +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

Sectors that the pathway covers.

+
+ + No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

Display name of a sector.

+
+ +
+

Must be one of:

+
    +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • "Steel"
  • +
  • "Cement"
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • "Power"
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • "Rail"
  • +
  • + "Shipping" +
  • +
  • "Other"
  • +
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array
+

Technologies applicable to this sector.

+
+ + No Additional Items +

Each item of this array must be:

+
+
+ + Type: enum (of string)
+

+ Display name of the technology as presented in + charts or tables. +

+
+ +
+

Must be one of:

+
    +
  • + "Precision Agriculture" +
  • +
  • + "Agroforestry" +
  • +
  • + "Bioenergy Crops" +
  • +
  • + "Energy Efficiency" +
  • +
  • + "Smart Grids" +
  • +
  • + "Renewable Heating" +
  • +
  • + "Heat Pumps" +
  • +
  • + "Building Automation" +
  • +
  • + "Smart Appliances" +
  • +
  • + "Insulation" +
  • +
  • + "Carbon Capture and Storage" +
  • +
  • + "Electrification" +
  • +
  • + "Process Optimization" +
  • +
  • + "Hydrogen Use" +
  • +
  • + "Coal" +
  • +
  • "Oil"
  • +
  • "Gas"
  • +
  • + "Wind" +
  • +
  • + "Solar" +
  • +
  • + "Nuclear" +
  • +
  • + "Biomass" +
  • +
  • + "Hydro" +
  • +
  • + "Renewables" +
  • +
  • + "Electric Vehicles" +
  • +
  • + "Hydrogen Vehicles" +
  • +
  • + "Biofuels" +
  • +
  • + "Public Transport" +
  • +
  • + "Active Mobility" +
  • +
  • + "Aviation Efficiency" +
  • +
  • + "Maritime Efficiency" +
  • +
  • + "Other" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Narrative description of the pathway. Replaces v1's + expertOverview and pathwayOverview. In the v1 corpus this is the + '#### Pathway Description' section of expertOverview; null means + no description is available. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ How the pathway can be applied to transition assessment. In the + v1 corpus this is the '#### Application to Transition + Assessment' section of expertOverview; null means no guidance is + available. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: enum (of string)
+

Display name of the metric

+
+ +
+

Must be one of:

+
    +
  • + "Emissions Intensity" +
  • +
  • "Capacity"
  • +
  • "Generation"
  • +
  • "Technology Mix"
  • +
  • + "Absolute Emissions" +
  • +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+
+

+ Key features of the pathway. Every field is an array of {sector, + geography, value} entries (#858), so a pathway can hold + different values for different parts of its coverage. A + non-varying feature carries exactly one entry at the widest + applicable scope: sector 'cross-sector' for a multi-sector + pathway else its lone sector, and geography 'Global' else the + pathway's widest declared region or country. An entry that is + absent at some scope means the #869 resolver keeps broadening + until it finds one; an explicit "No information" value is a real + authored value that terminates that fallback chain and displays + at its own scope. An empty array means nothing is authored at + any scope. +

+
+
+ +
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Describes the overall trend of greenhouse gas emissions + over time, from continued growth to rapid decline. + Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Significant increase" +
  • +
  • + "Moderate increase" +
  • +
  • + "Minor increase" +
  • +
  • + "Low or no change" +
  • +
  • + "Minor decrease" +
  • +
  • + "Moderate decrease" +
  • +
  • + "Significant decrease" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Indicates how efficiently energy is used to produce + economic output across the sectors covered in the + pathway. Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Significant deterioration" +
  • +
  • + "Moderate deterioration" +
  • +
  • + "Minor deterioration" +
  • +
  • + "Low or no change" +
  • +
  • + "Minor improvement" +
  • +
  • + "Moderate improvement" +
  • +
  • + "Significant improvement" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Captures the change in total energy consumption, driven + by factors such as socio-economic development, + technology shifts and consumer behavior. Scoped: see + keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Significant decrease" +
  • +
  • + "Moderate decrease" +
  • +
  • + "Minor decrease" +
  • +
  • + "Low or no change" +
  • +
  • + "Minor increase" +
  • +
  • + "Moderate increase" +
  • +
  • + "Significant increase" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Represents the extent to which energy end-uses + transition from fossil fuels to electricity. Scoped: see + keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Significant decrease" +
  • +
  • + "Moderate decrease" +
  • +
  • + "Minor decrease" +
  • +
  • + "Low or no change" +
  • +
  • + "Minor increase" +
  • +
  • + "Moderate increase" +
  • +
  • + "Significant increase" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Identifies the types of policies modeled as drivers of + the pathway, such as carbon pricing, subsidies, or + mandated phaseouts of specific technologies. Scoped: see + keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of enum (of string)
+ +

+ Must contain a minimum of + 1 items +

+

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Carbon price" +
  • +
  • + "Feed-in tariffs" +
  • +
  • + "Performance standards" +
  • +
  • + "Phaseout dates" +
  • +
  • + "Subsidies" +
  • +
  • + "Target technology shares" +
  • +
  • + "Other" +
  • +
  • + "None" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Describes how technology costs evolve over time, from + static cost assumptions to rapidly declining costs + (e.g., via learning curves). Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Increase" +
  • +
  • + "Low or no change" +
  • +
  • + "Decrease" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Defines which greenhouse gases are covered in the + pathway's modeled emissions. Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Emissions Scope

+ Type: enum (of string)
+

+ Defines which greenhouse gases are covered + in the pathway's modeled emissions. +

+
+ +
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "CO2" +
  • +
  • + "CO2e (Kyoto)" +
  • +
  • + "CO2e (CO2, Methane)" +
  • +
  • + "CO2e (unspecified GHGs)" +
  • +
  • + "Other emissions scope" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Represents the overall stringency and intent of modeled + policies relative to climate targets, often reflecting + if and how far the included policies go beyond currently + legislated ones Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "No policies included" +
  • +
  • + "Current/legislated policies" +
  • +
  • + "Current and drafted policies" +
  • +
  • + "NDCs, unconditional only" +
  • +
  • + "NDCs incl. conditional targets" +
  • +
  • + "High ambition policies" +
  • +
  • + "Other policy ambition" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Specifies the level of granularity in cost data, such as + total system costs or detailed CAPEX/OPEX breakdowns. + Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Total costs" +
  • +
  • + "Capital costs, O&M, etc." +
  • +
  • + "Other cost breakdown" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Lists emerging or breakthrough technologies that are + explicitly modeled within the pathway. These are + considered in technology deployment too. Scoped: see + keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of enum (of string)
+ +

+ Must contain a minimum of + 1 items +

+

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "No new technologies" +
  • +
  • + "CCUS" +
  • +
  • + "DAC" +
  • +
  • + "Green H2/ammonia" +
  • +
  • + "SAF" +
  • +
  • + "Battery storage" +
  • +
  • + "EGS/AGS" +
  • +
  • + "Other new technologies" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Summarizes how investment requirements are quantified, + from total system to sector-level or supply-chain + detail. Scoped: see keyFeatures. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures + entry: one of the sector display names, or + the widest sentinel 'cross-sector'. +

+
+ +
+

Must be one of:

+
    +
  • + "cross-sector" +
  • +
  • + "Land Use" +
  • +
  • + "Agriculture" +
  • +
  • + "Buildings" +
  • +
  • + "Steel" +
  • +
  • + "Cement" +
  • +
  • + "Chemicals" +
  • +
  • + "Coal Mining" +
  • +
  • + "Oil (Upstream)" +
  • +
  • + "Gas (Upstream)" +
  • +
  • + "Power" +
  • +
  • + "Automotive" +
  • +
  • + "Aviation" +
  • +
  • + "Rail" +
  • +
  • + "Shipping" +
  • +
  • + "Other" +
  • +
+
+ +
+
+ Examples: +
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ +

Scope Geography

+ Type: string
+

+ The geography axis of a scoped keyFeatures + entry: 'Global', 'cross-region', an + ISO-3166-1 alpha-2 country code, or an + author-defined region label. +

+
+ +
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: + ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters + long +

+ +
+
+ Examples: +
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+
+

Must be one of:

+
    +
  • + "No information" +
  • +
  • + "Total investment" +
  • +
  • + "By sector" +
  • +
  • + "By sector, part of value chain" +
  • +
  • + "By technology" +
  • +
  • + "By tech, part of value chain" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: object
+

+ The drivers that shape this pathway's outcomes. Every field is + required but nullable: null means the driver is not a core + driver for this pathway, as distinct from a driver that is + present but undescribed. +

+
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Policies modeled as a driver of this pathway. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Emissions targets or constraints driving this pathway. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Technology cost assumptions driving this pathway. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

Changes in investment driving this pathway.

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Macroeconomic assumptions driving this pathway. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

+ Behavioral or demand-side shifts driving this pathway. +

Must match regular expression: \.$ +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string or null
+

Any other core driver of this pathway.

Must match regular expression: \.$ +
+
+
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: array of object
+

+ Conditions the pathway's outcomes depend on. Descriptive only -- + deliberately NOT part of the #869 inheritance chain, so these + are not scoped by geography. +

+
+ +

+ All items must be unique +

+ No Additional Items +

Each item of this array must be:

+
+
+ + Type: object
+ No Additional Properties + +
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

Category of the dependency.

+
+
+

Must be one of:

+
    +
  • + "Policy strategy" +
  • +
  • + "Regulatory framework" +
  • +
  • + "Market and economics" +
  • +
  • + "Public acceptance" +
  • +
  • + "Consumer and client behavior" +
  • +
  • + "Infrastructure and logistics" +
  • +
  • + "Technology" +
  • +
  • + "Resource availability" +
  • +
  • + "Environmental impacts and ecosystem services" +
  • +
  • + "Labor availability" +
  • +
+
+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: string
+

What the pathway depends on, in prose.

Must match regular expression: \.$ + +

+ Must be at most 500 characters + long +

+
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

+ Sector the dependency applies to. Must be one of the + pathway's own declared sectors -- enforced by + scripts/schema-check-files.ts, since draft-07 cannot + reference sibling data. +

Same definition as name +
+
+
+
+
+
+
+

+ +

+
+ +
+
+ + Type: enum (of string)
+

Strength of the evidence for this dependency.

+
+
+

Must be one of:

+
    +
  • + "Quantitative" +
  • +
  • + "Qualitative" +
  • +
  • + "Anecdotal" +
  • +
  • + "No evidence" +
  • +
+
+
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/public/schema/scopeGeography.v2.html b/public/schema/scopeGeography.v2.html new file mode 100644 index 00000000..3c6c83d4 --- /dev/null +++ b/public/schema/scopeGeography.v2.html @@ -0,0 +1,357 @@ + + + + + + + + + + + + + Scope Geography + + +
+ + +
+ + +

Scope Geography

+
+

+ The geography axis of a scoped keyFeatures entry: 'Global', + 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined + region label. +

+
+
+

+ +

+ +
+
+ + Type: object
+ Must match regular expression: ^(?!\s*$).+ +
+
+ +
+
+

Must not be:

+
+
+ + Type: object
+ Must match regular expression: + ^[A-Za-z]{3}$ +
+
+
+
+
+
+ +

+ Must be at least 1 characters long +

+ +
+
Examples:
+
+
+
+
"Global"
+
+
+
+
+
+
"cross-region"
+
+
+
+
+
+
"South East Asia"
+
+
+
+
+
+
"Asia Pacific"
+
+
+
+
+
+
"TH"
+
+
+
+
+
+
"US"
+
+
+
+ + + + diff --git a/public/schema/scopeSector.v2.html b/public/schema/scopeSector.v2.html new file mode 100644 index 00000000..1cb72ca3 --- /dev/null +++ b/public/schema/scopeSector.v2.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + Scope Sector + + +
+ + +
+ + +

Scope Sector

+ Type: enum (of string)
+

+ The sector axis of a scoped keyFeatures entry: one of the sector display + names, or the widest sentinel 'cross-sector'. +

+
+
+

Must be one of:

+
    +
  • "cross-sector"
  • +
  • "Land Use"
  • +
  • "Agriculture"
  • +
  • "Buildings"
  • +
  • "Steel"
  • +
  • "Cement"
  • +
  • "Chemicals"
  • +
  • "Coal Mining"
  • +
  • "Oil (Upstream)"
  • +
  • "Gas (Upstream)"
  • +
  • "Power"
  • +
  • "Automotive"
  • +
  • "Aviation"
  • +
  • "Rail"
  • +
  • "Shipping"
  • +
  • "Other"
  • +
+
+ +
+
Examples:
+
+
+
+
"cross-sector"
+
+
+
+
+
+
"Power"
+
+
+
+
+
+
"Steel"
+
+
+
+ + + + diff --git a/scripts/schema-check-files.ts b/scripts/schema-check-files.ts index d079d859..223a6242 100644 --- a/scripts/schema-check-files.ts +++ b/scripts/schema-check-files.ts @@ -5,8 +5,14 @@ import type { FileEntry } from "../src/utils/validateData.ts"; import { validateFilesBySchema } from "../src/utils/validateData.ts"; import { decideIncludeInvalid } from "../src/utils/loadData.ts"; import pathwayMetadata from "../src/schema/pathwayMetadata.v1.json" with { type: "json" }; +import pathwayMetadataV2 from "../src/schema/pathwayMetadata.v2.json" with { type: "json" }; import pathwayTimeseries from "../src/schema/pathwayTimeseries.v1.json" with { type: "json" }; import { commonSchemas } from "../src/schema/common/index.ts"; +import type { PathwayMetadataV2 } from "../src/types/pathwayMetadata.v2.d.ts"; +import { + PATHWAY_METADATA_V2_ID, + validateScopedEntries, +} from "../src/utils/validateScopes.ts"; async function run(dir: string) { async function getJsonFilesRecursive(base: string): Promise { @@ -34,10 +40,29 @@ async function run(dir: string) { const { valid, invalid } = validateFilesBySchema(entries, [ pathwayMetadata, + pathwayMetadataV2, pathwayTimeseries, ...commonSchemas, ]); - return { dir, validCount: valid.length, invalid }; + + // Second pass over the AJV-valid v2 documents for the cross-field scope + // constraint draft-07 cannot express — see src/utils/validateScopes.ts. A + // document that fails here is reported exactly like a schema failure, so a + // mistyped region label breaks the build instead of silently matching nothing. + const scopeProblems = valid + .filter((r) => r.schemaId === PATHWAY_METADATA_V2_ID) + .map((r) => ({ + name: r.name, + errors: validateScopedEntries(r.data as PathwayMetadataV2), + })) + .filter((p) => p.errors.length > 0); + + const badNames = new Set(scopeProblems.map((p) => p.name)); + return { + dir, + validCount: valid.filter((r) => !badNames.has(r.name)).length, + invalid: [...invalid, ...scopeProblems], + }; } async function main() { diff --git a/src/schema/common/index.ts b/src/schema/common/index.ts index d7b9debc..97058d19 100644 --- a/src/schema/common/index.ts +++ b/src/schema/common/index.ts @@ -28,6 +28,13 @@ export const geographySchema: SchemaObject = geographySchemaJson; import emissionsScopeSchemaJson from "./emissionsScope.v1.json" with { type: "json" }; export const emissionsScopeSchema: SchemaObject = emissionsScopeSchemaJson; +// The two axes of a v2 scoped keyFeatures entry (#858). +import scopeSectorSchemaJson from "./scopeSector.v2.json" with { type: "json" }; +export const scopeSectorSchema: SchemaObject = scopeSectorSchemaJson; + +import scopeGeographySchemaJson from "./scopeGeography.v2.json" with { type: "json" }; +export const scopeGeographySchema: SchemaObject = scopeGeographySchemaJson; + // Aggregate — type stays correct export const commonSchemas: SchemaObject[] = [ publicationSchema, @@ -39,6 +46,8 @@ export const commonSchemas: SchemaObject[] = [ countryCodeSchema, geographySchema, emissionsScopeSchema, + scopeSectorSchema, + scopeGeographySchema, ]; export default commonSchemas; diff --git a/src/schema/common/scopeGeography.v2.json b/src/schema/common/scopeGeography.v2.json new file mode 100644 index 00000000..e945ca29 --- /dev/null +++ b/src/schema/common/scopeGeography.v2.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "title": "Scope Geography", + "description": "The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label.", + "$comment": "Deliberately an open string rather than an enum. Region labels are free-form by design -- geography.v1's 'regions' keys are kept as named in the source publication -- so a closed list could not express labels this repo already uses, e.g. IEA's 'Asia Pacific', 'Eurasia', 'Middle East', 'Central and South America', none of which appear in geographyItem.v1's enum. (#858's scope text says geographyItem, which would reject any finer-scope entry on an IEA pathway.) 'Global' is the widest sentinel; 'cross-region' is reserved for a multi-region non-global aggregate. Because any non-blank string validates here, the real constraint -- that the value is 'Global', 'cross-region', or a region/country the pathway declares in its own 'geography' -- is enforced by validateScopedEntries in src/utils/validateScopes.ts, run from scripts/schema-check-files.ts. That check is what catches a mistyped label.", + "type": "string", + "minLength": 1, + "allOf": [ + { "pattern": "^(?!\\s*$).+" }, + { "not": { "pattern": "^[A-Za-z]{3}$" } } + ], + "examples": [ + "Global", + "cross-region", + "South East Asia", + "Asia Pacific", + "TH", + "US" + ] +} diff --git a/src/schema/common/scopeSector.v2.json b/src/schema/common/scopeSector.v2.json new file mode 100644 index 00000000..f6068dad --- /dev/null +++ b/src/schema/common/scopeSector.v2.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "title": "Scope Sector", + "description": "The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'.", + "$comment": "Members mirror sector.v1.json#/$defs/displayName, plus the 'cross-sector' sentinel. 'cross-sector' means the union of THIS pathway's own declared sectors, not a universal match: an entry scoped 'cross-sector' covers a query for sector X only when X is one of the sectors the pathway declares in its own 'sectors' array. So a pathway covering only Y and Z is NOT a match for a sector-X search, even though its 'cross-sector' values would otherwise be broad enough. That constraint spans sibling data with dynamic keys, which draft-07 cannot express and AJV's $data cannot compute a union for, so it is enforced by validateScopedEntries in src/utils/validateScopes.ts, run from scripts/schema-check-files.ts.", + "type": "string", + "enum": [ + "cross-sector", + "Land Use", + "Agriculture", + "Buildings", + "Steel", + "Cement", + "Chemicals", + "Coal Mining", + "Oil (Upstream)", + "Gas (Upstream)", + "Power", + "Automotive", + "Aviation", + "Rail", + "Shipping", + "Other" + ], + "examples": ["cross-sector", "Power", "Steel"] +} diff --git a/src/schema/pathwayMetadata.v2.json b/src/schema/pathwayMetadata.v2.json new file mode 100644 index 00000000..b24377c5 --- /dev/null +++ b/src/schema/pathwayMetadata.v2.json @@ -0,0 +1,608 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", + "title": "Pathway Metadata", + "description": "A schema for the pathway metadata dataset in TPR.", + "type": "object", + "properties": { + "$schema": { + "description": "URI of the schema that validates this document (see https://json-schema.org/).", + "type": "string", + "maxLength": 1000 + }, + "id": { + "description": "The unique identifier for a pathway.", + "type": "string", + "maxLength": 100 + }, + "name": { + "description": "Name of the pathway.", + "$ref": "http://pathways.rmi.org/schema/common/label.v1.json", + "tsType": "import('./common/label.v1').LabelV1" + }, + "description": { + "description": "Brief description of the pathway.", + "type": "string", + "pattern": "\\.$", + "maxLength": 100 + }, + "publication": { + "description": "Bibliographic information about the report or dataset.", + "$ref": "http://pathways.rmi.org/schema/common/publication.v1.json", + "tsType": "import('./common/publication.v1').PublicationV1" + }, + "pathwayType": { + "description": "Type of the pathway pathway.", + "type": "string", + "enum": ["Normative", "Exploratory", "Predictive"] + }, + "modelYearNetzero": { + "description": "Year by which net zero is reached in the pathway. If Pathway does not reach net zero, this field should be omitted.", + "type": "integer", + "minimum": 2030, + "maximum": 2100 + }, + "modelYearStart": { + "description": "Year from which the model starts.", + "type": "integer", + "minimum": 1900, + "maximum": 2030 + }, + "modelYearEnd": { + "description": "Year in which the model ends.", + "type": "integer", + "minimum": 2030, + "maximum": 2100 + }, + "modelTempIncrease": { + "description": "Modeled temperature increase expected by the pathway (in degrees Celsius).", + "type": "number", + "multipleOf": 0.1, + "minimum": 0.5, + "maximum": 3 + }, + "geography": { + "description": "Geographical areas that the pathway covers.", + "$ref": "http://pathways.rmi.org/schema/common/geography.v1.json", + "tsType": "import('./common/geography.v1').GeographyV1" + }, + "sectors": { + "description": "Sectors that the pathway covers.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "$ref": "http://pathways.rmi.org/schema/common/sector.v1.json#/$defs/displayName" + }, + "technologies": { + "type": "array", + "description": "Technologies applicable to this sector.", + "items": { + "$ref": "http://pathways.rmi.org/schema/common/technology.v1.json#/$defs/displayName" + } + } + }, + "required": ["name", "technologies"], + "additionalProperties": false + } + }, + "pathwayDescription": { + "description": "Narrative description of the pathway. Replaces v1's expertOverview and pathwayOverview. In the v1 corpus this is the '#### Pathway Description' section of expertOverview; null means no description is available.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 2500 + }, + "transitionAssessment": { + "description": "How the pathway can be applied to transition assessment. In the v1 corpus this is the '#### Application to Transition Assessment' section of expertOverview; null means no guidance is available.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 2500 + }, + "metric": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "http://pathways.rmi.org/schema/common/metric.v1.json#/$defs/displayName", + "tsType": "import('./common/metric.v1').MetricV1['displayName']" + } + }, + "keyFeatures": { + "description": "Key features of the pathway. Every field is an array of {sector, geography, value} entries (#858), so a pathway can hold different values for different parts of its coverage. A non-varying feature carries exactly one entry at the widest applicable scope: sector 'cross-sector' for a multi-sector pathway else its lone sector, and geography 'Global' else the pathway's widest declared region or country. An entry that is absent at some scope means the resolver keeps broadening until it finds one; an explicit \"No information\" value is a real authored value that terminates that fallback chain and displays at its own scope. An empty array means nothing is authored at any scope.", + "type": "object", + "properties": { + "emissionsTrajectory": { + "description": "Describes the overall trend of greenhouse gas emissions over time, from continued growth to rapid decline. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Significant increase", + "Moderate increase", + "Minor increase", + "Low or no change", + "Minor decrease", + "Moderate decrease", + "Significant decrease" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "energyEfficiency": { + "description": "Indicates how efficiently energy is used to produce economic output across the sectors covered in the pathway. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Significant deterioration", + "Moderate deterioration", + "Minor deterioration", + "Low or no change", + "Minor improvement", + "Moderate improvement", + "Significant improvement" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "energyDemand": { + "description": "Captures the change in total energy consumption, driven by factors such as socio-economic development, technology shifts and consumer behavior. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Significant decrease", + "Moderate decrease", + "Minor decrease", + "Low or no change", + "Minor increase", + "Moderate increase", + "Significant increase" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "electrification": { + "description": "Represents the extent to which energy end-uses transition from fossil fuels to electricity. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Significant decrease", + "Moderate decrease", + "Minor decrease", + "Low or no change", + "Minor increase", + "Moderate increase", + "Significant increase" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "policyTypes": { + "description": "Identifies the types of policies modeled as drivers of the pathway, such as carbon pricing, subsidies, or mandated phaseouts of specific technologies. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "No information", + "Carbon price", + "Feed-in tariffs", + "Performance standards", + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Other", + "None" + ] + } + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "technologyCostTrend": { + "description": "Describes how technology costs evolve over time, from static cost assumptions to rapidly declining costs (e.g., via learning curves). Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Increase", + "Low or no change", + "Decrease" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "emissionsScope": { + "description": "Defines which greenhouse gases are covered in the pathway's modeled emissions. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "$ref": "http://pathways.rmi.org/schema/common/emissionsScope.v1.json", + "tsType": "import('./common/emissionsScope.v1').EmissionsScopeV1" + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "policyAmbition": { + "description": "Represents the overall stringency and intent of modeled policies relative to climate targets, often reflecting if and how far the included policies go beyond currently legislated ones Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "No policies included", + "Current/legislated policies", + "Current and drafted policies", + "NDCs, unconditional only", + "NDCs incl. conditional targets", + "High ambition policies", + "Other policy ambition" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "technologyCostsDetail": { + "description": "Specifies the level of granularity in cost data, such as total system costs or detailed CAPEX/OPEX breakdowns. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Total costs", + "Capital costs, O&M, etc.", + "Other cost breakdown" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "newTechnologiesIncluded": { + "description": "Lists emerging or breakthrough technologies that are explicitly modeled within the pathway. These are considered in technology deployment too. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "No information", + "No new technologies", + "CCUS", + "DAC", + "Green H2/ammonia", + "SAF", + "Battery storage", + "EGS/AGS", + "Other new technologies" + ] + } + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + }, + "investmentNeeds": { + "description": "Summarizes how investment requirements are quantified, from total system to sector-level or supply-chain detail. Scoped: see keyFeatures.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "sector": { + "$ref": "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + "tsType": "import('./common/scopeSector.v2').ScopeSectorV2" + }, + "geography": { + "$ref": "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + "tsType": "import('./common/scopeGeography.v2').ScopeGeographyV2" + }, + "value": { + "type": "string", + "enum": [ + "No information", + "Total investment", + "By sector", + "By sector, part of value chain", + "By technology", + "By tech, part of value chain" + ] + } + }, + "required": ["sector", "geography", "value"], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "required": [ + "emissionsTrajectory", + "energyEfficiency", + "energyDemand", + "electrification", + "policyTypes", + "technologyCostTrend", + "emissionsScope", + "policyAmbition", + "technologyCostsDetail", + "newTechnologiesIncluded", + "investmentNeeds" + ] + }, + "coreDrivers": { + "description": "The drivers that shape this pathway's outcomes. Every field is required but nullable: null means the driver is not a core driver for this pathway, as distinct from a driver that is present but undescribed.", + "type": "object", + "properties": { + "policies": { + "description": "Policies modeled as a driver of this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "emissionsTargets": { + "description": "Emissions targets or constraints driving this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "technologyCosts": { + "description": "Technology cost assumptions driving this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "investmentChange": { + "description": "Changes in investment driving this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "macroeconomicDrivers": { + "description": "Macroeconomic assumptions driving this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "behavioralShifts": { + "description": "Behavioral or demand-side shifts driving this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + }, + "otherDrivers": { + "description": "Any other core driver of this pathway.", + "type": ["string", "null"], + "pattern": "\\.$", + "maxLength": 500 + } + }, + "additionalProperties": false, + "required": [ + "policies", + "emissionsTargets", + "technologyCosts", + "investmentChange", + "macroeconomicDrivers", + "behavioralShifts", + "otherDrivers" + ] + }, + "dependencies": { + "description": "Conditions the pathway's outcomes depend on. Descriptive only -- deliberately NOT part of the #869 inheritance chain, so these are not scoped by geography.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "dependency_name": { + "description": "Category of the dependency.", + "type": "string", + "enum": [ + "Policy strategy", + "Regulatory framework", + "Market and economics", + "Public acceptance", + "Consumer and client behavior", + "Infrastructure and logistics", + "Technology", + "Resource availability", + "Environmental impacts and ecosystem services", + "Labor availability" + ] + }, + "dependency_description": { + "description": "What the pathway depends on, in prose.", + "type": "string", + "pattern": "\\.$", + "maxLength": 500 + }, + "sector": { + "description": "Sector the dependency applies to. Must be one of the pathway's own declared sectors -- enforced by scripts/schema-check-files.ts, since draft-07 cannot reference sibling data.", + "$ref": "http://pathways.rmi.org/schema/common/sector.v1.json#/$defs/displayName" + }, + "evidence_type": { + "description": "Strength of the evidence for this dependency.", + "type": "string", + "enum": ["Quantitative", "Qualitative", "Anecdotal", "No evidence"] + } + }, + "required": [ + "dependency_name", + "dependency_description", + "sector", + "evidence_type" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "publication", + "pathwayType", + "geography", + "sectors", + "pathwayDescription", + "metric", + "keyFeatures", + "coreDrivers", + "dependencies" + ] +} diff --git a/src/types/common/scopeGeography.v2.d.ts b/src/types/common/scopeGeography.v2.d.ts new file mode 100644 index 00000000..c32db0cf --- /dev/null +++ b/src/types/common/scopeGeography.v2.d.ts @@ -0,0 +1,12 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeographyV2 = { + [k: string]: unknown; +} & string; diff --git a/src/types/common/scopeSector.v2.d.ts b/src/types/common/scopeSector.v2.d.ts new file mode 100644 index 00000000..d124f829 --- /dev/null +++ b/src/types/common/scopeSector.v2.d.ts @@ -0,0 +1,26 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSectorV2 = + | "cross-sector" + | "Land Use" + | "Agriculture" + | "Buildings" + | "Steel" + | "Cement" + | "Chemicals" + | "Coal Mining" + | "Oil (Upstream)" + | "Gas (Upstream)" + | "Power" + | "Automotive" + | "Aviation" + | "Rail" + | "Shipping" + | "Other"; diff --git a/src/types/index.ts b/src/types/index.ts index b4f56054..5b40a688 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,12 +1,23 @@ import type { FacetMode } from "../utils/searchUtils"; import type { PathwayMetadataV1 } from "./pathwayMetadata.v1"; +import type { PathwayMetadataV2 } from "./pathwayMetadata.v2"; import type { PublicationV1 } from "./common/publication.v1"; import type { GeographyV1 } from "./common/geography.v1"; -// Re-export the (current) versioned pathway metadata type as generic +// Re-export the (current) versioned pathway metadata type as generic. +// Still v1: #858 lands the v2 schema and types first, and the loader is +// repointed at v2 in a later commit once data files carry the v2 $schema. export type PathwayMetadataType = PathwayMetadataV1; export type PublicationType = PublicationV1; +// Both versions are exported for the migration window. v1 and v2 documents +// coexist in src/data — validateData routes each by its own $schema $id. +export type { PathwayMetadataV1, PathwayMetadataV2 }; + +/** A single scoped keyFeatures entry: {sector, geography, value} (#858). */ +export type ScopedKeyFeature = + PathwayMetadataV2["keyFeatures"][K][number]; + // Enum-like types derived from the schema export type PathwayType = PathwayMetadataType["pathwayType"]; export type Sector = PathwayMetadataType["sectors"][number]["name"]; diff --git a/src/types/pathwayMetadata.v2.d.ts b/src/types/pathwayMetadata.v2.d.ts new file mode 100644 index 00000000..306f10ad --- /dev/null +++ b/src/types/pathwayMetadata.v2.d.ts @@ -0,0 +1,506 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Name of the pathway. + */ +export type Label = import("./common/label.v1").LabelV1; +/** + * Bibliographic information about the report or dataset. + */ +export type Publication = import("./common/publication.v1").PublicationV1; +/** + * Geographical areas that the pathway covers. + */ +export type Geography = import("./common/geography.v1").GeographyV1; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector1 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography1 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector2 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography2 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector3 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography3 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector4 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography4 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector5 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography5 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector6 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography6 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * Defines which greenhouse gases are covered in the pathway's modeled emissions. + */ +export type EmissionsScope = + import("./common/emissionsScope.v1").EmissionsScopeV1; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector7 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography7 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector8 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography8 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector9 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography9 = + import("./common/scopeGeography.v2").ScopeGeographyV2; +/** + * The sector axis of a scoped keyFeatures entry: one of the sector display names, or the widest sentinel 'cross-sector'. + */ +export type ScopeSector10 = import("./common/scopeSector.v2").ScopeSectorV2; +/** + * The geography axis of a scoped keyFeatures entry: 'Global', 'cross-region', an ISO-3166-1 alpha-2 country code, or an author-defined region label. + */ +export type ScopeGeography10 = + import("./common/scopeGeography.v2").ScopeGeographyV2; + +/** + * A schema for the pathway metadata dataset in TPR. v2 of #858: each keyFeatures field carries an array of {sector, geography, value} entries instead of a bare value, so the #869 resolver can serve the most specific value a pathway holds for a given search scope and fall back to broader scopes. Also adds coreDrivers, dependencies, pathwayDescription and transitionAssessment, and removes expertOverview and pathwayOverview. v1 documents remain valid against pathwayMetadata.v1.json; validateData routes each document by its own $schema. + */ +export interface PathwayMetadataV2 { + /** + * URI of the schema that validates this document (see https://json-schema.org/). + */ + $schema?: string; + /** + * The unique identifier for a pathway. + */ + id: string; + name: Label; + /** + * Brief description of the pathway. + */ + description: string; + publication: Publication; + /** + * Type of the pathway pathway. + */ + pathwayType: "Normative" | "Exploratory" | "Predictive"; + /** + * Year by which net zero is reached in the pathway. If Pathway does not reach net zero, this field should be omitted. + */ + modelYearNetzero?: number; + /** + * Year from which the model starts. + */ + modelYearStart?: number; + /** + * Year in which the model ends. + */ + modelYearEnd?: number; + /** + * Modeled temperature increase expected by the pathway (in degrees Celsius). + */ + modelTempIncrease?: number; + geography: Geography; + /** + * Sectors that the pathway covers. + */ + sectors: { + /** + * Display name of a sector. + */ + name: + | "Land Use" + | "Agriculture" + | "Buildings" + | "Steel" + | "Cement" + | "Chemicals" + | "Coal Mining" + | "Oil (Upstream)" + | "Gas (Upstream)" + | "Power" + | "Automotive" + | "Aviation" + | "Rail" + | "Shipping" + | "Other"; + /** + * Technologies applicable to this sector. + */ + technologies: ( + | "Precision Agriculture" + | "Agroforestry" + | "Bioenergy Crops" + | "Energy Efficiency" + | "Smart Grids" + | "Renewable Heating" + | "Heat Pumps" + | "Building Automation" + | "Smart Appliances" + | "Insulation" + | "Carbon Capture and Storage" + | "Electrification" + | "Process Optimization" + | "Hydrogen Use" + | "Coal" + | "Oil" + | "Gas" + | "Wind" + | "Solar" + | "Nuclear" + | "Biomass" + | "Hydro" + | "Renewables" + | "Electric Vehicles" + | "Hydrogen Vehicles" + | "Biofuels" + | "Public Transport" + | "Active Mobility" + | "Aviation Efficiency" + | "Maritime Efficiency" + | "Other" + )[]; + }[]; + /** + * Narrative description of the pathway. Replaces v1's expertOverview and pathwayOverview. In the v1 corpus this is the '#### Pathway Description' section of expertOverview; null means no description is available. + */ + pathwayDescription: string | null; + /** + * How the pathway can be applied to transition assessment. In the v1 corpus this is the '#### Application to Transition Assessment' section of expertOverview; null means no guidance is available. + */ + transitionAssessment?: string | null; + metric: import("./common/metric.v1").MetricV1["displayName"][]; + /** + * Key features of the pathway. Every field is an array of {sector, geography, value} entries (#858), so a pathway can hold different values for different parts of its coverage. A non-varying feature carries exactly one entry at the widest applicable scope: sector 'cross-sector' for a multi-sector pathway else its lone sector, and geography 'Global' else the pathway's widest declared region or country. An entry that is absent at some scope means the #869 resolver keeps broadening until it finds one; an explicit "No information" value is a real authored value that terminates that fallback chain and displays at its own scope. An empty array means nothing is authored at any scope. + */ + keyFeatures: { + /** + * Describes the overall trend of greenhouse gas emissions over time, from continued growth to rapid decline. Scoped: see keyFeatures. + */ + emissionsTrajectory: { + sector: ScopeSector; + geography: ScopeGeography; + value: + | "No information" + | "Significant increase" + | "Moderate increase" + | "Minor increase" + | "Low or no change" + | "Minor decrease" + | "Moderate decrease" + | "Significant decrease"; + }[]; + /** + * Indicates how efficiently energy is used to produce economic output across the sectors covered in the pathway. Scoped: see keyFeatures. + */ + energyEfficiency: { + sector: ScopeSector1; + geography: ScopeGeography1; + value: + | "No information" + | "Significant deterioration" + | "Moderate deterioration" + | "Minor deterioration" + | "Low or no change" + | "Minor improvement" + | "Moderate improvement" + | "Significant improvement"; + }[]; + /** + * Captures the change in total energy consumption, driven by factors such as socio-economic development, technology shifts and consumer behavior. Scoped: see keyFeatures. + */ + energyDemand: { + sector: ScopeSector2; + geography: ScopeGeography2; + value: + | "No information" + | "Significant decrease" + | "Moderate decrease" + | "Minor decrease" + | "Low or no change" + | "Minor increase" + | "Moderate increase" + | "Significant increase"; + }[]; + /** + * Represents the extent to which energy end-uses transition from fossil fuels to electricity. Scoped: see keyFeatures. + */ + electrification: { + sector: ScopeSector3; + geography: ScopeGeography3; + value: + | "No information" + | "Significant decrease" + | "Moderate decrease" + | "Minor decrease" + | "Low or no change" + | "Minor increase" + | "Moderate increase" + | "Significant increase"; + }[]; + /** + * Identifies the types of policies modeled as drivers of the pathway, such as carbon pricing, subsidies, or mandated phaseouts of specific technologies. Scoped: see keyFeatures. + */ + policyTypes: { + sector: ScopeSector4; + geography: ScopeGeography4; + /** + * @minItems 1 + */ + value: [ + ( + | "No information" + | "Carbon price" + | "Feed-in tariffs" + | "Performance standards" + | "Phaseout dates" + | "Subsidies" + | "Target technology shares" + | "Other" + | "None" + ), + ...( + | "No information" + | "Carbon price" + | "Feed-in tariffs" + | "Performance standards" + | "Phaseout dates" + | "Subsidies" + | "Target technology shares" + | "Other" + | "None" + )[], + ]; + }[]; + /** + * Describes how technology costs evolve over time, from static cost assumptions to rapidly declining costs (e.g., via learning curves). Scoped: see keyFeatures. + */ + technologyCostTrend: { + sector: ScopeSector5; + geography: ScopeGeography5; + value: "No information" | "Increase" | "Low or no change" | "Decrease"; + }[]; + /** + * Defines which greenhouse gases are covered in the pathway's modeled emissions. Scoped: see keyFeatures. + */ + emissionsScope: { + sector: ScopeSector6; + geography: ScopeGeography6; + value: EmissionsScope; + }[]; + /** + * Represents the overall stringency and intent of modeled policies relative to climate targets, often reflecting if and how far the included policies go beyond currently legislated ones Scoped: see keyFeatures. + */ + policyAmbition: { + sector: ScopeSector7; + geography: ScopeGeography7; + value: + | "No information" + | "No policies included" + | "Current/legislated policies" + | "Current and drafted policies" + | "NDCs, unconditional only" + | "NDCs incl. conditional targets" + | "High ambition policies" + | "Other policy ambition"; + }[]; + /** + * Specifies the level of granularity in cost data, such as total system costs or detailed CAPEX/OPEX breakdowns. Scoped: see keyFeatures. + */ + technologyCostsDetail: { + sector: ScopeSector8; + geography: ScopeGeography8; + value: + | "No information" + | "Total costs" + | "Capital costs, O&M, etc." + | "Other cost breakdown"; + }[]; + /** + * Lists emerging or breakthrough technologies that are explicitly modeled within the pathway. These are considered in technology deployment too. Scoped: see keyFeatures. + */ + newTechnologiesIncluded: { + sector: ScopeSector9; + geography: ScopeGeography9; + /** + * @minItems 1 + */ + value: [ + ( + | "No information" + | "No new technologies" + | "CCUS" + | "DAC" + | "Green H2/ammonia" + | "SAF" + | "Battery storage" + | "EGS/AGS" + | "Other new technologies" + ), + ...( + | "No information" + | "No new technologies" + | "CCUS" + | "DAC" + | "Green H2/ammonia" + | "SAF" + | "Battery storage" + | "EGS/AGS" + | "Other new technologies" + )[], + ]; + }[]; + /** + * Summarizes how investment requirements are quantified, from total system to sector-level or supply-chain detail. Scoped: see keyFeatures. + */ + investmentNeeds: { + sector: ScopeSector10; + geography: ScopeGeography10; + value: + | "No information" + | "Total investment" + | "By sector" + | "By sector, part of value chain" + | "By technology" + | "By tech, part of value chain"; + }[]; + }; + /** + * The drivers that shape this pathway's outcomes. Every field is required but nullable: null means the driver is not a core driver for this pathway, as distinct from a driver that is present but undescribed. + */ + coreDrivers: { + /** + * Policies modeled as a driver of this pathway. + */ + policies: string | null; + /** + * Emissions targets or constraints driving this pathway. + */ + emissionsTargets: string | null; + /** + * Technology cost assumptions driving this pathway. + */ + technologyCosts: string | null; + /** + * Changes in investment driving this pathway. + */ + investmentChange: string | null; + /** + * Macroeconomic assumptions driving this pathway. + */ + macroeconomicDrivers: string | null; + /** + * Behavioral or demand-side shifts driving this pathway. + */ + behavioralShifts: string | null; + /** + * Any other core driver of this pathway. + */ + otherDrivers: string | null; + }; + /** + * Conditions the pathway's outcomes depend on. Descriptive only -- deliberately NOT part of the #869 inheritance chain, so these are not scoped by geography. + */ + dependencies: { + /** + * Category of the dependency. + */ + dependency_name: + | "Policy strategy" + | "Regulatory framework" + | "Market and economics" + | "Public acceptance" + | "Consumer and client behavior" + | "Infrastructure and logistics" + | "Technology" + | "Resource availability" + | "Environmental impacts and ecosystem services" + | "Labor availability"; + /** + * What the pathway depends on, in prose. + */ + dependency_description: string; + /** + * Display name of a sector. + */ + sector: + | "Land Use" + | "Agriculture" + | "Buildings" + | "Steel" + | "Cement" + | "Chemicals" + | "Coal Mining" + | "Oil (Upstream)" + | "Gas (Upstream)" + | "Power" + | "Automotive" + | "Aviation" + | "Rail" + | "Shipping" + | "Other"; + /** + * Strength of the evidence for this dependency. + */ + evidence_type: "Quantitative" | "Qualitative" | "Anecdotal" | "No evidence"; + }[]; +} diff --git a/src/utils/validateScopes.test.ts b/src/utils/validateScopes.test.ts new file mode 100644 index 00000000..2ca1b09f --- /dev/null +++ b/src/utils/validateScopes.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest"; +import { validateScopedEntries } from "./validateScopes"; +import type { PathwayMetadataV2 } from "../types"; + +/** + * These cover the cross-field constraint that JSON Schema draft-07 cannot express + * (see validateScopes.ts). AJV already guarantees the shape, so the fixtures here + * only need the fields the check actually reads — hence the casts. + */ +function pathway(over: Partial): PathwayMetadataV2 { + return { + sectors: [ + { name: "Power", technologies: [] }, + { name: "Steel", technologies: [] }, + ], + geography: { + regions: { "South East Asia": ["TH", "VN"] }, + country: ["SG"], + }, + keyFeatures: {}, + dependencies: [], + ...over, + } as unknown as PathwayMetadataV2; +} + +function entry(sector: string, geography: string) { + return { sector, geography, value: "No information" }; +} + +describe("validateScopedEntries — sector axis", () => { + it("accepts a sector the pathway declares", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", "Global")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }); + + it("accepts the cross-sector sentinel", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("cross-sector", "Global")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }); + + it("rejects a sector the pathway does not declare", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Cement", "Global")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("/keyFeatures/emissionsTrajectory/0/sector"); + expect(errors[0]).toContain('"Cement"'); + }); + + it("accepts cross-sector on a single-sector pathway (documented non-check)", () => { + const errors = validateScopedEntries( + pathway({ + sectors: [{ name: "Power", technologies: [] }], + keyFeatures: { + emissionsTrajectory: [entry("cross-sector", "Global")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }); +}); + +describe("validateScopedEntries — geography axis", () => { + it.each(["Global", "cross-region", "South East Asia", "SG", "TH"])( + "accepts %s", + (geography) => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", geography)], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }, + ); + + it("accepts a country reached only through a declared region", () => { + // The pathway declares "South East Asia": ["TH","VN"] but no standalone VN, + // so scoping to VN is *narrower* than the declaration, not outside it. + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", "VN")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }); + + it("rejects a mistyped region label", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", "Souteast Asia")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("/keyFeatures/emissionsTrajectory/0/geography"); + expect(errors[0]).toContain('"Souteast Asia"'); + }); + + it("rejects a country the pathway does not cover", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", "DE")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"DE"'); + }); +}); + +describe("validateScopedEntries — reporting", () => { + it("reports both axes of a single bad entry, and indexes each entry", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [ + entry("Power", "Global"), + entry("Cement", "Narnia"), + ], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toHaveLength(2); + expect(errors.every((e) => e.includes("/emissionsTrajectory/1/"))).toBe(true); + }); + + it("checks every keyFeatures field, not just the first", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [entry("Power", "Global")], + policyAmbition: [entry("Cement", "Global")], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("/keyFeatures/policyAmbition/0/sector"); + }); + + it("passes an empty entries array — absent at every scope is legal", () => { + const errors = validateScopedEntries( + pathway({ + keyFeatures: { + emissionsTrajectory: [], + } as unknown as PathwayMetadataV2["keyFeatures"], + }), + ); + expect(errors).toEqual([]); + }); +}); + +describe("validateScopedEntries — dependencies", () => { + it("accepts a declared sector", () => { + const errors = validateScopedEntries( + pathway({ + dependencies: [ + { + dependency_name: "Technology", + dependency_description: "Needs grid upgrades.", + sector: "Power", + evidence_type: "Qualitative", + }, + ] as unknown as PathwayMetadataV2["dependencies"], + }), + ); + expect(errors).toEqual([]); + }); + + it("rejects an undeclared sector", () => { + const errors = validateScopedEntries( + pathway({ + dependencies: [ + { + dependency_name: "Technology", + dependency_description: "Needs grid upgrades.", + sector: "Aviation", + evidence_type: "Qualitative", + }, + ] as unknown as PathwayMetadataV2["dependencies"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("/dependencies/0/sector"); + }); + + it("rejects the cross-sector sentinel, which is not legal here", () => { + const errors = validateScopedEntries( + pathway({ + dependencies: [ + { + dependency_name: "Technology", + dependency_description: "Needs grid upgrades.", + sector: "cross-sector", + evidence_type: "Qualitative", + }, + ] as unknown as PathwayMetadataV2["dependencies"], + }), + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('"cross-sector"'); + }); +}); diff --git a/src/utils/validateScopes.ts b/src/utils/validateScopes.ts new file mode 100644 index 00000000..1cca1c97 --- /dev/null +++ b/src/utils/validateScopes.ts @@ -0,0 +1,129 @@ +/** + * Structural checks for v2 scoped keyFeatures entries (#858). + * + * #858 requires an entry's `sector`/`geography` to be declared in the pathway's + * own `sectors`/`geography`, or be the widest sentinel. That constraint spans + * sibling data with dynamic keys (`geography.regions` is an open object of + * author-defined labels), which JSON Schema draft-07 cannot express: there is no + * way to point an `enum` at another part of the same document, and AJV's `$data` + * can only reference a single value, not compute the union of region labels, + * region members, and country codes that this needs. + * + * So `scopeGeography.v2.json` validates the *shape* — a non-blank, non-3-letter + * string — and this module validates the *reference*. Without it a mistyped + * region label ("Souteast Asia") would validate cleanly and then silently match + * nothing at search time, which is the worst of both worlds. Run from + * `scripts/schema-check-files.ts`, so `npm run schema:check` gates it. + * + * Errors are formatted like AJV's (` `) so callers can + * merge them into the same `ValidationProblem.errors` list without special-casing. + */ +import type { PathwayMetadataV2 } from "../types/pathwayMetadata.v2"; + +/** `$id` of the schema these checks apply to. */ +export const PATHWAY_METADATA_V2_ID = + "http://pathways.rmi.org/schema/pathwayMetadata.v2.json"; + +/** Sector sentinel meaning "the union of this pathway's own declared sectors". */ +export const CROSS_SECTOR = "cross-sector"; + +/** Geography sentinels: widest possible, and a multi-region non-global aggregate. */ +export const GLOBAL_SCOPE = "Global"; +export const CROSS_REGION = "cross-region"; + +type ScopedEntry = { sector: string; geography: string; value: unknown }; + +/** + * Every geography token an entry on this pathway may legitimately name: the + * two sentinels, each declared region label, every country inside those regions, + * and every standalone country. Region members count because a pathway that + * covers "South East Asia" does cover Thailand — scoping an entry to `TH` is + * more specific than the pathway's own declaration, not outside it. + */ +function allowedGeographies(pathway: PathwayMetadataV2): Set { + const allowed = new Set([GLOBAL_SCOPE, CROSS_REGION]); + const geo = pathway.geography; + if (!geo || typeof geo !== "object") return allowed; + if (geo.regions) { + for (const [label, members] of Object.entries(geo.regions)) { + allowed.add(label); + if (Array.isArray(members)) members.forEach((m) => allowed.add(m)); + } + } + if (Array.isArray(geo.country)) geo.country.forEach((c) => allowed.add(c)); + return allowed; +} + +/** + * Sectors an entry may name: the pathway's own, plus the `cross-sector` + * sentinel. + * + * Note what is deliberately *not* checked: #858 remarks that `cross-sector` is + * "only meaningful for multi-sector pathways", but a single-sector pathway using + * it is harmless — it resolves to that one sector — so flagging it would be a + * false positive on a legal document rather than a caught mistake. + */ +function declaredSectors(pathway: PathwayMetadataV2): Set { + const declared = new Set(); + for (const s of pathway.sectors ?? []) { + if (s?.name) declared.add(s.name); + } + return declared; +} + +function quote(values: Iterable): string { + return [...values] + .sort((a, b) => a.localeCompare(b)) + .map((v) => `"${v}"`) + .join(", "); +} + +/** + * Check one v2 metadata document's scope references. Returns an empty array when + * everything resolves. Assumes the document already passed AJV against + * `pathwayMetadata.v2.json`, so shapes are trusted and only references are tested. + */ +export function validateScopedEntries(pathway: PathwayMetadataV2): string[] { + const errors: string[] = []; + const declared = declaredSectors(pathway); + const entrySectors = new Set([CROSS_SECTOR, ...declared]); + const geographies = allowedGeographies(pathway); + + const keyFeatures = (pathway.keyFeatures ?? {}) as Record< + string, + ScopedEntry[] | undefined + >; + for (const [field, entries] of Object.entries(keyFeatures)) { + if (!Array.isArray(entries)) continue; + entries.forEach((entry, i) => { + const at = `/keyFeatures/${field}/${i}`; + if (!entrySectors.has(entry.sector)) { + errors.push( + `${at}/sector "${entry.sector}" is not a sector this pathway declares` + + ` (allowed: ${quote(entrySectors)})`, + ); + } + if (!geographies.has(entry.geography)) { + errors.push( + `${at}/geography "${entry.geography}" is not a geography this pathway` + + ` declares (allowed: ${quote(geographies)})`, + ); + } + }); + } + + // dependencies are descriptive and not part of the inheritance chain, but + // #858 still scopes each to a sector, and that sector must be a real one. + // Note this uses `declared`, not `entrySectors`: the schema types this field as + // the plain sector enum, so `cross-sector` is not a legal value here. + (pathway.dependencies ?? []).forEach((dep, i) => { + if (dep?.sector && !declared.has(dep.sector)) { + errors.push( + `/dependencies/${i}/sector "${dep.sector}" is not a sector this pathway` + + ` declares (allowed: ${quote(declared)})`, + ); + } + }); + + return errors; +} From 3bde9e6f4c98057b0317a86326c4719c7c36c443 Mon Sep 17 00:00:00 2001 From: repro Date: Thu, 13 Aug 2026 13:19:25 +0200 Subject: [PATCH 3/6] update generated files --- public/schema/pathwayMetadata.v2.html | 16 +- src/schema/pathwayMetadata.v2.test.ts | 250 ++++++++++++++++++++++++++ src/types/pathwayMetadata.v2.d.ts | 4 +- src/utils/validateScopes.test.ts | 4 +- 4 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 src/schema/pathwayMetadata.v2.test.ts diff --git a/public/schema/pathwayMetadata.v2.html b/public/schema/pathwayMetadata.v2.html index fd0fd215..e6774645 100644 --- a/public/schema/pathwayMetadata.v2.html +++ b/public/schema/pathwayMetadata.v2.html @@ -62,17 +62,7 @@

Pathway Metadata

Type: object

- A schema for the pathway metadata dataset in TPR. v2 of #858: each - keyFeatures field carries an array of {sector, geography, value} entries - instead of a bare value, so the #869 resolver can serve the most - specific value a pathway holds for a given search scope and fall back to - broader scopes. Also adds coreDrivers, dependencies, pathwayDescription - and transitionAssessment, and removes expertOverview and - pathwayOverview. v1 documents remain valid against - pathwayMetadata.v1.json; validateData routes each document by its own - $schema. -

+ >

A schema for the pathway metadata dataset in TPR.

No Additional Properties @@ -10913,8 +10903,8 @@

applicable scope: sector 'cross-sector' for a multi-sector pathway else its lone sector, and geography 'Global' else the pathway's widest declared region or country. An entry that is - absent at some scope means the #869 resolver keeps broadening - until it finds one; an explicit "No information" value is a real + absent at some scope means the resolver keeps broadening until + it finds one; an explicit "No information" value is a real authored value that terminates that fallback chain and displays at its own scope. An empty array means nothing is authored at any scope. diff --git a/src/schema/pathwayMetadata.v2.test.ts b/src/schema/pathwayMetadata.v2.test.ts new file mode 100644 index 00000000..2725b19c --- /dev/null +++ b/src/schema/pathwayMetadata.v2.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import v1Json from "./pathwayMetadata.v1.json" with { type: "json" }; +import v2Json from "./pathwayMetadata.v2.json" with { type: "json" }; +import scopeSectorJson from "./common/scopeSector.v2.json" with { type: "json" }; +import sectorJson from "./common/sector.v1.json" with { type: "json" }; +import emissionsScopeJson from "./common/emissionsScope.v1.json" with { type: "json" }; + +/** + * Guards v2's keyFeatures against silent self-drift. + * + * v2 spells the scoped-entry wrapper out once per field rather than sharing a + * `$defs` entry via `allOf`. That was measured, not assumed: the `allOf` version + * validates identically and generates nicer types, but AJV's `strict: true` + * (`strictRequired`, then `strictTypes`) forces the boilerplate back in for a net + * saving of 18 lines, it cannot use `additionalProperties: false` — that keyword + * only sees its own branch's `properties`, so it would reject `value` — and the + * `propertyNames` substitute degrades the commonest authoring error from + * "must NOT have additional properties" to "property name must be valid" with the + * offending key unnamed, because `fmt()` in validateData.ts drops AJV's `params`. + * + * The cost of that choice is 11 copies of one shape, so these tests enforce what + * the `$ref` would have: that the copies stay identical, and that each field's + * `value` still matches v1's enum verbatim, which is #858's actual requirement. + */ + +/** The slice of JSON Schema draft-07 these assertions actually read. */ +interface JsonSchema { + $id?: string; + $ref?: string; + $defs?: Record; + type?: string | string[]; + enum?: string[]; + items?: JsonSchema; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + uniqueItems?: boolean; + minItems?: number; + description?: string; + tsType?: string; +} + +const v1 = v1Json as unknown as JsonSchema; +const v2 = v2Json as unknown as JsonSchema; +const scopeSector = scopeSectorJson as unknown as JsonSchema; +const sector = sectorJson as unknown as JsonSchema; +const emissionsScope = emissionsScopeJson as unknown as JsonSchema; + +/** Throwing accessors keep every read type-safe without non-null assertions. */ +function props(schema: JsonSchema, where: string): Record { + if (!schema.properties) throw new Error(`${where}: expected properties`); + return schema.properties; +} + +function prop(schema: JsonSchema, name: string, where: string): JsonSchema { + const found = props(schema, where)[name]; + if (!found) throw new Error(`${where}: expected property ${name}`); + return found; +} + +function items(schema: JsonSchema, where: string): JsonSchema { + if (!schema.items) throw new Error(`${where}: expected items`); + return schema.items; +} + +function enumOf(schema: JsonSchema, where: string): string[] { + if (!schema.enum) throw new Error(`${where}: expected enum`); + return schema.enum; +} + +const KEY_FEATURE_FIELDS = [ + "emissionsTrajectory", + "energyEfficiency", + "energyDemand", + "electrification", + "policyTypes", + "technologyCostTrend", + "emissionsScope", + "policyAmbition", + "technologyCostsDetail", + "newTechnologiesIncluded", + "investmentNeeds", +] as const; + +/** v1 stores these as arrays, so in v2 the whole array is one entry's `value`. */ +const ARRAY_VALUED: ReadonlySet = new Set([ + "policyTypes", + "newTechnologiesIncluded", +]); + +/** v1's only field whose enum lacked "No information" — it has "None" instead. */ +const GAINED_NO_INFORMATION = "policyTypes"; + +const kf2 = prop(v2, "keyFeatures", "v2"); +const kf1 = prop(v1, "keyFeatures", "v1"); + +/** The v2 field schema (the array), and the scoped entry inside it. */ +const field = (name: string): JsonSchema => prop(kf2, name, "v2.keyFeatures"); +const entry = (name: string): JsonSchema => + items(field(name), `v2.keyFeatures.${name}`); +const entryValue = (name: string): JsonSchema => + prop(entry(name), "value", `v2.keyFeatures.${name}.items`); + +describe("pathwayMetadata.v2 keyFeatures — field set", () => { + it("declares exactly the 11 fields, and the same ones as v1", () => { + expect(Object.keys(props(kf2, "v2.keyFeatures"))).toEqual([ + ...KEY_FEATURE_FIELDS, + ]); + expect(Object.keys(props(kf2, "v2.keyFeatures"))).toEqual( + Object.keys(props(kf1, "v1.keyFeatures")), + ); + }); + + it("requires all 11 and forbids extras", () => { + expect([...(kf2.required ?? [])].sort()).toEqual( + [...KEY_FEATURE_FIELDS].sort(), + ); + expect(kf2.additionalProperties).toBe(false); + }); +}); + +describe("pathwayMetadata.v2 keyFeatures — the wrapper is identical everywhere", () => { + it.each(KEY_FEATURE_FIELDS)( + "%s is a uniqueItems array with a description", + (name) => { + const f = field(name); + expect(f.type).toBe("array"); + expect(f.uniqueItems).toBe(true); + // No minItems: an empty array is the legal "absent at every scope" state. + expect(f.minItems).toBeUndefined(); + expect(typeof f.description).toBe("string"); + expect(f.description?.endsWith(".")).toBe(true); + }, + ); + + it.each(KEY_FEATURE_FIELDS)( + "%s entries are closed {sector, geography, value}", + (name) => { + const e = entry(name); + expect(e.type).toBe("object"); + expect(Object.keys(props(e, name)).sort()).toEqual([ + "geography", + "sector", + "value", + ]); + expect([...(e.required ?? [])].sort()).toEqual([ + "geography", + "sector", + "value", + ]); + expect(e.additionalProperties).toBe(false); + }, + ); + + it("every field's sector and geography subschemas are byte-identical", () => { + // The whole point of the guard: one field drifting is the failure mode a + // shared $ref would have made impossible. + const scopes = KEY_FEATURE_FIELDS.map((name) => + JSON.stringify({ + sector: prop(entry(name), "sector", name), + geography: prop(entry(name), "geography", name), + }), + ); + expect(new Set(scopes).size).toBe(1); + }); + + it("points sector and geography at the v2 scope subschemas", () => { + const e = entry("emissionsTrajectory"); + expect(prop(e, "sector", "sector").$ref).toBe( + "http://pathways.rmi.org/schema/common/scopeSector.v2.json", + ); + expect(prop(e, "geography", "geography").$ref).toBe( + "http://pathways.rmi.org/schema/common/scopeGeography.v2.json", + ); + // Without the tsType hints the generator inlines the unions per field + // instead of importing the named types. + expect(prop(e, "sector", "sector").tsType).toContain("ScopeSectorV2"); + expect(prop(e, "geography", "geography").tsType).toContain( + "ScopeGeographyV2", + ); + }); +}); + +describe("pathwayMetadata.v2 keyFeatures — values carry over from v1", () => { + it.each(KEY_FEATURE_FIELDS)("%s value enum matches v1", (name) => { + const v2Value = entryValue(name); + const v1Value = prop(kf1, name, "v1.keyFeatures"); + + if (name === "emissionsScope") { + // A $ref in v1, so it stays a $ref — the enum lives in the common schema. + expect(v2Value.$ref).toBe(v1Value.$ref); + return; + } + + if (ARRAY_VALUED.has(name)) { + // The v1 array becomes one entry's value, not one entry per member. + expect(v2Value.type).toBe("array"); + expect(v2Value.uniqueItems).toBe(v1Value.uniqueItems); + expect(v2Value.minItems).toBe(v1Value.minItems); + const v1Members = enumOf(items(v1Value, name), name); + const expected = + name === GAINED_NO_INFORMATION + ? ["No information", ...v1Members] + : v1Members; + expect(enumOf(items(v2Value, name), name)).toEqual(expected); + return; + } + + expect(v2Value.type).toBe("string"); + expect(enumOf(v2Value, name)).toEqual(enumOf(v1Value, name)); + }); + + it('every field offers an explicit "No information" value', () => { + // #858: an explicit "No information" terminates the resolver's fallback + // chain, as distinct from an absent entry. It only works if every field can + // express it — policyTypes is the one v1 field that could not. + for (const name of KEY_FEATURE_FIELDS) { + const value = entryValue(name); + // emissionsScope holds its enum in the common subschema it $refs, so follow + // the reference rather than skipping the field — skipping would let this + // test pass while the one $ref'd field quietly lost the value. + let options: string[]; + if (value.enum) { + options = value.enum; + } else if (value.items?.enum) { + options = value.items.enum; + } else if (value.$ref === emissionsScope.$id) { + options = enumOf(emissionsScope, "emissionsScope.v1"); + } else { + throw new Error(`${name}: could not resolve a value enum`); + } + expect(options, `${name} is missing "No information"`).toContain( + "No information", + ); + } + }); +}); + +describe("scopeSector.v2 tracks sector.v1", () => { + it("is sector.v1's display names plus the cross-sector sentinel", () => { + const defs = sector.$defs; + if (!defs) throw new Error("sector.v1: expected $defs"); + const sectorNames = enumOf(defs.displayName, "sector.v1.displayName"); + const scopeNames = enumOf(scopeSector, "scopeSector.v2"); + expect(scopeNames).toContain("cross-sector"); + expect([...scopeNames].filter((n) => n !== "cross-sector").sort()).toEqual( + [...sectorNames].sort(), + ); + }); +}); diff --git a/src/types/pathwayMetadata.v2.d.ts b/src/types/pathwayMetadata.v2.d.ts index 306f10ad..eeb41698 100644 --- a/src/types/pathwayMetadata.v2.d.ts +++ b/src/types/pathwayMetadata.v2.d.ts @@ -122,7 +122,7 @@ export type ScopeGeography10 = import("./common/scopeGeography.v2").ScopeGeographyV2; /** - * A schema for the pathway metadata dataset in TPR. v2 of #858: each keyFeatures field carries an array of {sector, geography, value} entries instead of a bare value, so the #869 resolver can serve the most specific value a pathway holds for a given search scope and fall back to broader scopes. Also adds coreDrivers, dependencies, pathwayDescription and transitionAssessment, and removes expertOverview and pathwayOverview. v1 documents remain valid against pathwayMetadata.v1.json; validateData routes each document by its own $schema. + * A schema for the pathway metadata dataset in TPR. */ export interface PathwayMetadataV2 { /** @@ -230,7 +230,7 @@ export interface PathwayMetadataV2 { transitionAssessment?: string | null; metric: import("./common/metric.v1").MetricV1["displayName"][]; /** - * Key features of the pathway. Every field is an array of {sector, geography, value} entries (#858), so a pathway can hold different values for different parts of its coverage. A non-varying feature carries exactly one entry at the widest applicable scope: sector 'cross-sector' for a multi-sector pathway else its lone sector, and geography 'Global' else the pathway's widest declared region or country. An entry that is absent at some scope means the #869 resolver keeps broadening until it finds one; an explicit "No information" value is a real authored value that terminates that fallback chain and displays at its own scope. An empty array means nothing is authored at any scope. + * Key features of the pathway. Every field is an array of {sector, geography, value} entries (#858), so a pathway can hold different values for different parts of its coverage. A non-varying feature carries exactly one entry at the widest applicable scope: sector 'cross-sector' for a multi-sector pathway else its lone sector, and geography 'Global' else the pathway's widest declared region or country. An entry that is absent at some scope means the resolver keeps broadening until it finds one; an explicit "No information" value is a real authored value that terminates that fallback chain and displays at its own scope. An empty array means nothing is authored at any scope. */ keyFeatures: { /** diff --git a/src/utils/validateScopes.test.ts b/src/utils/validateScopes.test.ts index 2ca1b09f..fc47efc4 100644 --- a/src/utils/validateScopes.test.ts +++ b/src/utils/validateScopes.test.ts @@ -143,7 +143,9 @@ describe("validateScopedEntries — reporting", () => { }), ); expect(errors).toHaveLength(2); - expect(errors.every((e) => e.includes("/emissionsTrajectory/1/"))).toBe(true); + expect(errors.every((e) => e.includes("/emissionsTrajectory/1/"))).toBe( + true, + ); }); it("checks every keyFeatures field, not just the first", () => { From f71d7f5e94ddfdb1c4df4f39fe02c68266161620 Mon Sep 17 00:00:00 2001 From: repro Date: Thu, 13 Aug 2026 13:41:38 +0200 Subject: [PATCH 4/6] feat(data): migrate ACE and IEA pathways to metadata v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the 4 ASEAN Centre for Energy and 3 IEA metadata files to pathwayMetadata.v2 via a new re-runnable codemod. src/data now holds 7 v2 and 49 v1 documents, which coexist because validateData routes each by its own $schema $id. Nothing reads v2 yet — the loader still points at v1, so the app is unchanged. All 7 resolve to a single widest-scope entry per keyFeature: cross-sector/South East Asia for ACE, cross-sector/Global for IEA. Both IEA files that carry pathwayOverview fold it into pathwayDescription as the lead paragraph; it has no readers in the app, so nothing observable moves. - scripts/codemod-v1-to-v2.ts splits v1's expertOverview into its three sections, wraps each keyFeature as one scoped entry, and scaffolds coreDrivers/dependencies. It skips files already on v2, so the remaining 49 are a re-run rather than a rewrite. A development tool only: there is no runtime v1 conversion, so un-migrated files simply will not load once the loader moves to v2. - The splitter accepts a bare line matching a section title as a heading. That exists for ACE-CNS-2024, whose "Core Drivers" heading lost its #### markers; without it, 1.5 KB of core-drivers prose folds into pathwayDescription and pushes it from 1204 to 2727 chars. - coreDrivers is scaffolded all-null per #858 rather than populated. The v1 "#### Core Drivers" prose does not map onto the 7 named fields mechanically: four paragraphs already exceed the 500-char cap, the italic labels ("Technology shifts", "Falling energy demand", "Economic growth") do not correspond 1:1 to the field names, and every section has unlabeled paragraphs with no destination. The codemod prints the prose it is not carrying so the hand-authoring ticket starts from the text. - transitionAssessment's maxLength goes 2500 -> 3000. 2500 was chosen for symmetry with pathwayDescription rather than measured; the longest section in the corpus is 2655 chars (ACE-RAS-2024), which made the codemod's own output invalid. pathwayDescription's 2500 is confirmed correct — the longest across all 56 files is 2459. Fixtures are added rather than converted, so the v1 fixtures stay v1 and the new coexistence tests can assert both halves. pathwayMetadata_v2_full carries several entries per field at different scopes, which #869 and #859 will need; _v2_minimal proves an all-empty keyFeatures document validates. One coexistence test documents a sharp edge deliberately: validateDataCollect filters entries to the single $id it is handed, so documents of the other version are dropped as neither valid nor invalid. That is what makes a mixed corpus work, and it is why repointing the loader has to report the count it skipped. Refs #858, #801. Co-Authored-By: Claude Opus 5 --- scripts/codemod-v1-to-v2.test.ts | 286 +++++++++++++++ scripts/codemod-v1-to-v2.ts | 334 ++++++++++++++++++ .../asean-centre-for-energy/ACE-ATS-2024.json | 113 +++++- .../asean-centre-for-energy/ACE-BAS-2024.json | 105 +++++- .../asean-centre-for-energy/ACE-CNS-2024.json | 114 ++++-- .../asean-centre-for-energy/ACE-RAS-2024.json | 113 +++++- src/data/iea/IEA-APS-2024.json | 118 +++++-- src/data/iea/IEA-NZE-2024.json | 117 ++++-- src/data/iea/IEA-STEPS-2024.json | 118 +++++-- src/schema/pathwayMetadata.v2.json | 2 +- src/utils/validateData.test.tsx | 103 ++++++ testdata/valid/pathwayMetadata_v2_full.json | 163 +++++++++ .../valid/pathwayMetadata_v2_minimal.json | 39 ++ 13 files changed, 1582 insertions(+), 143 deletions(-) create mode 100644 scripts/codemod-v1-to-v2.test.ts create mode 100644 scripts/codemod-v1-to-v2.ts create mode 100644 testdata/valid/pathwayMetadata_v2_full.json create mode 100644 testdata/valid/pathwayMetadata_v2_minimal.json diff --git a/scripts/codemod-v1-to-v2.test.ts b/scripts/codemod-v1-to-v2.test.ts new file mode 100644 index 00000000..bb3ded43 --- /dev/null +++ b/scripts/codemod-v1-to-v2.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from "vitest"; +import { + splitExpertOverview, + widestScope, + upgradeV1ToV2, + PATHWAY_DESCRIPTION, + CORE_DRIVERS, + TRANSITION_ASSESSMENT, +} from "./codemod-v1-to-v2.ts"; +import type { PathwayMetadataV1 } from "../src/types/pathwayMetadata.v1.d.ts"; + +const V2_ID = "http://pathways.rmi.org/schema/pathwayMetadata.v2.json"; + +const WELL_FORMED = [ + "#### Pathway Description", + "", + "A description of the pathway.", + "", + "#### Core Drivers", + "", + "*Policy:* Policies drive it.", + "", + "#### Application to Transition Assessment", + "", + "How to apply it.", +].join("\n"); + +/** ACE-CNS-2024's shape: the Core Drivers heading lost its `####` markers. */ +const BARE_HEADING = [ + "#### Pathway Description", + "", + "A description of the pathway.", + "", + "Core Drivers", + "", + "*Policy:* Policies drive it.", + "", + "#### Application to Transition Assessment", + "", + "How to apply it.", +].join("\n"); + +describe("splitExpertOverview", () => { + it("splits the three ATX-headed sections", () => { + const s = splitExpertOverview(WELL_FORMED); + expect(s.get(PATHWAY_DESCRIPTION)).toBe("A description of the pathway."); + expect(s.get(CORE_DRIVERS)).toBe("*Policy:* Policies drive it."); + expect(s.get(TRANSITION_ASSESSMENT)).toBe("How to apply it."); + }); + + it("treats a bare section title as a heading (the ACE-CNS case)", () => { + const s = splitExpertOverview(BARE_HEADING); + // Without the tolerance, Core Drivers prose would land in the description + // and push it over the 2500-char limit. + expect(s.get(PATHWAY_DESCRIPTION)).toBe("A description of the pathway."); + expect(s.get(CORE_DRIVERS)).toBe("*Policy:* Policies drive it."); + }); + + it("does not treat a title mentioned mid-sentence as a heading", () => { + const s = splitExpertOverview( + [ + "#### Pathway Description", + "", + "The Core Drivers of this pathway are policy-led.", + ].join("\n"), + ); + expect(s.get(PATHWAY_DESCRIPTION)).toBe( + "The Core Drivers of this pathway are policy-led.", + ); + expect(s.has(CORE_DRIVERS)).toBe(false); + }); + + it("preserves markdown inside a section body", () => { + const s = splitExpertOverview( + ["#### Pathway Description", "", "para one.", "", "para two."].join("\n"), + ); + expect(s.get(PATHWAY_DESCRIPTION)).toBe("para one.\n\npara two."); + }); + + it("returns no sections for text with no recognised headings", () => { + expect(splitExpertOverview("Just prose.").size).toBe(0); + }); +}); + +function v1(over: Partial): PathwayMetadataV1 { + return { + $schema: "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + id: "X", + name: { full: "X" }, + description: "A pathway.", + publication: { + title: { full: "T" }, + publisher: { full: "TransitionZero" }, + year: 2024, + }, + pathwayType: "Normative", + geography: { global: true }, + sectors: [{ name: "Power", technologies: [] }], + expertOverview: WELL_FORMED, + metric: ["Capacity"], + keyFeatures: { + emissionsTrajectory: "Significant decrease", + energyEfficiency: "Moderate improvement", + energyDemand: "Minor increase", + electrification: "Significant increase", + policyTypes: ["Carbon price"], + technologyCostTrend: "Decrease", + emissionsScope: "CO2e (Kyoto)", + policyAmbition: "High ambition policies", + technologyCostsDetail: "Total costs", + newTechnologiesIncluded: ["CCUS"], + investmentNeeds: "By sector", + }, + ...over, + } as unknown as PathwayMetadataV1; +} + +describe("widestScope", () => { + it("uses the lone sector when there is only one", () => { + expect(widestScope(v1({})).sector).toBe("Power"); + }); + + it("uses cross-sector for a multi-sector pathway", () => { + const doc = v1({ + sectors: [ + { name: "Power", technologies: [] }, + { name: "Steel", technologies: [] }, + ], + } as Partial); + expect(widestScope(doc).sector).toBe("cross-sector"); + }); + + it("collapses a repeated sector name rather than calling it multi-sector", () => { + // pathwayMetadata has no uniqueItems on sectors, and the _full fixture + // deliberately lists Automotive twice. + const doc = v1({ + sectors: [ + { name: "Power", technologies: [] }, + { name: "Power", technologies: ["Solar"] }, + ], + } as Partial); + expect(widestScope(doc).sector).toBe("Power"); + }); + + it("prefers Global when the pathway is global, even with regions present", () => { + const doc = v1({ + geography: { global: true, regions: { "South East Asia": ["TH"] } }, + } as Partial); + expect(widestScope(doc).geography).toBe("Global"); + }); + + it("uses the lone region label (the ACE case)", () => { + const doc = v1({ + geography: { regions: { "South East Asia": ["TH", "VN"] } }, + } as Partial); + expect(widestScope(doc).geography).toBe("South East Asia"); + }); + + it("uses the lone country code", () => { + const doc = v1({ + geography: { country: ["TH"] }, + } as Partial); + expect(widestScope(doc).geography).toBe("TH"); + }); + + it("throws rather than guessing between several regions", () => { + const doc = v1({ + geography: { regions: { A: ["TH"], B: ["VN"] } }, + } as Partial); + expect(() => widestScope(doc)).toThrow(/ambiguous/); + }); + + it("throws rather than guessing between several countries", () => { + const doc = v1({ + geography: { country: ["TH", "VN"] }, + } as Partial); + expect(() => widestScope(doc)).toThrow(/ambiguous/); + }); + + it("throws when there is no geography at all", () => { + const doc = v1({ geography: {} } as Partial); + expect(() => widestScope(doc)).toThrow(/no geography/); + }); +}); + +describe("upgradeV1ToV2", () => { + it("repoints $schema at v2", () => { + expect(upgradeV1ToV2(v1({})).doc.$schema).toBe(V2_ID); + }); + + it("wraps every keyFeature as one entry at the widest scope", () => { + const { doc } = upgradeV1ToV2(v1({})); + const kf = doc.keyFeatures; + expect(Object.keys(kf)).toHaveLength(11); + for (const entries of Object.values(kf)) { + expect(entries).toHaveLength(1); + expect(entries[0].sector).toBe("Power"); + expect(entries[0].geography).toBe("Global"); + } + expect(kf.emissionsTrajectory[0].value).toBe("Significant decrease"); + }); + + it("nests an array-valued field rather than splatting it into entries", () => { + const doc = upgradeV1ToV2( + v1({ + keyFeatures: { + ...v1({}).keyFeatures, + policyTypes: ["Carbon price", "Subsidies"], + }, + } as Partial), + ).doc; + expect(doc.keyFeatures.policyTypes).toHaveLength(1); + expect(doc.keyFeatures.policyTypes[0].value).toEqual([ + "Carbon price", + "Subsidies", + ]); + }); + + it("moves the description section into pathwayDescription", () => { + const { doc } = upgradeV1ToV2(v1({})); + expect(doc.pathwayDescription).toBe("A description of the pathway."); + expect(doc.transitionAssessment).toBe("How to apply it."); + }); + + it("folds pathwayOverview in as the lead paragraph", () => { + const { doc, foldedPathwayOverview } = upgradeV1ToV2( + v1({ pathwayOverview: "A short summary." } as Partial), + ); + expect(foldedPathwayOverview).toBe(true); + expect(doc.pathwayDescription).toBe( + "A short summary.\n\nA description of the pathway.", + ); + }); + + it("drops the v1 overview fields", () => { + const { doc } = upgradeV1ToV2( + v1({ pathwayOverview: "A short summary." } as Partial), + ); + expect("expertOverview" in doc).toBe(false); + expect("pathwayOverview" in doc).toBe(false); + }); + + it("scaffolds coreDrivers all-null and dependencies empty", () => { + const { doc } = upgradeV1ToV2(v1({})); + expect(Object.values(doc.coreDrivers)).toEqual([ + null, + null, + null, + null, + null, + null, + null, + ]); + expect(doc.dependencies).toEqual([]); + }); + + it("returns the Core Drivers prose it does not carry over", () => { + expect(upgradeV1ToV2(v1({})).coreDriversProse).toBe( + "*Policy:* Policies drive it.", + ); + }); + + it("nulls pathwayDescription when there is no description section", () => { + const { doc } = upgradeV1ToV2( + v1({ expertOverview: "No headings here." } as Partial), + ); + expect(doc.pathwayDescription).toBeNull(); + expect(doc.transitionAssessment).toBeNull(); + }); + + it("preserves unrelated fields and their order", () => { + const { doc } = upgradeV1ToV2( + v1({ modelYearNetzero: 2050 } as Partial), + ); + expect(doc.modelYearNetzero).toBe(2050); + // pathwayDescription takes expertOverview's slot; coreDrivers/dependencies + // follow keyFeatures. Key order keeps the data-file diffs readable. + const keys = Object.keys(doc); + expect(keys.indexOf("pathwayDescription")).toBeLessThan( + keys.indexOf("metric"), + ); + expect(keys.indexOf("coreDrivers")).toBeGreaterThan( + keys.indexOf("keyFeatures"), + ); + }); +}); diff --git a/scripts/codemod-v1-to-v2.ts b/scripts/codemod-v1-to-v2.ts new file mode 100644 index 00000000..b181f582 --- /dev/null +++ b/scripts/codemod-v1-to-v2.ts @@ -0,0 +1,334 @@ +/** + * One-shot codemod: pathwayMetadata v1 -> v2 (#858). + * + * Run over an explicit list of files, in place: + * + * npx ts-node --esm scripts/codemod-v1-to-v2.ts src/data/iea/IEA-NZE-2024.json ... + * npx ts-node --esm scripts/codemod-v1-to-v2.ts --dry-run src/data/iea + * + * A directory argument expands to the metadata files under it. Files already on v2 + * are skipped, so re-running is safe and the remaining 49 files are a re-run + * rather than a rewrite. + * + * This is a development tool, not a runtime path: the app does not convert v1 + * documents on load. Files that still carry the v1 $schema simply are not loaded + * once the loader points at v2. + * + * What it does NOT do: populate `coreDrivers`. The v1 "#### Core Drivers" prose + * cannot be mapped onto the 7 named fields mechanically -- several paragraphs + * exceed the 500-char cap, the italic labels do not correspond 1:1 to the field + * names, and each section has unlabeled paragraphs with no destination. Per #858 + * the fields are scaffolded null for hand-authoring; the prose is printed in the + * report so it is not lost track of. + */ +import { promises as fs } from "node:fs"; +import { join, extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PathwayMetadataV1 } from "../src/types/pathwayMetadata.v1.d.ts"; +import type { PathwayMetadataV2 } from "../src/types/pathwayMetadata.v2.d.ts"; + +const V1_ID = "http://pathways.rmi.org/schema/pathwayMetadata.v1.json"; +const V2_ID = "http://pathways.rmi.org/schema/pathwayMetadata.v2.json"; + +/** Widest sentinels, mirroring src/utils/validateScopes.ts. */ +const CROSS_SECTOR = "cross-sector"; +const GLOBAL_SCOPE = "Global"; + +/** + * The three sections every v1 `expertOverview` is built from. Verified against all + * 56 files: 55 have all three, and ACE-CNS-2024 is missing "Core Drivers" only + * because its heading lost its `####` markers (see splitExpertOverview). + */ +export const PATHWAY_DESCRIPTION = "Pathway Description"; +export const CORE_DRIVERS = "Core Drivers"; +export const TRANSITION_ASSESSMENT = "Application to Transition Assessment"; +export const SECTION_TITLES = [ + PATHWAY_DESCRIPTION, + CORE_DRIVERS, + TRANSITION_ASSESSMENT, +] as const; + +const CORE_DRIVER_FIELDS = [ + "policies", + "emissionsTargets", + "technologyCosts", + "investmentChange", + "macroeconomicDrivers", + "behavioralShifts", + "otherDrivers", +] as const; + +/** + * Split a v1 `expertOverview` into its named sections. + * + * Headings are ATX (`#### Core Drivers`), but a bare line whose entire content is + * a known section title also counts. That tolerance exists for exactly one file: + * ACE-CNS-2024.json lost the `####` on its "Core Drivers" heading, which would + * otherwise fold 1.5 KB of core-drivers prose into pathwayDescription and push it + * past the 2500-char limit. Treating the bare title as a heading is safe because + * the strings are long and specific enough not to occur as body text. + */ +export function splitExpertOverview(text: string): Map { + const sections = new Map(); + const lines = text.split("\n"); + let current: string | null = null; + let buffer: string[] = []; + + const flush = () => { + if (current !== null) sections.set(current, buffer.join("\n").trim()); + buffer = []; + }; + + for (const line of lines) { + const stripped = line.trim(); + const atx = /^#{1,6}\s*(.+?)\s*$/.exec(stripped); + const heading = atx ? atx[1] : stripped; + const known = SECTION_TITLES.find((t) => t === heading); + if (known && (atx || stripped === known)) { + flush(); + current = known; + continue; + } + if (current !== null) buffer.push(line); + } + flush(); + return sections; +} + +/** + * The widest scope an entry on this pathway can carry, per #858: `cross-sector` + * for a multi-sector pathway else its lone sector, and `Global` else the + * pathway's single declared region or country. + * + * Throws rather than guessing when a pathway declares several regions or several + * standalone countries without `global`, since which of them is "widest" is an + * authoring decision (`cross-region` exists for that case). No file in the corpus + * hits this today -- all 56 resolve. + */ +export function widestScope(doc: PathwayMetadataV1): { + sector: string; + geography: string; +} { + const names = [...new Set((doc.sectors ?? []).map((s) => s.name))]; + if (names.length === 0) throw new Error("pathway declares no sectors"); + const sector = names.length > 1 ? CROSS_SECTOR : names[0]; + + const geo = doc.geography ?? {}; + const regions = Object.keys(geo.regions ?? {}); + const countries = geo.country ?? []; + + let geography: string; + if (geo.global === true) { + geography = GLOBAL_SCOPE; + } else if (regions.length === 1) { + geography = regions[0]; + } else if (regions.length > 1) { + throw new Error( + `${regions.length} regions and no "global" flag: widest geography is ambiguous ` + + `(consider "cross-region"): ${regions.join(", ")}`, + ); + } else if (countries.length === 1) { + geography = countries[0]; + } else if (countries.length > 1) { + throw new Error( + `${countries.length} countries and no "global" flag or region: widest geography is ambiguous`, + ); + } else { + throw new Error("pathway declares no geography"); + } + + return { sector, geography }; +} + +/** Wrap one v1 value as a single scoped entry at the given scope. */ +function scoped(sector: string, geography: string, value: T) { + return [{ sector, geography, value }]; +} + +export interface UpgradeResult { + doc: PathwayMetadataV2; + /** Prose with no destination in v2 -- reported so it is not lost track of. */ + coreDriversProse: string; + scope: { sector: string; geography: string }; + foldedPathwayOverview: boolean; +} + +/** Transform one v1 document into its v2 equivalent. Pure; does no I/O. */ +export function upgradeV1ToV2(doc: PathwayMetadataV1): UpgradeResult { + const scope = widestScope(doc); + const { sector, geography } = scope; + const kf = doc.keyFeatures; + + const sections = splitExpertOverview(doc.expertOverview ?? ""); + const description = sections.get(PATHWAY_DESCRIPTION) ?? ""; + const assessment = sections.get(TRANSITION_ASSESSMENT) ?? ""; + const coreDriversProse = sections.get(CORE_DRIVERS) ?? ""; + + // #858 folds pathwayOverview into pathwayDescription. It is a short standalone + // summary, so it reads as the lead paragraph. It has no readers in the app + // today, so nothing observable depends on where it lands. + const overview = doc.pathwayOverview?.trim(); + const descriptionParts = [overview, description].filter( + (p): p is string => !!p && p.length > 0, + ); + + const out: Record = {}; + for (const [key, value] of Object.entries(doc)) { + switch (key) { + case "$schema": + out.$schema = V2_ID; + break; + case "pathwayOverview": + // Superseded; emitted below in expertOverview's position. + break; + case "expertOverview": + out.pathwayDescription = + descriptionParts.length > 0 ? descriptionParts.join("\n\n") : null; + out.transitionAssessment = assessment.length > 0 ? assessment : null; + break; + case "keyFeatures": + out.keyFeatures = { + emissionsTrajectory: scoped( + sector, + geography, + kf.emissionsTrajectory, + ), + energyEfficiency: scoped(sector, geography, kf.energyEfficiency), + energyDemand: scoped(sector, geography, kf.energyDemand), + electrification: scoped(sector, geography, kf.electrification), + policyTypes: scoped(sector, geography, kf.policyTypes), + technologyCostTrend: scoped( + sector, + geography, + kf.technologyCostTrend, + ), + emissionsScope: scoped(sector, geography, kf.emissionsScope), + policyAmbition: scoped(sector, geography, kf.policyAmbition), + technologyCostsDetail: scoped( + sector, + geography, + kf.technologyCostsDetail, + ), + newTechnologiesIncluded: scoped( + sector, + geography, + kf.newTechnologiesIncluded, + ), + investmentNeeds: scoped(sector, geography, kf.investmentNeeds), + }; + // Scaffolded for hand-authoring; see the file header. + out.coreDrivers = Object.fromEntries( + CORE_DRIVER_FIELDS.map((f) => [f, null]), + ); + out.dependencies = []; + break; + default: + out[key] = value; + } + } + + return { + doc: out as unknown as PathwayMetadataV2, + coreDriversProse, + scope, + foldedPathwayOverview: !!overview, + }; +} + +async function metadataFilesUnder(path: string): Promise { + const stat = await fs.stat(path); + if (!stat.isDirectory()) return [path]; + const dirents = await fs.readdir(path, { withFileTypes: true }); + const found: string[] = []; + for (const d of dirents) { + const full = join(path, d.name); + if (d.isDirectory()) found.push(...(await metadataFilesUnder(full))); + else if (d.isFile() && extname(d.name) === ".json") found.push(full); + } + return found.sort(); +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const paths = args.filter((a) => !a.startsWith("--")); + + if (paths.length === 0) { + console.error( + "usage: codemod-v1-to-v2.ts [--dry-run] [...]\n" + + "Refusing to run with no explicit target.", + ); + process.exit(1); + } + + const files = (await Promise.all(paths.map(metadataFilesUnder))).flat(); + const report: string[] = []; + let migrated = 0; + let skipped = 0; + + for (const file of files) { + const raw = await fs.readFile(file, "utf8"); + const parsed = JSON.parse(raw) as { $schema?: string }; + if (parsed.$schema === V2_ID) { + skipped++; + continue; + } + if (parsed.$schema !== V1_ID) { + // Timeseries files and anything else non-metadata. + skipped++; + continue; + } + + const doc = parsed as unknown as PathwayMetadataV1; + let result: UpgradeResult; + try { + result = upgradeV1ToV2(doc); + } catch (e) { + console.error(`✖ ${file}: ${String(e instanceof Error ? e.message : e)}`); + process.exitCode = 1; + continue; + } + + const { scope, coreDriversProse, foldedPathwayOverview } = result; + const descLength = result.doc.pathwayDescription?.length ?? 0; + console.info( + `✔ ${file}\n` + + ` scope: (${scope.sector}, ${scope.geography})` + + ` pathwayDescription: ${descLength} chars` + + (foldedPathwayOverview ? " [pathwayOverview folded in]" : ""), + ); + if (coreDriversProse.length > 0) { + report.push(`## ${file}\n\n${coreDriversProse}\n`); + } else { + console.info(` note: no "${CORE_DRIVERS}" section found`); + } + + if (!dryRun) { + await fs.writeFile(file, `${JSON.stringify(result.doc, null, 2)}\n`); + } + migrated++; + } + + console.info( + `\n${dryRun ? "Would migrate" : "Migrated"} ${migrated} file(s); skipped ${skipped}.`, + ); + + if (report.length > 0) { + console.info( + `\n--- "${CORE_DRIVERS}" prose NOT carried into v2 (coreDrivers is scaffolded null; ` + + `hand-author from this text) ---\n`, + ); + console.info(report.join("\n")); + } + if (!dryRun) { + console.info("Run `npm run schema:check` and `npx prettier --write` next."); + } +} + +// Only run when invoked directly, so the exported helpers stay importable by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e: unknown) => { + console.error(String(e instanceof Error ? e.stack : e)); + process.exit(1); + }); +} diff --git a/src/data/asean-centre-for-energy/ACE-ATS-2024.json b/src/data/asean-centre-for-energy/ACE-ATS-2024.json index ce43752f..c11e636a 100644 --- a/src/data/asean-centre-for-energy/ACE-ATS-2024.json +++ b/src/data/asean-centre-for-energy/ACE-ATS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "ACE-ATS-2024", "publication": { "title": { @@ -72,7 +72,8 @@ "technologies": [] } ], - "expertOverview": "#### Pathway Description\n\nThe ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE ASEAN Member States Targets Scenario (ATS) is an exploratory pathway which assumes that every ASEAN member state fully achieves its unconditional transition targets on time, including unconditional goals set via Nationally Determined Contributions (NDCs). This pathway models the impact of existing policies, such as national power development plans (PDPs), and the implementation of these stated targets. The ATS pathway models more ambitious energy efficiency targets, as well as a faster deployment of low-carbon energy technologies, especially from renewable energy. The ATS pathway does not include more aspirational national objectives, such as conditional NDCs or long-term net zero goals. For examples of pathways that include these policies, see the ACE Regional Aspiration Scenario (RAS) and IEA Announced Pledges Scenario (APS).\n\nPower generation emissions decline -2.4% per year, resulting in a total decrease of 74% relative to power emissions in the Baseline Scenario. However, the ATS pathway projects to an average annual emissions increase of 0.8% across the full energy sector. \n\n#### Core Drivers\n\nThe ATS pathway is primarily influenced by existing and announced policies and does not consider declining technology costs or significant new technologies.\n\n*Policy:* Unconditional national policies are the primary driver of transition outcomes in the ATS pathway. Energy efficiency targets and renewable energy targets, including detailed plans from national power development plans, drive significant renewables deployment and emissions reductions. Power capacity deployment follows the plans detailed in NDCs and PDPs until the final year laid out in these policies, and ATS simulates further additions based on the technology mix at the end year of the policy plans.\n\nThe ATS pathway assumes technology costs remain fixed at current levels across the full trajectory. The ATS pathway provides information on demand shifts, investment flows, and technology deployment, but these factors are driven primarily by modeled policy shifts. \n\n#### Application to Transition Assessment\n\nACE ATS is an exploratory, policy-focused pathway, which provides useful values for assessing the alignment of corporate plans to region-wide policy impacts. As a region-specific pathway, ACE ATS is not directly linked to a global implied temperature rise, limiting its use as a quantitative benchmark the ambition of climate targets.\n\nThe ATS is particularly well-suited for evaluating the alignment of corporate targets, strategies, and investment pipelines with the stated ambitions of the jurisdictions in which they operate. Its strong policy orientation allows for a meaningful comparison between corporate actions and national development priorities. A misalignment with the ATS may signal potential exposure to future regulatory risks, such as non-compliance with evolving energy policies or reduced competitiveness in markets where low-carbon technologies are being prioritized. Alignment, in the other hand, may indicate that a company is strategically positioned to benefit from market shifts. The ATS pathway includes the impacts of stated policies and targets which have not yet been implemented; therefore, misalignment does not necessarily imply exposure to current regulatory risk but rather a potential gap in future readiness. However, the ATS pathway excludes aspirational goals such as conditional NDCs, making the pathway a relatively conservative choice for evaluating potential policy alignment and impact.\n\nDue to its assumption of static technology costs and limited modelling of specific technology deployment, the ATS pathway has more limited applications in assessing the commercial or technological feasibility of specific transition strategies. Users interested in these applications may consider supplementing the ATS pathway with additional pathways that provide more detailed and dynamic technology cost and deployment projections.\n\nATS provides national targets. These targets are presented with fine granularity, enabling detailed national policy alignment assessments. The pathway also includes regional-level projections for generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. This data is relevant and useful for analyzing specific decarbonization levers and project investment pipelines within company plans, and supports region-specific benchmark, but does not enable country-specific comparisons of changes in generation or capacity.", + "pathwayDescription": "The ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE ASEAN Member States Targets Scenario (ATS) is an exploratory pathway which assumes that every ASEAN member state fully achieves its unconditional transition targets on time, including unconditional goals set via Nationally Determined Contributions (NDCs). This pathway models the impact of existing policies, such as national power development plans (PDPs), and the implementation of these stated targets. The ATS pathway models more ambitious energy efficiency targets, as well as a faster deployment of low-carbon energy technologies, especially from renewable energy. The ATS pathway does not include more aspirational national objectives, such as conditional NDCs or long-term net zero goals. For examples of pathways that include these policies, see the ACE Regional Aspiration Scenario (RAS) and IEA Announced Pledges Scenario (APS).\n\nPower generation emissions decline -2.4% per year, resulting in a total decrease of 74% relative to power emissions in the Baseline Scenario. However, the ATS pathway projects to an average annual emissions increase of 0.8% across the full energy sector.", + "transitionAssessment": "ACE ATS is an exploratory, policy-focused pathway, which provides useful values for assessing the alignment of corporate plans to region-wide policy impacts. As a region-specific pathway, ACE ATS is not directly linked to a global implied temperature rise, limiting its use as a quantitative benchmark the ambition of climate targets.\n\nThe ATS is particularly well-suited for evaluating the alignment of corporate targets, strategies, and investment pipelines with the stated ambitions of the jurisdictions in which they operate. Its strong policy orientation allows for a meaningful comparison between corporate actions and national development priorities. A misalignment with the ATS may signal potential exposure to future regulatory risks, such as non-compliance with evolving energy policies or reduced competitiveness in markets where low-carbon technologies are being prioritized. Alignment, in the other hand, may indicate that a company is strategically positioned to benefit from market shifts. The ATS pathway includes the impacts of stated policies and targets which have not yet been implemented; therefore, misalignment does not necessarily imply exposure to current regulatory risk but rather a potential gap in future readiness. However, the ATS pathway excludes aspirational goals such as conditional NDCs, making the pathway a relatively conservative choice for evaluating potential policy alignment and impact.\n\nDue to its assumption of static technology costs and limited modelling of specific technology deployment, the ATS pathway has more limited applications in assessing the commercial or technological feasibility of specific transition strategies. Users interested in these applications may consider supplementing the ATS pathway with additional pathways that provide more detailed and dynamic technology cost and deployment projections.\n\nATS provides national targets. These targets are presented with fine granularity, enabling detailed national policy alignment assessments. The pathway also includes regional-level projections for generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. This data is relevant and useful for analyzing specific decarbonization levers and project investment pipelines within company plans, and supports region-specific benchmark, but does not enable country-specific comparisons of changes in generation or capacity.", "metric": [ "Emissions Intensity", "Capacity", @@ -81,22 +82,98 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Moderate increase", - "energyEfficiency": "Moderate improvement", - "energyDemand": "Significant increase", - "electrification": "Low or no change", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Moderate increase" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Moderate improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Significant increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], "policyTypes": [ - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": [ + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } ], - "technologyCostTrend": "Low or no change", - "emissionsScope": "CO2e (unspecified GHGs)", - "policyAmbition": "NDCs, unconditional only", - "technologyCostsDetail": "Capital costs, O&M, etc.", - "newTechnologiesIncluded": ["Green H2/ammonia", "SAF"], - "investmentNeeds": "By sector" - } + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "CO2e (unspecified GHGs)" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "NDCs, unconditional only" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Capital costs, O&M, etc." + } + ], + "newTechnologiesIncluded": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": ["Green H2/ammonia", "SAF"] + } + ], + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "By sector" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/asean-centre-for-energy/ACE-BAS-2024.json b/src/data/asean-centre-for-energy/ACE-BAS-2024.json index 1547ce38..dca5dbc4 100644 --- a/src/data/asean-centre-for-energy/ACE-BAS-2024.json +++ b/src/data/asean-centre-for-energy/ACE-BAS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "ACE-BAS-2024", "publication": { "title": { @@ -72,7 +72,8 @@ "technologies": [] } ], - "expertOverview": "#### Pathway Description\n\nThe ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE Baseline scenario (BAS) is a predictive pathway that extrapolates observed historic trends of the ASEAN Member States (AMS) energy systems into the future. It assumes a business-as-usual level of effort and specifically disregards modelling any policy interventions, even existing ones to meet national energy efficiency (EE) and renewable energy (RE) targets. Hence, it also excludes firm plant capacity additions based on power development plans (PDP). The extrapolation of past trends also implies that technology costs remain static and new technologies do not benefit from learning rates.\n\nAs a conservative baseline pathway, ACE BAS forecasts emissions at the ASEAN level to more than double by 2050.\n\n#### Core Drivers\n\nThe BAS pathway is driven by historical trends in energy consumption growth.\n\n*Economic growth:* Rising energy consumption in the BAS pathway is a function of region-wide population growth and rapid economic development. The total population in ASEAN is forecast to grow from 680 million in 2022 to 790 million in 2050. GDP growth across the region is projected to be 3.9% CAGR between that same period, along with further increases in electrification rate and clean cooking access.\n\nPolicy impacts are explicitly excluded from this model in order to provide a baseline case for modeling policy impacts, which are included in other ACE pathways such as the Regional Aspiration Scenario (RAS). Energy efficiency and technology costs are assumed to be static, leaving limited avenues for potential emissions reductions in this baseline pathway.\n\n#### Application to Transition Assessment\n\nThe ACE BAS pathway is intended to provide a base case for the ASEAN region, explicitly excluding policy impacts, technology cost declines, or efficiency improvements as avenues for emissions reductions. As a result, it provides a highly conversative reference, which may be used to place a floor on potential future rates of transition in the energy sector. As a single-region model, the BAS pathway is not associated with a global implied temperature rise, and should not be used as a quantitative benchmark for the ambition of climate targets.\n\nMisalignment to the baseline scenario can highlight companies or plans which do not keep pace with purely historical trends, without accounting for planned policy interventions. However, the scenario’s conservative limit its applications in assessing the ambition of corporate targets.\n\nFor transition assessment applications focused on assessing the commercial or technological feasibility of company plans, BAS’s assumptions of static technology costs and energy efficiency may be overly conservative compared to alternative business-as-usual pathways. Due to its deliberate exclusion of policy impacts, including existing policies, the BAS pathway should not be used for assessing the alignment of company strategies with existing or potential policies, and ACE provides alternate pathways for these applications.\n\nThe BAS provides benchmark data on 5-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, wind, solar, biomass, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. BAS provides data at the regional ASEAN level, which enables region-specific benchmarking but limits its applicability for assessing country-specific strategies.", + "pathwayDescription": "The ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE Baseline scenario (BAS) is a predictive pathway that extrapolates observed historic trends of the ASEAN Member States (AMS) energy systems into the future. It assumes a business-as-usual level of effort and specifically disregards modelling any policy interventions, even existing ones to meet national energy efficiency (EE) and renewable energy (RE) targets. Hence, it also excludes firm plant capacity additions based on power development plans (PDP). The extrapolation of past trends also implies that technology costs remain static and new technologies do not benefit from learning rates.\n\nAs a conservative baseline pathway, ACE BAS forecasts emissions at the ASEAN level to more than double by 2050.", + "transitionAssessment": "The ACE BAS pathway is intended to provide a base case for the ASEAN region, explicitly excluding policy impacts, technology cost declines, or efficiency improvements as avenues for emissions reductions. As a result, it provides a highly conversative reference, which may be used to place a floor on potential future rates of transition in the energy sector. As a single-region model, the BAS pathway is not associated with a global implied temperature rise, and should not be used as a quantitative benchmark for the ambition of climate targets.\n\nMisalignment to the baseline scenario can highlight companies or plans which do not keep pace with purely historical trends, without accounting for planned policy interventions. However, the scenario’s conservative limit its applications in assessing the ambition of corporate targets.\n\nFor transition assessment applications focused on assessing the commercial or technological feasibility of company plans, BAS’s assumptions of static technology costs and energy efficiency may be overly conservative compared to alternative business-as-usual pathways. Due to its deliberate exclusion of policy impacts, including existing policies, the BAS pathway should not be used for assessing the alignment of company strategies with existing or potential policies, and ACE provides alternate pathways for these applications.\n\nThe BAS provides benchmark data on 5-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, wind, solar, biomass, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. BAS provides data at the regional ASEAN level, which enables region-specific benchmarking but limits its applicability for assessing country-specific strategies.", "metric": [ "Emissions Intensity", "Capacity", @@ -81,16 +82,92 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Significant increase", - "energyEfficiency": "Minor improvement", - "energyDemand": "Significant increase", - "electrification": "Low or no change", - "policyTypes": ["None"], - "technologyCostTrend": "Low or no change", - "emissionsScope": "CO2e (unspecified GHGs)", - "policyAmbition": "No policies included", - "technologyCostsDetail": "Capital costs, O&M, etc.", - "newTechnologiesIncluded": ["No new technologies"], - "investmentNeeds": "By sector" - } + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Significant increase" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Minor improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Significant increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], + "policyTypes": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": ["None"] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "CO2e (unspecified GHGs)" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "No policies included" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Capital costs, O&M, etc." + } + ], + "newTechnologiesIncluded": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": ["No new technologies"] + } + ], + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "By sector" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/asean-centre-for-energy/ACE-CNS-2024.json b/src/data/asean-centre-for-energy/ACE-CNS-2024.json index b3105738..f9d883a0 100644 --- a/src/data/asean-centre-for-energy/ACE-CNS-2024.json +++ b/src/data/asean-centre-for-energy/ACE-CNS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "ACE-CNS-2024", "publication": { "title": { @@ -73,7 +73,8 @@ "technologies": [] } ], - "expertOverview": "#### Pathway Description\n\nThe ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The Carbon Neutral Scenario (CNS) is a normative pathway in which the ASEAN region achieves net-zero carbon emissions by 2050, both in energy and non-energy sectors. This pathway builds on the extensive set of stated and aspirational policies modelled in ACE’s Regional Aspiration Scenario (RAS), introducing further emissions constraints, accelerated low-emissions technology and low-carbon fuels availability, gradual retirement of some coal and gas technologies, and expanded low-emission power generation. It assumes that countries improve their energy efficiency according to their full potential, develop and deploy renewable energy sources according to their individual technical potential, and models capacity additions beyond existing power development plans (PDPs), prioritizing dispatch of renewable energy. The CNS pathway also models widespread adoption of carbon capture and storage (CCS) technology, and rapid scale-up of low-carbon fuels. The CNS pathway incorporates least-cost-optimization in its projections.\n\nCore Drivers\n\nThe CNS pathway combines widespread policy, rapid technology deployment, and explicit targets for emissions and emissions intensity reductions.\n\n*Policy:* The policies considered by CNS are equivalent to the RAS pathway, including nationally determined contributions, energy efficiency targets, and power development plans. These policies are a core driver of CNS projections but not differentiate it from the RAS pathway. \n\n*Emissions goals:* The CNS pathway introduces explicit emissions constraints, with specific emissions and emissions intensity reduction targets in power, industry, and transport beyond what is contained in existing policy and pledges. These constraints impose rapid emissions reductions across the full energy system. \n\n*Technology Deployment & Technology costs:* Emissions constraints and the least-cost approach led to a more rapid build-out of low-carbon power capacity than in other ACE pathways, including a more rapid deployment of technologies that are less mature or not yet established in the region (such as nuclear, geothermal, and tidal & wave power). However, the CNS pathway does not forecast changes in technology costs, limiting the potential impact of cost optimization. \n\nThe CNS pathway provides detailed information on demand and investment flows. The pathway forecasts very large increases in energy investment, with 55 billion USD a year between 2023 and 2030, and up to 371 bn USD between 2040-2050, more than twice the investment seen in the ACE RAS pathway.\n\n#### Application to Transition Assessment\n\nThe Carbon Neutrality Scenario is a normative pathway, which provides a useful trajectory for exploring rapid and ambitious transition across Southeast Asia. As a single-region model, CNS is not linked to a global emissions outcome and so cannot be used to quantify corporate ambition in terms of implied temperature rise – however, as a pathway with large scale emissions reductions by 2050, it can be used as a region-specific benchmark for high ambition strategies.\n\nAs a normative pathway, CNS includes significant interventions that exceed existing regional goals. As a result, (mis)alignment to the CNS pathway is less directly connected to regulatory risk or potential shifts in market share, as objectives within the CNS pathway may differ significantly from currently stated jurisdictional policies. Users interested in assessing the policy alignment or technological and commercial feasibility of corporate transition strategies should supplement CNS with pathways that provide more direct links to predicted policy and market conditions, such as ACE RAS and IEA APS.\n\nAs a cost-optimized pathway, the CNS pathway can be used to quantify investment needs associated with a rapid region-wide change and to contextualize the commercial and market feasibility of a corporate transition strategy. However, due to exclusion of technology cost changes over time in CNS, and users may wish to supplement it with options that provide more detailed and dynamic cost and deployment projections.\n\nThe CNS pathway includes regional-level projections for power generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. These are relevant and useful for analyzing specific decarbonization levers and project investment pipelines within company plans. However, CNS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", + "pathwayDescription": "The ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The Carbon Neutral Scenario (CNS) is a normative pathway in which the ASEAN region achieves net-zero carbon emissions by 2050, both in energy and non-energy sectors. This pathway builds on the extensive set of stated and aspirational policies modelled in ACE’s Regional Aspiration Scenario (RAS), introducing further emissions constraints, accelerated low-emissions technology and low-carbon fuels availability, gradual retirement of some coal and gas technologies, and expanded low-emission power generation. It assumes that countries improve their energy efficiency according to their full potential, develop and deploy renewable energy sources according to their individual technical potential, and models capacity additions beyond existing power development plans (PDPs), prioritizing dispatch of renewable energy. The CNS pathway also models widespread adoption of carbon capture and storage (CCS) technology, and rapid scale-up of low-carbon fuels. The CNS pathway incorporates least-cost-optimization in its projections.", + "transitionAssessment": "The Carbon Neutrality Scenario is a normative pathway, which provides a useful trajectory for exploring rapid and ambitious transition across Southeast Asia. As a single-region model, CNS is not linked to a global emissions outcome and so cannot be used to quantify corporate ambition in terms of implied temperature rise – however, as a pathway with large scale emissions reductions by 2050, it can be used as a region-specific benchmark for high ambition strategies.\n\nAs a normative pathway, CNS includes significant interventions that exceed existing regional goals. As a result, (mis)alignment to the CNS pathway is less directly connected to regulatory risk or potential shifts in market share, as objectives within the CNS pathway may differ significantly from currently stated jurisdictional policies. Users interested in assessing the policy alignment or technological and commercial feasibility of corporate transition strategies should supplement CNS with pathways that provide more direct links to predicted policy and market conditions, such as ACE RAS and IEA APS.\n\nAs a cost-optimized pathway, the CNS pathway can be used to quantify investment needs associated with a rapid region-wide change and to contextualize the commercial and market feasibility of a corporate transition strategy. However, due to exclusion of technology cost changes over time in CNS, and users may wish to supplement it with options that provide more detailed and dynamic cost and deployment projections.\n\nThe CNS pathway includes regional-level projections for power generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. These are relevant and useful for analyzing specific decarbonization levers and project investment pipelines within company plans. However, CNS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", "metric": [ "Emissions Intensity", "Capacity", @@ -82,27 +83,98 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Moderate decrease", - "energyEfficiency": "Significant improvement", - "energyDemand": "Minor increase", - "electrification": "Moderate increase", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Moderate decrease" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Significant improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Minor increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Moderate increase" + } + ], "policyTypes": [ - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": [ + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "CO2e (unspecified GHGs)" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "High ambition policies" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Capital costs, O&M, etc." + } ], - "technologyCostTrend": "Low or no change", - "emissionsScope": "CO2e (unspecified GHGs)", - "policyAmbition": "High ambition policies", - "technologyCostsDetail": "Capital costs, O&M, etc.", "newTechnologiesIncluded": [ - "Battery storage", - "CCUS", - "Green H2/ammonia", - "SAF" + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": ["Battery storage", "CCUS", "Green H2/ammonia", "SAF"] + } ], - "investmentNeeds": "By sector" - } + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "By sector" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/asean-centre-for-energy/ACE-RAS-2024.json b/src/data/asean-centre-for-energy/ACE-RAS-2024.json index 4e57929e..75b51c5d 100644 --- a/src/data/asean-centre-for-energy/ACE-RAS-2024.json +++ b/src/data/asean-centre-for-energy/ACE-RAS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "ACE-RAS-2024", "publication": { "title": { @@ -72,7 +72,8 @@ "technologies": [] } ], - "expertOverview": "#### Pathway Description\n\nThe ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE Regional Aspiration Scenario (RAS) is an exploratory pathway which assumes that every ASEAN member state fully achieves both its unconditional and conditional transition targets on time, including Nationally Determined Contributions (NDCs), national power development plans (PDPs), and the enhanced scenarios of the national energy roadmaps of each ASEAN member state (e.g., the Clean Energy Scenario in the Philippines). The RAS pathway includes the 2025 ASEAN Plan of Action for Energy Cooperation (APAEC) targets for renewable energy generation and energy efficiency. The RAS pathway extends the ACE ATS pathway, covering all policies included in ATS with the inclusion of additional aspirational goals. The RAS also differs from the ATS pathway in using a least-cost optimization approach, which results in both a faster build-out of renewable energy and lower utilization rates for fossil fuel power plans. \n\nEmissions remain stable in the RAS until 2030, after which they start to decrease very slightly at a rate of approximately 0.1% per year across the energy sector. Emissions from power generation decrease much more rapidly to approximately a third of 2022 emissions by 2050. Emissions in the industry and residential sectors also drop significantly compared to the ATS.\n\n#### Core Drivers\n\nThe RAS pathway is primarily driven by a range of policy mechanisms and cost optimization based on current technology costs.\n\n*Policy:* The RAS pathway adds conditional NDC targets, national targets from the enhanced scenario of the AMS energy roadmap, and APAEC 2025 regional targets on top of existing policies and unconditional NDCS (modeled in ACE ATS). This increases the target share of renewable energy in total energy production and requires a steeper reduction of energy intensity in transport and industry.\n\n*Technology Costs and Technology Deployment:* RAS incorporates a least-cost approach to power capacity additions and generation. This results in a continued shift towards lower-cost renewables generation and a reduction in utilization rates for fossil fuel power plants. However, the RAS pathway does not include potential changes in technology costs over time. \n\nThe RAS pathway provides information on demand shifts, investment flows, and technology deployment, but these factors are driven primarily by modeled policy shifts.\n\n#### Application to Transition Assessment\n\nACE RAS is an exploratory, policy-focused pathway, which provides useful values for assessing the alignment of corporate plans to region-wide policy impacts under conditions of significant policy action. As a region-specific pathway, ACE RAS is not directly linked to a global implied temperature rise, limiting its use as a quantitative benchmark for the ambition of climate targets. However, for companies that aim to align their strategies with national policy targets, the RAS can be used to assess the level of alignment between company ambition and relevant policy ambition.\n\nThe RAS is designed to support alignment assessments with potential future policies, making it a valuable tool for evaluating corporate strategies and investment pipelines against both national and regional ambitions. A misalignment with the RAS may signal potential exposure to future regulatory risks, such as non-compliance with evolving energy policies or reduced competitiveness in markets where low-carbon technologies are being prioritized. Alignment may indicate that a company is strategically positioned to benefit from market shifts. The RAS pathway includes the impacts of aspirational policies and targets which have not yet been implemented; therefore, misalignment does not necessarily imply exposure to current regulatory risk but rather a potential gap in future readiness. Due to its inclusion of aspirational goals, alignment to the RAS pathway gives a stronger indication that a company is keeping pace with national ambition than alignment to a more limited policy pathway such as ACE ATS.\n\nDue to its inclusion of least-cost-optimized projections, the RAS pathway has useful applications for assessing the commercial feasibility of transition strategies. Alignment or misalignment to the RAS pathway may indicate that a company is outpacing or lagging economy-wide optimal trends. However, due to RAS’s assumption of static technology costs, users may consider supplementing the RAS pathway with options that provide more detailed and dynamic technology cost and deployment projections.\n\nThe RAS pathway provides regional-level projections for generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, RAS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", + "pathwayDescription": "The ASEAN Centre for Energy (ACE) acts as the ASEAN energy data center and knowledge hub and produces the ACE ASEAN Energy Outlook providing energy pathways for Southeast Asia. The ACE Regional Aspiration Scenario (RAS) is an exploratory pathway which assumes that every ASEAN member state fully achieves both its unconditional and conditional transition targets on time, including Nationally Determined Contributions (NDCs), national power development plans (PDPs), and the enhanced scenarios of the national energy roadmaps of each ASEAN member state (e.g., the Clean Energy Scenario in the Philippines). The RAS pathway includes the 2025 ASEAN Plan of Action for Energy Cooperation (APAEC) targets for renewable energy generation and energy efficiency. The RAS pathway extends the ACE ATS pathway, covering all policies included in ATS with the inclusion of additional aspirational goals. The RAS also differs from the ATS pathway in using a least-cost optimization approach, which results in both a faster build-out of renewable energy and lower utilization rates for fossil fuel power plans. \n\nEmissions remain stable in the RAS until 2030, after which they start to decrease very slightly at a rate of approximately 0.1% per year across the energy sector. Emissions from power generation decrease much more rapidly to approximately a third of 2022 emissions by 2050. Emissions in the industry and residential sectors also drop significantly compared to the ATS.", + "transitionAssessment": "ACE RAS is an exploratory, policy-focused pathway, which provides useful values for assessing the alignment of corporate plans to region-wide policy impacts under conditions of significant policy action. As a region-specific pathway, ACE RAS is not directly linked to a global implied temperature rise, limiting its use as a quantitative benchmark for the ambition of climate targets. However, for companies that aim to align their strategies with national policy targets, the RAS can be used to assess the level of alignment between company ambition and relevant policy ambition.\n\nThe RAS is designed to support alignment assessments with potential future policies, making it a valuable tool for evaluating corporate strategies and investment pipelines against both national and regional ambitions. A misalignment with the RAS may signal potential exposure to future regulatory risks, such as non-compliance with evolving energy policies or reduced competitiveness in markets where low-carbon technologies are being prioritized. Alignment may indicate that a company is strategically positioned to benefit from market shifts. The RAS pathway includes the impacts of aspirational policies and targets which have not yet been implemented; therefore, misalignment does not necessarily imply exposure to current regulatory risk but rather a potential gap in future readiness. Due to its inclusion of aspirational goals, alignment to the RAS pathway gives a stronger indication that a company is keeping pace with national ambition than alignment to a more limited policy pathway such as ACE ATS.\n\nDue to its inclusion of least-cost-optimized projections, the RAS pathway has useful applications for assessing the commercial feasibility of transition strategies. Alignment or misalignment to the RAS pathway may indicate that a company is outpacing or lagging economy-wide optimal trends. However, due to RAS’s assumption of static technology costs, users may consider supplementing the RAS pathway with options that provide more detailed and dynamic technology cost and deployment projections.\n\nThe RAS pathway provides regional-level projections for generation and capacity on 5-year intervals and using a moderately detailed breakdown by energy sources such as coal, wind, solar, biomass, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, RAS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", "metric": [ "Emissions Intensity", "Capacity", @@ -81,22 +82,98 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Low or no change", - "energyEfficiency": "Significant improvement", - "energyDemand": "Moderate increase", - "electrification": "Low or no change", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Significant improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Moderate increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } + ], "policyTypes": [ - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": [ + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Low or no change" + } ], - "technologyCostTrend": "Low or no change", - "emissionsScope": "CO2e (unspecified GHGs)", - "policyAmbition": "NDCs incl. conditional targets", - "technologyCostsDetail": "Capital costs, O&M, etc.", - "newTechnologiesIncluded": ["Battery storage", "Green H2/ammonia", "SAF"], - "investmentNeeds": "By sector" - } + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "CO2e (unspecified GHGs)" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "NDCs incl. conditional targets" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "Capital costs, O&M, etc." + } + ], + "newTechnologiesIncluded": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": ["Battery storage", "Green H2/ammonia", "SAF"] + } + ], + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "South East Asia", + "value": "By sector" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/iea/IEA-APS-2024.json b/src/data/iea/IEA-APS-2024.json index 64bdfa8b..0f963970 100644 --- a/src/data/iea/IEA-APS-2024.json +++ b/src/data/iea/IEA-APS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "IEA-APS-2024", "publication": { "title": { @@ -316,8 +316,8 @@ "technologies": [] } ], - "pathwayOverview": "The Announced Pledges Scenario (APS) shows the future of the energy sector if all countries were to hit their aspirational targets, including national and regional net zero emissions pledges, on time and in full – in addition to their legislated policies (as described in STEPS). The APS has a 50% probability to not exceed 1.7°C by 2100. The difference between the APS and the Net Zero Emissions by 2050 scenario highlights the ambition gap between countries’ commitments and a 1.5°C pathway.", - "expertOverview": "#### Pathway Description\n\nThe Announced Pledges Scenario (APS) provides a pathway in which all countries achieve their aspirational energy transition goals, including national and regional net zero emissions pledges, in addition to their currently legislated policies. As an exploratory scenario, APS provides a detailed description of a possible future, but does not attempt to predict which announced policies will be implemented. The APS pathway corresponds to 1.7°C of warming by 2100 (50% probability). This is significantly lower than the IEA’s current policies scenario (STEPS), which forecasts 2.4°C of warming by 2100 (50% probability), but still higher than the IEA’s Net Zero scenario (NZE), which targets 1.5°C of warming by 2100 (50% probability). This indicates that the policies and pledges incorporated into APS significantly increase the pace of the transition compared to status quo conditions.\n\n#### Core Drivers\n\nIEA APS is primarily driven by large-scale policy action, continued cost decline for low-emissions technologies, and the introduction of new low-carbon technology.\n\n*Policy:* The APS assumes current nationally determined contributions (NDCs) are met, including, for example, emissions intensity goals and coal phase-out commitments. It includes national power development plans and targets for renewable energy additions or technology-based capacity mixes. In addition to explicit policies included in pledges, APS introduces additional measures to achieve aspirational policy goals. The APS models a carbon price, affecting electricity, industry and energy production sectors. The carbon tax is structured in three regional tiers: advanced economies, emerging markets and developing economies (EMDEs) with net-zero pledges, and EMDEs without pledges. \n\n*Technology costs:* IEA APS assumes significant cost declines in solar PV, onshore wind, and offshore wind power generation in most regions of the world and modest cost declines in nuclear power generation. These declines are mostly driven by reduced capital costs. The combined costs of fuel, CO2 prices, and operations and maintenance are projected to increase, making fossil-fuel based electricity less competitive over time.\n\nThe APS pathway provides detailed projections for demand shifts, infrastructure buildout, investment flow changes, and new technology deployment, but these factors are primarily caused by modelled policies and declining technology costs.\n\n#### Application to Transition Assessment\n\nAPS is a detailed, policy-focused, exploratory pathway. As a global integrated assessment model, APS provides a direct connection between the described pathway and a global temperature outcome. This makes it suitable for assessing the ambition of targets or plans against a 1.7°C outcome.\n\nDue to its strong policy focus, APS is well-suited to assessing the alignment of corporate targets, plans, and investment pipelines with the stated ambitions of the jurisdictions they operate in. Misalignment to the APS pathway can indicate potential risk that a company will fall out of line with future regulatory or economic policy and face declining market share as other segments of the energy sector are encouraged to grow. Conversely, alignment to APS may indicate that a company is well-positioned to take advantage of future policy and market dynamics. However, because APS includes aspirational pledges and policies which have not yet been implemented, misalignment to APS does not mean a company is automatically exposed to current regulatory risk.\n \nAPS provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, APS provides data at only the regional level, which limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", + "pathwayDescription": "The Announced Pledges Scenario (APS) shows the future of the energy sector if all countries were to hit their aspirational targets, including national and regional net zero emissions pledges, on time and in full – in addition to their legislated policies (as described in STEPS). The APS has a 50% probability to not exceed 1.7°C by 2100. The difference between the APS and the Net Zero Emissions by 2050 scenario highlights the ambition gap between countries’ commitments and a 1.5°C pathway.\n\nThe Announced Pledges Scenario (APS) provides a pathway in which all countries achieve their aspirational energy transition goals, including national and regional net zero emissions pledges, in addition to their currently legislated policies. As an exploratory scenario, APS provides a detailed description of a possible future, but does not attempt to predict which announced policies will be implemented. The APS pathway corresponds to 1.7°C of warming by 2100 (50% probability). This is significantly lower than the IEA’s current policies scenario (STEPS), which forecasts 2.4°C of warming by 2100 (50% probability), but still higher than the IEA’s Net Zero scenario (NZE), which targets 1.5°C of warming by 2100 (50% probability). This indicates that the policies and pledges incorporated into APS significantly increase the pace of the transition compared to status quo conditions.", + "transitionAssessment": "APS is a detailed, policy-focused, exploratory pathway. As a global integrated assessment model, APS provides a direct connection between the described pathway and a global temperature outcome. This makes it suitable for assessing the ambition of targets or plans against a 1.7°C outcome.\n\nDue to its strong policy focus, APS is well-suited to assessing the alignment of corporate targets, plans, and investment pipelines with the stated ambitions of the jurisdictions they operate in. Misalignment to the APS pathway can indicate potential risk that a company will fall out of line with future regulatory or economic policy and face declining market share as other segments of the energy sector are encouraged to grow. Conversely, alignment to APS may indicate that a company is well-positioned to take advantage of future policy and market dynamics. However, because APS includes aspirational pledges and policies which have not yet been implemented, misalignment to APS does not mean a company is automatically exposed to current regulatory risk.\n \nAPS provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, APS provides data at only the regional level, which limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", "metric": [ "Emissions Intensity", "Capacity", @@ -326,29 +326,99 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Moderate decrease", - "energyEfficiency": "Significant improvement", - "energyDemand": "Low or no change", - "electrification": "Moderate increase", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate decrease" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Low or no change" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate increase" + } + ], "policyTypes": [ - "Carbon price", - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "Global", + "value": [ + "Carbon price", + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Decrease" + } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "CO2" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "NDCs incl. conditional targets" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Capital costs, O&M, etc." + } ], - "technologyCostTrend": "Decrease", - "emissionsScope": "CO2", - "policyAmbition": "NDCs incl. conditional targets", - "technologyCostsDetail": "Capital costs, O&M, etc.", "newTechnologiesIncluded": [ - "CCUS", - "DAC", - "Green H2/ammonia", - "SAF", - "Battery storage" + { + "sector": "cross-sector", + "geography": "Global", + "value": ["CCUS", "DAC", "Green H2/ammonia", "SAF", "Battery storage"] + } ], - "investmentNeeds": "By tech, part of value chain" - } + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "By tech, part of value chain" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/iea/IEA-NZE-2024.json b/src/data/iea/IEA-NZE-2024.json index 5aa1447e..b8d1fdec 100644 --- a/src/data/iea/IEA-NZE-2024.json +++ b/src/data/iea/IEA-NZE-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "IEA-NZE-2024", "publication": { "title": { @@ -98,7 +98,8 @@ "technologies": [] } ], - "expertOverview": "#### Pathway Description\n\nThe IEA Net Zero Emissions by 2050 (NZE) Scenario is a normative 1.5 aligned pathway, outlining how the global energy sector can reach net zero CO2 emissions by 2050. It assumes that advanced economies achieve net zero earlier (by mid 2040s), with emerging markets following by 2050. As a normative scenario, the NZE provides a detailed description of one pathway to reach a targeted future state, but it does not attempt to predict which policies or outcomes are most probable. The NZE pathway corresponds to a 50% probability of 1.5°C of warming by 2100. This is a more ambitious outcome than the IEA’s Announced Pledges Scenario (APS), which projects 1.7°C of warming by 2100 (50% probability), indicating a significant gap remains between announced governmental pledges and the trajectory described by the NZE pathway.\n\n#### Core Drivers\n\nThe NZE pathway is driven through a combination of wide-reaching policy, significant cost declines for existing green technologies, significant efficiency improvement and demand shifts, and the introduction of significant new technologies.\n\n*Policy:* The NZE pathway expands upon the policies considered in the APS pathway, most notably by introducing a more significant global carbon price for power, industry, and transport. A carbon price is introduced in all regions by 2030, with prices rising to USD$250/tCO2 in advanced economies, and USD$200/tCO2 in major emerging markets by 2050. Additionally, while the IEA NZE makes note of how the phase-out of fossil fuel subsidies need to be carefully designed to limit impacts on household budgets, they are largely removed in the NZE by 2030.\n\n*Technology costs:* The IEA NZE pathway assumes an S-curve trajectory of cost decline, with rapid early reductions as deployment scales up, followed by gradual flattening. This pattern applies across most renewables and transition technologies, including solar PV, offshore wind, battery EVs, and hydrogen fuel cells.\n\n*Technology shifts:* The NZE pathway is primarily driven by the rapid deployment of clean technology. Renewable energy capacity, led by solar PV and wind, approximately triples between 2023 and 2030, and reaches nearly 90% of global electricity by 2050. EVs account for approximately 60% of new car sales by 2030, and dominate the global fleet by 2050. Additionally, CCUS scales from 40Mt in 2023 to over 1Gt in 2030, and over 6Gt in 2050. This rapid expansion of clean technologies enables the peak of unabated fossil-fuel demand before 2030 and its subsequent decline by about 80% by 2050, marking the global phase down of coal, oil, and gas. \n\n*Falling energy demand:* The NZE pathway models significant energy efficiency improvements, alongside meaningful shifts in individual consumption patterns, which significantly reduce potential energy demand. Total energy consumption in the NZE pathway falls from well over 400 EJ in 2023 to under 350 EJ in 2050, a much larger decline than is observed in most similar pathways.\n\n#### Application to Transition Assessment\n\nIEA NZE is a global, temperature constrained normative scenario. As such, the NZE provides a direct connection between the described pathway and a global temperature outcome. It is well-suited for science-based target setting and net zero strategy alignment and is one of the most widely-used pathways to benchmark strategies against a projected 1.5°C temperature rise.\n\nAs a normative pathway, NZE models a potential pathway to achieve a target temperature outcome and introduces significant new policies and market shifts as part of the process. As a result, (mis)alignment to the NZE pathway is less directly connected to regulatory risk or potential shifts in market share, as objectives within the NZE pathway may differ significantly from currently stated jurisdictional goals. Users interested in assessing the policy alignment or technological and commercial feasibility of corporate transition strategies should review where underlying NZE assumptions diverge from current trends, and consider supplementing NZE with additional pathways that model a range of different policies and technology developments.\n\nThe NZE provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers. However, NZE provides data only at the global level. This limits its suitability for assessing region- or jurisdiction-specific implications for company transition strategies.", + "pathwayDescription": "The IEA Net Zero Emissions by 2050 (NZE) Scenario is a normative 1.5 aligned pathway, outlining how the global energy sector can reach net zero CO2 emissions by 2050. It assumes that advanced economies achieve net zero earlier (by mid 2040s), with emerging markets following by 2050. As a normative scenario, the NZE provides a detailed description of one pathway to reach a targeted future state, but it does not attempt to predict which policies or outcomes are most probable. The NZE pathway corresponds to a 50% probability of 1.5°C of warming by 2100. This is a more ambitious outcome than the IEA’s Announced Pledges Scenario (APS), which projects 1.7°C of warming by 2100 (50% probability), indicating a significant gap remains between announced governmental pledges and the trajectory described by the NZE pathway.", + "transitionAssessment": "IEA NZE is a global, temperature constrained normative scenario. As such, the NZE provides a direct connection between the described pathway and a global temperature outcome. It is well-suited for science-based target setting and net zero strategy alignment and is one of the most widely-used pathways to benchmark strategies against a projected 1.5°C temperature rise.\n\nAs a normative pathway, NZE models a potential pathway to achieve a target temperature outcome and introduces significant new policies and market shifts as part of the process. As a result, (mis)alignment to the NZE pathway is less directly connected to regulatory risk or potential shifts in market share, as objectives within the NZE pathway may differ significantly from currently stated jurisdictional goals. Users interested in assessing the policy alignment or technological and commercial feasibility of corporate transition strategies should review where underlying NZE assumptions diverge from current trends, and consider supplementing NZE with additional pathways that model a range of different policies and technology developments.\n\nThe NZE provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers. However, NZE provides data only at the global level. This limits its suitability for assessing region- or jurisdiction-specific implications for company transition strategies.", "metric": [ "Emissions Intensity", "Capacity", @@ -107,29 +108,99 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Significant decrease", - "energyEfficiency": "Significant improvement", - "energyDemand": "Significant decrease", - "electrification": "Significant increase", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant decrease" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant decrease" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant increase" + } + ], "policyTypes": [ - "Carbon price", - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "Global", + "value": [ + "Carbon price", + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Decrease" + } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "CO2" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "High ambition policies" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Capital costs, O&M, etc." + } ], - "technologyCostTrend": "Decrease", - "emissionsScope": "CO2", - "policyAmbition": "High ambition policies", - "technologyCostsDetail": "Capital costs, O&M, etc.", "newTechnologiesIncluded": [ - "CCUS", - "DAC", - "Green H2/ammonia", - "SAF", - "Battery storage" + { + "sector": "cross-sector", + "geography": "Global", + "value": ["CCUS", "DAC", "Green H2/ammonia", "SAF", "Battery storage"] + } ], - "investmentNeeds": "By tech, part of value chain" - } + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "By tech, part of value chain" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/data/iea/IEA-STEPS-2024.json b/src/data/iea/IEA-STEPS-2024.json index 414ae794..f7e213bb 100644 --- a/src/data/iea/IEA-STEPS-2024.json +++ b/src/data/iea/IEA-STEPS-2024.json @@ -1,5 +1,5 @@ { - "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v1.json", + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", "id": "IEA-STEPS-2024", "publication": { "title": { @@ -316,8 +316,8 @@ "technologies": [] } ], - "pathwayOverview": "The Stated Policies Scenario (STEPS) provides a sense of the energy sector’s direction of travel today, based on the latest market data, technology costs and in-depth analysis of the prevailing policy settings in countries around the world. STEPS is complemented by APS, a scenario which assumes that countries’ aspirational targets will be met in full. The difference between STEPS and APS highlights a commitment gap.", - "expertOverview": "#### Pathway Description\n\nThe Stated Policies Scenario (STEPS) projects the energy sector’s current direction of travel, based on the latest market data, technology costs and in-depth analysis of the stated policies in countries around the world. STEPS provides a detailed projection of future outcomes based on current conditions, but does not attempt to predict which existing policies or economic conditions are more or less likely to change. The STEPS pathway corresponds to 2.4°C of warming by 2100 (50% probability). This is significantly higher than the IEA’s announced pledges scenario (APS), which projects 1.7°C of warming by 2100 (50% probability), and the IEA’s Net Zero scenario (NZE), which targets 1.5°C of warming by 2100 (50% probability).\n\n#### Core Drivers\n\nThe STEPS pathway is primarily driven by existing policy dynamics and forecast future declines in technology costs, which drive large-scale deployment of mature low-carbon technologies.\n\n*Policy:* The STEPS pathway models stated policies, such as energy subsidies or power development plans, but does not include aspirational policy goals that lack specific provisions for their implementation, such as long-term net-zero pledges. The STEPS pathway includes only existing or scheduled carbon pricing schemes for electricity, industry, and energy production sectors, and does not introduce a global carbon price, as is done in the APS and NZE pathway.\n\n*Technology costs:* Depending on the region, the IEA STEPS assumes moderate to significant cost declines in solar PV, onshore wind, and offshore wind power generation and modest cost declines in nuclear power generation. These declines are mostly driven by falling capital costs. The pathway projects moderate increases in fossil fuel power generation costs in advanced economies, but mixed trends in EMDEs. \n\nThe STEPS pathway provides detailed projections for demand shifts, infrastructure buildout, investment flows, and new technology deployment, but these factors are primarily caused by modelled existing policies and declining technology costs. \n\n#### Application to Transition Assessment\n\nAs a predictive pathway based on detailed modeling of current stated policies, market conditions, and technology trends, STEPS provides a valuable base-case projection for benchmarking the impact of future transition impacts in the absence of significant policy or market shifts. As a global integrated assessment model, STEPS provides a direct connection between the described pathway and a global temperature outcome. While this allows assessing the ambition of stated targets or plans against a 2.4°C outcome, this high level of implied temperature rise is not applicable for most institutional targets.\n\nDue to its strong focus on modeling existing policy and market trends, STEPS is well-suited to assessing the alignment of corporate targets, plans, and investment pipelines against a conservative forecast of market shifts. Misalignment to the STEPS pathway indicates that a company or plan may already be lagging forecasted rates of change, potentially exposing them to regulatory risks or declining market share as other segments of the energy sector grow. As STEPS only models stated policies with clear implementation plans, and does not include aspirational pledges such as those modeled in APS, it can be viewed as a conservative projection of potential market dynamics, and plans that exceed the rate of transition forecast in STEPS may still be exposed to future risks if new policies or technology shifts occur.\n\nSTEPS provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, STEPS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", + "pathwayDescription": "The Stated Policies Scenario (STEPS) provides a sense of the energy sector’s direction of travel today, based on the latest market data, technology costs and in-depth analysis of the prevailing policy settings in countries around the world. STEPS is complemented by APS, a scenario which assumes that countries’ aspirational targets will be met in full. The difference between STEPS and APS highlights a commitment gap.\n\nThe Stated Policies Scenario (STEPS) projects the energy sector’s current direction of travel, based on the latest market data, technology costs and in-depth analysis of the stated policies in countries around the world. STEPS provides a detailed projection of future outcomes based on current conditions, but does not attempt to predict which existing policies or economic conditions are more or less likely to change. The STEPS pathway corresponds to 2.4°C of warming by 2100 (50% probability). This is significantly higher than the IEA’s announced pledges scenario (APS), which projects 1.7°C of warming by 2100 (50% probability), and the IEA’s Net Zero scenario (NZE), which targets 1.5°C of warming by 2100 (50% probability).", + "transitionAssessment": "As a predictive pathway based on detailed modeling of current stated policies, market conditions, and technology trends, STEPS provides a valuable base-case projection for benchmarking the impact of future transition impacts in the absence of significant policy or market shifts. As a global integrated assessment model, STEPS provides a direct connection between the described pathway and a global temperature outcome. While this allows assessing the ambition of stated targets or plans against a 2.4°C outcome, this high level of implied temperature rise is not applicable for most institutional targets.\n\nDue to its strong focus on modeling existing policy and market trends, STEPS is well-suited to assessing the alignment of corporate targets, plans, and investment pipelines against a conservative forecast of market shifts. Misalignment to the STEPS pathway indicates that a company or plan may already be lagging forecasted rates of change, potentially exposing them to regulatory risks or declining market share as other segments of the energy sector grow. As STEPS only models stated policies with clear implementation plans, and does not include aspirational pledges such as those modeled in APS, it can be viewed as a conservative projection of potential market dynamics, and plans that exceed the rate of transition forecast in STEPS may still be exposed to future risks if new policies or technology shifts occur.\n\nSTEPS provides benchmark data on 5- and 10-year intervals and uses a moderately detailed breakdown of specific generation technologies such as coal, onshore wind, offshore wind, solar PV, and geothermal. This level of detail makes it well-suited to assessing specific decarbonization levers and project pipelines within company plans. However, STEPS provides data at only the regional level. This limits its suitability for assessing companies which have operations concentrated in one or few countries, as it allows comparison only to the regional average.", "metric": [ "Emissions Intensity", "Capacity", @@ -326,29 +326,99 @@ "Absolute Emissions" ], "keyFeatures": { - "emissionsTrajectory": "Minor decrease", - "energyEfficiency": "Moderate improvement", - "energyDemand": "Moderate increase", - "electrification": "Moderate increase", + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Minor decrease" + } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate improvement" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate increase" + } + ], "policyTypes": [ - "Carbon price", - "Phaseout dates", - "Subsidies", - "Target technology shares", - "Performance standards", - "Other" + { + "sector": "cross-sector", + "geography": "Global", + "value": [ + "Carbon price", + "Phaseout dates", + "Subsidies", + "Target technology shares", + "Performance standards", + "Other" + ] + } + ], + "technologyCostTrend": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Decrease" + } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "CO2" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Current and drafted policies" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Capital costs, O&M, etc." + } ], - "technologyCostTrend": "Decrease", - "emissionsScope": "CO2", - "policyAmbition": "Current and drafted policies", - "technologyCostsDetail": "Capital costs, O&M, etc.", "newTechnologiesIncluded": [ - "CCUS", - "DAC", - "Green H2/ammonia", - "SAF", - "Battery storage" + { + "sector": "cross-sector", + "geography": "Global", + "value": ["CCUS", "DAC", "Green H2/ammonia", "SAF", "Battery storage"] + } ], - "investmentNeeds": "By tech, part of value chain" - } + "investmentNeeds": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "By tech, part of value chain" + } + ] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] } diff --git a/src/schema/pathwayMetadata.v2.json b/src/schema/pathwayMetadata.v2.json index b24377c5..a48a6734 100644 --- a/src/schema/pathwayMetadata.v2.json +++ b/src/schema/pathwayMetadata.v2.json @@ -97,7 +97,7 @@ "description": "How the pathway can be applied to transition assessment. In the v1 corpus this is the '#### Application to Transition Assessment' section of expertOverview; null means no guidance is available.", "type": ["string", "null"], "pattern": "\\.$", - "maxLength": 2500 + "maxLength": 3000 }, "metric": { "type": "array", diff --git a/src/utils/validateData.test.tsx b/src/utils/validateData.test.tsx index 0e53e3df..458aad9b 100644 --- a/src/utils/validateData.test.tsx +++ b/src/utils/validateData.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { validateDataCollect, FileEntry } from "./validateData"; import { PathwayMetadataType } from "../types"; import pathwayMetadata from "../schema/pathwayMetadata.v1.json" with { type: "json" }; +import pathwayMetadataV2 from "../schema/pathwayMetadata.v2.json" with { type: "json" }; import { commonSchemas } from "../schema/common"; function ok(entry: FileEntry | FileEntry[]) { @@ -30,7 +31,24 @@ function fail(entry: FileEntry | FileEntry[], rx?: RegExp | string) { } } +/** Same as {@link fail}, but routes the document against v2. */ +function fail2(entry: FileEntry | FileEntry[], rx?: RegExp | string) { + const arr = Array.isArray(entry) ? entry : [entry]; + const { invalid } = validateDataCollect( + arr, + pathwayMetadataV2, + commonSchemas, + ); + expect(invalid.length).toBeGreaterThan(0); + if (rx) { + const messages = invalid.flatMap((p) => p.errors).join("\n"); + expect(messages).toMatch(rx); + } +} + import basePathway from "../../testdata/valid/pathwayMetadata_standard.json" assert { type: "json" }; +import v2Full from "../../testdata/valid/pathwayMetadata_v2_full.json" assert { type: "json" }; +import v2Minimal from "../../testdata/valid/pathwayMetadata_v2_minimal.json" assert { type: "json" }; describe("pathway schema enforces expected limits", () => { it("accepts a valid object", () => { @@ -287,3 +305,88 @@ describe("pathway schema enforces expected limits", () => { ); }); }); + +describe("v1 and v2 documents coexist (#858)", () => { + const v1Entry: FileEntry = { name: "v1.json", data: basePathway }; + const v2Entry: FileEntry = { name: "v2.json", data: v2Full }; + const v2MinEntry: FileEntry = { name: "v2-min.json", data: v2Minimal }; + + it("validates a v1 document against v1", () => { + const { valid, invalid } = validateDataCollect( + [v1Entry], + pathwayMetadata, + commonSchemas, + ); + expect(invalid).toHaveLength(0); + expect(valid).toHaveLength(1); + }); + + it("validates v2 documents against v2, including all-empty keyFeatures", () => { + const { valid, invalid } = validateDataCollect( + [v2Entry, v2MinEntry], + pathwayMetadataV2, + commonSchemas, + ); + expect(invalid).toHaveLength(0); + expect(valid).toHaveLength(2); + }); + + it("routes by the document's own $schema, so a mixed corpus splits cleanly", () => { + const mixed = [v1Entry, v2Entry]; + expect( + validateDataCollect(mixed, pathwayMetadata, commonSchemas).valid.map( + (r) => r.name, + ), + ).toEqual(["v1.json"]); + expect( + validateDataCollect(mixed, pathwayMetadataV2, commonSchemas).valid.map( + (r) => r.name, + ), + ).toEqual(["v2.json"]); + }); + + it("SILENTLY DROPS documents of the other version — neither valid nor invalid", () => { + // Load-bearing behaviour, not a bug to fix here: validateDataCollect filters + // entries to the one $id it was handed. It is what lets v1 and v2 sit in + // src/data together, and it is also why pointing the loader at v2 makes every + // un-migrated v1 file disappear from the app with no error. Whatever calls + // this has to report the count it dropped, or 49 missing pathways look like a + // data bug. + const { valid, invalid } = validateDataCollect( + [v2Entry], + pathwayMetadata, + commonSchemas, + ); + expect(valid).toHaveLength(0); + expect(invalid).toHaveLength(0); + }); + + it("keeps v2's scoped keyFeatures out of v1 and vice versa", () => { + // A v1-shaped scalar is not a legal v2 value... + fail2( + { + name: "scalar-in-v2.json", + data: { ...v2Full, keyFeatures: basePathway.keyFeatures }, + }, + /keyFeatures/, + ); + // ...and a v2-shaped array is not a legal v1 value. + fail( + { + name: "array-in-v1.json", + data: { ...basePathway, keyFeatures: v2Full.keyFeatures }, + }, + /keyFeatures/, + ); + }); + + it("rejects a v2 document that still carries the removed overview fields", () => { + fail2( + { + name: "expert-overview-in-v2.json", + data: { ...v2Full, expertOverview: "Should not be here." }, + }, + /must NOT have additional properties/, + ); + }); +}); diff --git a/testdata/valid/pathwayMetadata_v2_full.json b/testdata/valid/pathwayMetadata_v2_full.json new file mode 100644 index 00000000..3ed82566 --- /dev/null +++ b/testdata/valid/pathwayMetadata_v2_full.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", + "id": "pathway-v2-full", + "name": { "full": "Full V2 Pathway", "short": "Full V2" }, + "publication": { + "title": { "full": "Example Title", "short": "Example" }, + "subtitle": "Exercising every v2 shape", + "author": ["Doe, Jane"], + "publisher": { "full": "TransitionZero" }, + "year": 2024, + "month": 6, + "day": 1, + "city": "London", + "license": "CC BY 4.0", + "links": [{ "description": "Report", "url": "https://www.example.com/" }] + }, + "description": "A v2 pathway exercising multi-scope key features.", + "pathwayType": "Normative", + "modelTempIncrease": 1.5, + "modelYearStart": 2020, + "modelYearEnd": 2050, + "modelYearNetzero": 2050, + "geography": { + "global": true, + "regions": { + "South East Asia": ["ID", "TH", "VN"], + "North America": ["CA", "MX", "US"] + }, + "country": ["SG"] + }, + "sectors": [ + { "name": "Power", "technologies": ["Solar", "Wind", "Coal"] }, + { "name": "Steel", "technologies": ["Hydrogen Use"] } + ], + "pathwayDescription": "A pathway whose key features vary by sector and geography, used to exercise the scoped-entry shape end to end.", + "transitionAssessment": "Use this fixture wherever a v2 document with more than one scope per field is needed.", + "metric": [ + "Emissions Intensity", + "Capacity", + "Generation", + "Technology Mix", + "Absolute Emissions" + ], + "keyFeatures": { + "emissionsTrajectory": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate decrease" + }, + { + "sector": "Power", + "geography": "South East Asia", + "value": "Significant decrease" + }, + { "sector": "Steel", "geography": "TH", "value": "Minor decrease" } + ], + "energyEfficiency": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Moderate improvement" + }, + { + "sector": "Steel", + "geography": "North America", + "value": "No information" + } + ], + "energyDemand": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Minor increase" + } + ], + "electrification": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Significant increase" + }, + { "sector": "Power", "geography": "SG", "value": "Moderate increase" } + ], + "policyTypes": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": ["Carbon price", "Subsidies"] + }, + { + "sector": "Power", + "geography": "South East Asia", + "value": ["Target technology shares", "Phaseout dates", "Other"] + } + ], + "technologyCostTrend": [ + { "sector": "cross-sector", "geography": "Global", "value": "Decrease" } + ], + "emissionsScope": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "CO2e (Kyoto)" + } + ], + "policyAmbition": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "High ambition policies" + }, + { + "sector": "Steel", + "geography": "North America", + "value": "Current/legislated policies" + } + ], + "technologyCostsDetail": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": "Capital costs, O&M, etc." + } + ], + "newTechnologiesIncluded": [ + { + "sector": "cross-sector", + "geography": "Global", + "value": ["CCUS", "DAC", "Green H2/ammonia", "Battery storage"] + }, + { + "sector": "Steel", + "geography": "Global", + "value": ["No new technologies"] + } + ], + "investmentNeeds": [] + }, + "coreDrivers": { + "policies": "Carbon pricing across both covered sectors.", + "emissionsTargets": "Net zero by 2050 with interim 2030 milestones.", + "technologyCosts": null, + "investmentChange": "Investment roughly doubles by 2040.", + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [ + { + "dependency_name": "Infrastructure and logistics", + "dependency_description": "Grid buildout must keep pace with renewable additions.", + "sector": "Power", + "evidence_type": "Quantitative" + }, + { + "dependency_name": "Technology", + "dependency_description": "Hydrogen direct reduction must reach commercial scale.", + "sector": "Steel", + "evidence_type": "Qualitative" + } + ] +} diff --git a/testdata/valid/pathwayMetadata_v2_minimal.json b/testdata/valid/pathwayMetadata_v2_minimal.json new file mode 100644 index 00000000..6ea9eb22 --- /dev/null +++ b/testdata/valid/pathwayMetadata_v2_minimal.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", + "id": "pathway-v2-minimal", + "name": { "full": "Minimal V2 Pathway" }, + "publication": { + "title": { "full": "Example Title" }, + "publisher": { "full": "TransitionZero" }, + "year": 2024 + }, + "description": "A minimal v2 pathway file that passes schema validation.", + "pathwayType": "Exploratory", + "geography": { "regions": { "South East Asia": [] } }, + "sectors": [{ "name": "Other", "technologies": [] }], + "pathwayDescription": null, + "metric": ["Capacity"], + "keyFeatures": { + "emissionsTrajectory": [], + "energyEfficiency": [], + "energyDemand": [], + "electrification": [], + "policyTypes": [], + "technologyCostTrend": [], + "emissionsScope": [], + "policyAmbition": [], + "technologyCostsDetail": [], + "newTechnologiesIncluded": [], + "investmentNeeds": [] + }, + "coreDrivers": { + "policies": null, + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null + }, + "dependencies": [] +} From f82abe63a3d4f5ed0615de1010dd9cac7af4509c Mon Sep 17 00:00:00 2001 From: repro Date: Thu, 13 Aug 2026 16:12:02 +0200 Subject: [PATCH 5/6] feat(search): read v2 scoped keyFeatures in search and rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the loader at pathwayMetadata.v2 and moves every consumer onto the scoped {sector, geography, value} shape. Only v2 documents load, so the app now shows the 7 migrated ACE/IEA pathways; the 49 still on v1 are skipped by $schema routing until they migrate. That skip is silent by construction — validateDataCollect drops non-matching documents as neither valid nor invalid — so pathwayMetadata.ts counts and logs them. Without it, 49 missing pathways look like a data bug. New src/utils/keyFeatureScope.ts answers "which entries apply to what the user is looking at": containment on both axes, where cross-sector means the union of the pathway's own declared sectors (not a universal match), and a geography scope contains a query when the query's ISO set is a subset of the entry's. Broader answers narrower, never the reverse. Deliberately no cost model, no ranking, no fallback — that is #869, and it is what will turn a non-match at the queried scope into a ranked broader-scope match rather than an exclusion. The emissionsTrajectory and policyAmbition facets now match like the sector and metric facets — ANY/ALL over a value list, empty list meaning absent — restricted to the entries whose scope contains the active sector/geography selection. The two near-identical 30-line arms collapse into one helper. concrete.includes(v) against an array is always false, so selecting either returned zero pathways, and option building emitted "[object Object]". Neither arm had any test coverage before — no filterPathways test passed either filter — which is why the whole suite stayed green while both were broken. Adding that coverage caught a regression that would otherwise have shipped: in v1 a missing field contributed undefined, which buildOptionsFromValues read as the absent bucket, but in v2 an empty entry array contributes no elements, so the "None" option disappeared from both dropdowns while the filter still honoured the token. Fixed with withAbsentOption, matching how the sector facet does it. Rendering keeps its current output. KeyFeatures reads through widestValue, a deliberately provisional stand-in for #869's resolver: it picks the value at the broadest declared scope, which reproduces v1 exactly for codemod-migrated data (one entry, at its widest scope). #859 replaces it and adds the badge naming the scope. PathwayDetailPage renders pathwayDescription and transitionAssessment under separate subheadings. v1's single expertOverview blob was three sections, so rendering only the description would have visibly dropped the Application to Transition Assessment text. The "Expert Overview" heading is left alone; naming is #859's call. Verified against the running app: 7 pathways load, the skip warning fires without error, and all 11 key features on IEA-NZE render values matching the source file — including the multi-select branch, which degrades silently rather than throwing when handed the wrong shape. Refs #858. Enables #869, #859. Co-Authored-By: Claude Opus 5 --- src/components/KeyFeatures.test.tsx | 39 ++- src/components/KeyFeatures.tsx | 7 +- src/data/pathwayMetadata.ts | 32 ++- src/pages/PathwayDetailPage.tsx | 18 +- src/types/index.ts | 6 +- src/utils/keyFeatureScope.test.ts | 285 +++++++++++++++++++++ src/utils/keyFeatureScope.ts | 215 ++++++++++++++++ src/utils/searchUtils.scopedFacets.test.ts | 275 ++++++++++++++++++++ src/utils/searchUtils.ts | 127 +++++---- 9 files changed, 916 insertions(+), 88 deletions(-) create mode 100644 src/utils/keyFeatureScope.test.ts create mode 100644 src/utils/keyFeatureScope.ts create mode 100644 src/utils/searchUtils.scopedFacets.test.ts diff --git a/src/components/KeyFeatures.test.tsx b/src/components/KeyFeatures.test.tsx index d452faf1..b4311262 100644 --- a/src/components/KeyFeatures.test.tsx +++ b/src/components/KeyFeatures.test.tsx @@ -3,19 +3,28 @@ import { render, screen } from "@testing-library/react"; import KeyFeatures from "./KeyFeatures"; import type { PathwayMetadataType } from "../types"; +/** + * v2 scopes every keyFeature as {sector, geography, value} entries (#858). These + * fixtures use a single widest-scope entry per field — what the codemod produces — + * so the rendering assertions below still describe v1's output. + */ +const wide = (value: T) => [ + { sector: "cross-sector", geography: "Global", value }, +]; + const mockKeyFeatures: PathwayMetadataType["keyFeatures"] = { - emissionsScope: "CO2", - emissionsTrajectory: "Moderate decrease", - energyEfficiency: "Minor improvement", - energyDemand: "Low or no change", - electrification: "Moderate increase", - policyTypes: ["Carbon price", "Subsidies"], - policyAmbition: "NDCs incl. conditional targets", - newTechnologiesIncluded: ["CCUS", "Battery storage"], - technologyCostTrend: "Decrease", - technologyCostsDetail: "Total costs", - investmentNeeds: "By technology", -}; + emissionsScope: wide("CO2"), + emissionsTrajectory: wide("Moderate decrease"), + energyEfficiency: wide("Minor improvement"), + energyDemand: wide("Low or no change"), + electrification: wide("Moderate increase"), + policyTypes: wide(["Carbon price", "Subsidies"]), + policyAmbition: wide("NDCs incl. conditional targets"), + newTechnologiesIncluded: wide(["CCUS", "Battery storage"]), + technologyCostTrend: wide("Decrease"), + technologyCostsDetail: wide("Total costs"), + investmentNeeds: wide("By technology"), +} as unknown as PathwayMetadataType["keyFeatures"]; describe("KeyFeatures", () => { it("renders all four group headers", () => { @@ -86,7 +95,7 @@ describe("KeyFeatures", () => { it("sentiment feature: unfavorable value uses red scale color", () => { const unfavorable = { ...mockKeyFeatures, - emissionsTrajectory: "Significant increase", + emissionsTrajectory: wide("Significant increase"), } as unknown as PathwayMetadataType["keyFeatures"]; render(); @@ -101,7 +110,9 @@ describe("KeyFeatures", () => { it("sentiment feature: no-info value renders a Badge, not a colored text span", () => { const noInfo = { ...mockKeyFeatures, - emissionsTrajectory: undefined, + // v2's way of saying "nothing authored at any scope" is an empty entry + // array, not a missing field — the field stays required. + emissionsTrajectory: [], } as unknown as PathwayMetadataType["keyFeatures"]; render(); diff --git a/src/components/KeyFeatures.tsx b/src/components/KeyFeatures.tsx index d68ce2a9..ab65907b 100644 --- a/src/components/KeyFeatures.tsx +++ b/src/components/KeyFeatures.tsx @@ -1,6 +1,7 @@ import React from "react"; import { PathwayMetadataType } from "../types"; import { getKeyFeatureTooltip } from "../utils/tooltipUtils"; +import { widestValue } from "../utils/keyFeatureScope"; import TextWithTooltip from "./TextWithTooltip"; import Badge from "./Badge"; import SentimentScale, { getSentimentPalette } from "./SentimentScale"; @@ -238,7 +239,11 @@ export const FeatureItem: React.FC = ({ labelClassName = "text-xs font-medium text-rmigray-500", showLabel = true, }) => { - const rawValue = keyFeatures[feature.key]; + // v2 stores each feature as scoped {sector, geography, value} entries (#858). + // Render the value at the broadest scope, which reproduces v1's output exactly + // for codemod-migrated data (one entry, at its widest scope). #869 replaces this + // with a scope-aware resolver and #859 adds the badge that names the scope. + const rawValue = widestValue(keyFeatures[feature.key]); const label =

{feature.label}

; diff --git a/src/data/pathwayMetadata.ts b/src/data/pathwayMetadata.ts index 14129fb9..da4e37ea 100644 --- a/src/data/pathwayMetadata.ts +++ b/src/data/pathwayMetadata.ts @@ -1,7 +1,8 @@ import { PathwayMetadataType } from "../types"; import { FileEntry } from "../utils/validateData"; import { assembleData, decideIncludeInvalid } from "../utils/loadData"; -import pathwayMetadataSchema from "../schema/pathwayMetadata.v1.json" with { type: "json" }; +import pathwayMetadataSchema from "../schema/pathwayMetadata.v2.json" with { type: "json" }; +import pathwayMetadataV1Schema from "../schema/pathwayMetadata.v1.json" with { type: "json" }; import { commonSchemas } from "../schema/common"; // 1) Grab every JSON file in this folder **and subfolders** @@ -20,6 +21,35 @@ const entries: FileEntry[] = Object.entries(modules) })) .sort((a, b) => a.name.localeCompare(b.name)); +/** + * Count metadata files still carrying the v1 `$schema` (#858). + * + * `validateDataCollect` routes each document by its own `$schema` and drops + * anything that does not match the schema it was handed — as neither valid nor + * invalid. That is what lets v1 and v2 documents share src/data during the + * migration, but it also means an un-migrated file vanishes from the app with no + * error at all. Counting them here turns "pathways are missing" from a mystery + * into a number. Timeseries files are routed away by the same mechanism and are + * not counted, since their absence from this list is by design. + */ +const V1_METADATA_ID = String( + (pathwayMetadataV1Schema as { $id?: string }).$id, +); + +const unmigrated = entries.filter( + (e) => + typeof e.data === "object" && + e.data !== null && + (e.data as { $schema?: unknown }).$schema === V1_METADATA_ID, +).length; + +if (unmigrated > 0) { + console.warn( + `[pathwayMetadata] ${unmigrated} metadata file(s) still use schema v1 and are ` + + `not loaded. Migrate them with scripts/codemod-v1-to-v2.ts (#858).`, + ); +} + export const pathwayMetadata: PathwayMetadataType[] = assembleData( entries, pathwayMetadataSchema, diff --git a/src/pages/PathwayDetailPage.tsx b/src/pages/PathwayDetailPage.tsx index d1eda1dd..b4b90c26 100644 --- a/src/pages/PathwayDetailPage.tsx +++ b/src/pages/PathwayDetailPage.tsx @@ -259,8 +259,24 @@ const PathwayDetailPage: React.FC = () => {

Expert Overview

+ {/* + v1's single `expertOverview` markdown blob was three sections; + v2 splits it into `pathwayDescription` and + `transitionAssessment` (#858), with the middle section becoming + the structured `coreDrivers`. Both are rendered here so the + migration loses no visible content, under headings matching the + ones the markdown used to carry. #859 owns the real presentation + of these fields, including whether this heading keeps its name. + */}
- {pathway.expertOverview} +

Pathway Description

+ {pathway.pathwayDescription ?? ""} + {pathway.transitionAssessment && ( + <> +

Application to Transition Assessment

+ {pathway.transitionAssessment} + + )}
diff --git a/src/types/index.ts b/src/types/index.ts index 5b40a688..acd17b42 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,9 +5,9 @@ import type { PublicationV1 } from "./common/publication.v1"; import type { GeographyV1 } from "./common/geography.v1"; // Re-export the (current) versioned pathway metadata type as generic. -// Still v1: #858 lands the v2 schema and types first, and the loader is -// repointed at v2 in a later commit once data files carry the v2 $schema. -export type PathwayMetadataType = PathwayMetadataV1; +// v2 as of #858: src/data/pathwayMetadata.ts validates against v2, so only v2 +// documents reach the app and this is the shape every consumer sees. +export type PathwayMetadataType = PathwayMetadataV2; export type PublicationType = PublicationV1; // Both versions are exported for the migration window. v1 and v2 documents diff --git a/src/utils/keyFeatureScope.test.ts b/src/utils/keyFeatureScope.test.ts new file mode 100644 index 00000000..15780eb7 --- /dev/null +++ b/src/utils/keyFeatureScope.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect } from "vitest"; +import { + entryValues, + sectorScopeContains, + entryISOSet, + geographyScopeContains, + entriesInScope, + valuesInScope, + widestValue, +} from "./keyFeatureScope"; +import { ABSENT_FILTER_TOKEN } from "./absent"; +import type { PathwayMetadataType } from "../types"; + +/** + * A pathway covering Power and Steel, with one mapped region, one unmapped region + * (the NGFS shape pending #801), and one standalone country. + */ +function pathway(over: Partial = {}): PathwayMetadataType { + return { + sectors: [ + { name: "Power", technologies: [] }, + { name: "Steel", technologies: [] }, + ], + geography: { + regions: { + "South East Asia": ["ID", "TH", "VN"], + "Unmapped Region": [], + }, + country: ["US"], + }, + ...over, + } as unknown as PathwayMetadataType; +} + +const e = (sector: string, geography: string, value: string | string[]) => ({ + sector, + geography, + value, +}); + +describe("entryValues", () => { + it("flattens scalar and array-valued entries alike", () => { + expect( + entryValues([ + e("Power", "Global", "A"), + e("Steel", "Global", ["B", "C"]), + ]), + ).toEqual(["A", "B", "C"]); + }); + + it("returns [] for an empty entry list", () => { + expect(entryValues([])).toEqual([]); + }); + + it("returns [] for a non-array — a stale v1 scalar degrades, not throws", () => { + // Relevant while v1 and v2 coexist: a v1-shaped scalar must not blow up here. + expect(entryValues("Significant decrease")).toEqual([]); + expect(entryValues(null)).toEqual([]); + expect(entryValues(undefined)).toEqual([]); + }); +}); + +describe("sectorScopeContains", () => { + const declared = ["Power", "Steel"]; + + it("matches an exact sector", () => { + expect(sectorScopeContains("Power", "Power", declared)).toBe(true); + }); + + it("does not match a different sector", () => { + expect(sectorScopeContains("Power", "Steel", declared)).toBe(false); + }); + + it("cross-sector contains any sector the pathway declares", () => { + expect(sectorScopeContains("cross-sector", "Steel", declared)).toBe(true); + }); + + it("cross-sector is NOT a universal match", () => { + // Per Jacob on #869: cross-sector is the union of the pathway's own sectors, + // so a pathway covering only Power and Steel does not answer a Cement query. + expect(sectorScopeContains("cross-sector", "Cement", declared)).toBe(false); + }); +}); + +describe("entryISOSet", () => { + it("returns null for Global, meaning everything", () => { + expect(entryISOSet("Global", pathway())).toBeNull(); + }); + + it("resolves a region label through the pathway's own mapping", () => { + expect( + [...(entryISOSet("South East Asia", pathway()) ?? [])].sort(), + ).toEqual(["ID", "TH", "VN"]); + }); + + it("resolves a bare country code", () => { + expect([...(entryISOSet("US", pathway()) ?? [])]).toEqual(["US"]); + }); + + it("resolves an unmapped region to the empty set, not to everything", () => { + // #801: a region the publication never mapped must match nothing rather than + // silently behaving like Global. + expect(entryISOSet("Unmapped Region", pathway())?.size).toBe(0); + }); + + it("resolves cross-region to the pathway's whole ISO coverage", () => { + const set = entryISOSet("cross-region", pathway()); + expect([...(set ?? [])].sort()).toEqual(["ID", "TH", "US", "VN"]); + }); + + it("resolves an unrecognised label to the empty set", () => { + expect(entryISOSet("Souteast Asia", pathway())?.size).toBe(0); + }); +}); + +describe("geographyScopeContains", () => { + const p = pathway(); + + it("Global contains a country", () => { + expect(geographyScopeContains("Global", "TH", p)).toBe(true); + }); + + it("a region contains a country inside it", () => { + expect(geographyScopeContains("South East Asia", "TH", p)).toBe(true); + }); + + it("a country does NOT contain the region around it", () => { + // Containment is directional: broader answers narrower, never the reverse. + expect(geographyScopeContains("TH", "South East Asia", p)).toBe(false); + }); + + it("a region does not contain a country outside it", () => { + expect(geographyScopeContains("South East Asia", "US", p)).toBe(false); + }); + + it("only Global contains a Global query", () => { + expect(geographyScopeContains("Global", "Global", p)).toBe(true); + expect(geographyScopeContains("South East Asia", "Global", p)).toBe(false); + }); + + it("the absent bucket does not constrain which scope to read", () => { + expect( + geographyScopeContains("South East Asia", ABSENT_FILTER_TOKEN, p), + ).toBe(true); + }); + + it("an unmapped region contains nothing", () => { + expect(geographyScopeContains("Unmapped Region", "TH", p)).toBe(false); + }); +}); + +describe("entriesInScope", () => { + const entries = [ + e("cross-sector", "Global", "wide"), + e("Power", "South East Asia", "power-sea"), + e("Steel", "US", "steel-us"), + ]; + const p = pathway(); + + it("returns every entry when neither axis is filtered", () => { + expect(entriesInScope(entries, {}, p)).toHaveLength(3); + }); + + it("keeps entries whose sector contains the queried sector", () => { + expect( + entriesInScope(entries, { sectors: ["Power"] }, p).map((x) => x.value), + ).toEqual(["wide", "power-sea"]); + }); + + it("keeps entries whose geography contains the queried country", () => { + expect( + entriesInScope(entries, { geographies: ["TH"] }, p).map((x) => x.value), + ).toEqual(["wide", "power-sea"]); + }); + + it("applies both axes together", () => { + expect( + entriesInScope( + entries, + { sectors: ["Steel"], geographies: ["US"] }, + p, + ).map((x) => x.value), + ).toEqual(["wide", "steel-us"]); + }); + + it("excludes an entry when only one axis matches", () => { + // Steel data exists, but only for the US — not for Thailand. + expect( + entriesInScope( + entries, + { sectors: ["Steel"], geographies: ["TH"] }, + p, + ).map((x) => x.value), + ).toEqual(["wide"]); + }); + + it("treats several selections on one axis as 'any of them'", () => { + expect( + entriesInScope(entries, { sectors: ["Power", "Steel"] }, p).map( + (x) => x.value, + ), + ).toEqual(["wide", "power-sea", "steel-us"]); + }); + + it("can narrow to nothing when no entry covers the query", () => { + const narrow = [e("Power", "South East Asia", "power-sea")]; + expect(entriesInScope(narrow, { geographies: ["US"] }, p)).toEqual([]); + }); + + it("returns [] for absent entries", () => { + expect(entriesInScope(undefined, { sectors: ["Power"] }, p)).toEqual([]); + expect(entriesInScope([], { sectors: ["Power"] }, p)).toEqual([]); + }); +}); + +describe("widestValue", () => { + it("returns undefined when nothing is authored", () => { + expect(widestValue([])).toBeUndefined(); + expect(widestValue(undefined)).toBeUndefined(); + }); + + it("returns the only value when there is one entry", () => { + expect(widestValue([e("cross-sector", "Global", "only")])).toBe("only"); + }); + + it("prefers cross-sector over a named sector", () => { + expect( + widestValue([ + e("Power", "Global", "narrow"), + e("cross-sector", "Global", "wide"), + ]), + ).toBe("wide"); + }); + + it("prefers Global over a region, and a region over a country", () => { + expect( + widestValue([ + e("cross-sector", "TH", "country"), + e("cross-sector", "South East Asia", "region"), + e("cross-sector", "Global", "global"), + ]), + ).toBe("global"); + expect( + widestValue([ + e("cross-sector", "TH", "country"), + e("cross-sector", "South East Asia", "region"), + ]), + ).toBe("region"); + }); + + it("ranks sector ahead of geography", () => { + // A cross-sector entry wins even when its geography is narrower, matching the + // "sector > geography" precedence #869 defines for its cost model. + expect( + widestValue([ + e("Power", "Global", "power-global"), + e("cross-sector", "TH", "cross-th"), + ]), + ).toBe("cross-th"); + }); + + it("preserves an array value intact", () => { + expect(widestValue([e("cross-sector", "Global", ["a", "b"])])).toEqual([ + "a", + "b", + ]); + }); + + it("degrades to undefined for a stale v1 scalar", () => { + expect(widestValue("Moderate decrease")).toBeUndefined(); + }); +}); + +describe("valuesInScope", () => { + it("flattens the in-scope entries' values", () => { + const entries = [ + e("cross-sector", "Global", ["a", "b"]), + e("Steel", "US", "c"), + ]; + expect(valuesInScope(entries, { sectors: ["Power"] }, pathway())).toEqual([ + "a", + "b", + ]); + }); +}); diff --git a/src/utils/keyFeatureScope.ts b/src/utils/keyFeatureScope.ts new file mode 100644 index 00000000..eca64a66 --- /dev/null +++ b/src/utils/keyFeatureScope.ts @@ -0,0 +1,215 @@ +/** + * Reading v2's scoped keyFeatures entries (#858). + * + * Each keyFeature is an array of `{sector, geography, value}` entries, so + * "what is this pathway's emissionsTrajectory?" now depends on which part of the + * pathway's coverage you are asking about. This module answers two questions the + * search layer needs: + * + * - which entries are relevant to the user's current sector/geography filter + * ({@link entriesInScope}), and + * - what values those entries carry ({@link entryValues}). + * + * Deliberately **containment only**: an entry is relevant when its scope contains + * the query on both axes. There is no cost model, no ranking, and no notion of how + * far the query had to broaden — that is #869's resolver. This decides inclusion, + * nothing more, so search keeps working over v2 data without pre-empting the + * design that lands next. + */ +import type { GeographyCode, PathwayMetadataType } from "../types"; +import { pathwayISOCoverage, toISO2 } from "./geographyUtils"; +import { selectedGeographyToISO } from "./filterRegions"; + +/** Sector sentinel: the union of *this pathway's own* declared sectors. */ +export const CROSS_SECTOR = "cross-sector"; +/** Geography sentinels: everything, and a multi-region non-global aggregate. */ +export const GLOBAL_SCOPE = "Global"; +export const CROSS_REGION = "cross-region"; + +/** One scoped entry, structurally — the 11 fields differ only in `value`. */ +export interface ScopedEntry { + sector: string; + geography: string; + value: string | string[]; +} + +/** + * Coerce a field's value to entries, tolerating anything that is not an array. + * + * Takes `unknown` on purpose. While v1 and v2 coexist a v1-shaped scalar can + * reach these helpers from a hand-built fixture or a stale mock, and degrading to + * "no entries" is better than throwing. `Array.isArray` narrows a typed + * `readonly T[]` union to `any[]`, so the cast is what keeps this type-safe. + */ +function asEntries(value: unknown): readonly ScopedEntry[] { + return Array.isArray(value) ? (value as readonly ScopedEntry[]) : []; +} + +/** Flatten one field's entries to the values they carry, array-valued or not. */ +export function entryValues(entries: unknown): string[] { + return asEntries(entries).flatMap((e) => { + if (Array.isArray(e.value)) return e.value; + return e.value != null ? [e.value] : []; + }); +} + +/** + * Does an entry's sector scope contain a queried sector? + * + * `cross-sector` is **not** a universal match (per Jacob on #869): it means the + * union of the sectors this pathway declares, so a pathway covering only Y and Z + * does not answer a query for sector X even though its `cross-sector` values are + * nominally broad enough. + */ +export function sectorScopeContains( + entrySector: string, + querySector: string, + declaredSectors: readonly string[], +): boolean { + if (entrySector === querySector) return true; + if (entrySector === CROSS_SECTOR) + return declaredSectors.includes(querySector); + return false; +} + +/** + * The ISO codes an entry's geography scope covers, or `null` for "everything". + * + * A region label resolves through the pathway's own `geography.regions` mapping, + * which is why an unmapped region (empty member array, e.g. the NGFS files + * pending #801) resolves to the empty set and therefore matches nothing rather + * than matching everything. + */ +export function entryISOSet( + entryGeography: string, + pathway: PathwayMetadataType, +): Set | null { + if (entryGeography === GLOBAL_SCOPE) return null; + + const geo = pathway.geography; + if (entryGeography === CROSS_REGION) return pathwayISOCoverage(geo); + + const members = geo?.regions?.[entryGeography]; + if (Array.isArray(members)) return new Set(members); + + const iso = toISO2(entryGeography); + if (iso) return new Set([iso as GeographyCode]); + + return new Set(); +} + +/** + * Does an entry's geography scope contain a selected geography token? + * + * Containment, not overlap: the query's ISO set must be a *subset* of the entry's. + * "Power in Thailand" is answered by an entry scoped to South East Asia, but an + * entry scoped to Thailand does not answer a query about South East Asia. + */ +export function geographyScopeContains( + entryGeography: string, + queryToken: string, + pathway: PathwayMetadataType, +): boolean { + const query = selectedGeographyToISO(queryToken); + // The "None" bucket is about the pathway having no geography at all; it says + // nothing about which scope to read, so it does not constrain this axis. + if (query.kind === "absent") return true; + + const entrySet = entryISOSet(entryGeography, pathway); + if (entrySet === null) return true; // Global contains everything + + if (query.kind === "global") return false; // only Global contains Global + if (query.iso.size === 0) return false; // unrecognised token matches nothing + for (const code of query.iso) if (!entrySet.has(code)) return false; + return true; +} + +export interface ScopeQuery { + /** Selected sector tokens; empty leaves the sector axis unconstrained. */ + sectors?: readonly string[]; + /** Selected geography tokens; empty leaves the geography axis unconstrained. */ + geographies?: readonly string[]; +} + +/** + * The entries relevant to the user's current filter. + * + * With neither axis filtered this returns every entry, which is what makes the + * blank-search view behave exactly as it did on v1 data. Multiple selections on + * an axis are treated as "contains any of them": the ANY/ALL facet mode the user + * picked governs *value* matching, not which scope to read from, so a + * sector=[Power, Steel] selection makes entries for either sector relevant. + */ +export function entriesInScope( + entries: unknown, + query: ScopeQuery, + pathway: PathwayMetadataType, +): ScopedEntry[] { + const all = asEntries(entries); + const sectors = query.sectors ?? []; + const geographies = query.geographies ?? []; + if (sectors.length === 0 && geographies.length === 0) return [...all]; + + const declared = (pathway.sectors ?? []).map((s) => s.name); + + return all.filter((entry) => { + const sectorOk = + sectors.length === 0 || + sectors.some((s) => sectorScopeContains(entry.sector, s, declared)); + if (!sectorOk) return false; + const geoOk = + geographies.length === 0 || + geographies.some((g) => + geographyScopeContains(entry.geography, g, pathway), + ); + return geoOk; + }); +} + +/** Values of the entries relevant to the query — what a facet matches against. */ +export function valuesInScope( + entries: unknown, + query: ScopeQuery, + pathway: PathwayMetadataType, +): string[] { + return entryValues(entriesInScope(entries, query, pathway)); +} + +/** + * How broad a scope axis is, lower being broader. Derived from the token alone so + * this works without pathway context, which is what {@link widestValue}'s callers + * (the rendering components) have available. + */ +function sectorBreadth(sector: string): number { + return sector === CROSS_SECTOR ? 0 : 1; +} + +function geographyBreadth(geography: string): number { + if (geography === GLOBAL_SCOPE) return 0; + if (geography === CROSS_REGION) return 1; + // A two-letter token is a country code; anything longer is a region label. + return /^[A-Za-z]{2}$/.test(geography) ? 3 : 2; +} + +/** + * The value at the broadest scope a field declares. + * + * **Provisional.** This is a placeholder for #869's resolver, which will pick the + * value for the scope the *user* is looking at and report how far it had to + * broaden so #859 can badge it. Until then the components show the widest value, + * which is what v1 effectively showed — every codemod-migrated pathway has exactly + * one entry, at its widest scope — so rendering is unchanged for current data. + * + * Returns `undefined` when nothing is authored at any scope, which the callers + * already render as "No information". + */ +export function widestValue(entries: unknown): string | string[] | undefined { + const all = asEntries(entries); + if (all.length === 0) return undefined; + const ranked = [...all].sort( + (a, b) => + sectorBreadth(a.sector) - sectorBreadth(b.sector) || + geographyBreadth(a.geography) - geographyBreadth(b.geography), + ); + return ranked[0].value; +} diff --git a/src/utils/searchUtils.scopedFacets.test.ts b/src/utils/searchUtils.scopedFacets.test.ts new file mode 100644 index 00000000..c52dfd15 --- /dev/null +++ b/src/utils/searchUtils.scopedFacets.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest"; +import { filterPathways, getGlobalFacetOptions } from "./searchUtils"; +import type { FiltersWithArrays } from "./searchUtils"; +import { ABSENT_FILTER_TOKEN } from "./absent"; +import type { PathwayMetadataType } from "../types"; + +/** + * Coverage for the two keyFeature-backed search facets over v2 scoped entries + * (#858): `emissionsTrajectory` and `policyAmbition`. + * + * These arms had no test coverage at all before v2 — no `filterPathways` test + * passed either filter — which is why the v1->v2 shape change broke them + * silently: `concrete.includes(v)` against an array is simply always false, so + * selecting either facet quietly returned zero pathways. + * + * The semantics asserted here are containment: a pathway matches on the value at + * the scope the user is looking at, and a pathway holding that value only at some + * *other* scope is not a match. Fallback ranking is #869's job, not this layer's. + */ + +type Entry = { sector: string; geography: string; value: string }; + +function pathway( + id: string, + opts: { + sectors?: string[]; + emissionsTrajectory?: Entry[]; + policyAmbition?: Entry[]; + }, +): PathwayMetadataType { + return { + id, + name: { full: id }, + sectors: (opts.sectors ?? ["Power", "Steel"]).map((name) => ({ + name, + technologies: [], + })), + geography: { + regions: { "South East Asia": ["ID", "TH", "VN"] }, + country: ["US"], + }, + metric: [], + keyFeatures: { + emissionsTrajectory: opts.emissionsTrajectory ?? [], + policyAmbition: opts.policyAmbition ?? [], + }, + } as unknown as PathwayMetadataType; +} + +const e = (sector: string, geography: string, value: string): Entry => ({ + sector, + geography, + value, +}); + +const ids = (list: PathwayMetadataType[]) => list.map((p) => p.id).sort(); + +describe("emissionsTrajectory facet over scoped entries", () => { + const wide = pathway("wide", { + emissionsTrajectory: [e("cross-sector", "Global", "Significant decrease")], + }); + const perSector = pathway("perSector", { + emissionsTrajectory: [ + e("Power", "Global", "Significant decrease"), + e("Steel", "Global", "Minor decrease"), + ], + }); + const regional = pathway("regional", { + emissionsTrajectory: [ + e("cross-sector", "South East Asia", "Significant decrease"), + ], + }); + const empty = pathway("empty", { emissionsTrajectory: [] }); + const all = [wide, perSector, regional, empty]; + + it("matches a value held at the widest scope when nothing is narrowed", () => { + const filters: FiltersWithArrays = { + emissionsTrajectory: ["Significant decrease"], + }; + expect(ids(filterPathways(all, filters))).toEqual([ + "perSector", + "regional", + "wide", + ]); + }); + + it("respects the user's sector: excludes a pathway whose value at that sector differs", () => { + // perSector holds "Significant decrease" for Power but "Minor decrease" for + // Steel. Filtering to Steel must not match it, even though the value exists + // elsewhere in the pathway. This is the whole point of the scope check. + const filters: FiltersWithArrays = { + sector: ["Steel"], + emissionsTrajectory: ["Significant decrease"], + }; + const result = ids(filterPathways(all, filters)); + expect(result).not.toContain("perSector"); + expect(result).toEqual(["regional", "wide"]); + }); + + it("matches that same pathway when the user asks about the sector it holds", () => { + const filters: FiltersWithArrays = { + sector: ["Power"], + emissionsTrajectory: ["Significant decrease"], + }; + expect(ids(filterPathways(all, filters))).toContain("perSector"); + }); + + it("matches a regional entry from a country inside that region", () => { + const filters: FiltersWithArrays = { + geography: ["TH"], + emissionsTrajectory: ["Significant decrease"], + }; + expect(ids(filterPathways(all, filters))).toContain("regional"); + }); + + it("does not match a regional entry from a country outside that region", () => { + // "regional" only has South East Asia data; the US is not in it. (The + // geography facet would also exclude it, but this asserts the scope check + // independently — the pathway does declare US coverage.) + const filters: FiltersWithArrays = { + geography: ["US"], + emissionsTrajectory: ["Significant decrease"], + }; + expect(ids(filterPathways(all, filters))).not.toContain("regional"); + }); + + it("treats an empty entry list as the absent bucket", () => { + expect( + ids(filterPathways(all, { emissionsTrajectory: [ABSENT_FILTER_TOKEN] })), + ).toEqual(["empty"]); + }); + + it("does not match the absent bucket when a value is present", () => { + const filters: FiltersWithArrays = { + emissionsTrajectory: [ABSENT_FILTER_TOKEN], + }; + expect(ids(filterPathways(all, filters))).not.toContain("wide"); + }); + + it("passes every pathway through when the facet is not selected", () => { + expect(ids(filterPathways(all, {}))).toEqual([ + "empty", + "perSector", + "regional", + "wide", + ]); + }); + + it("ANY mode matches a pathway holding either selected value", () => { + const filters: FiltersWithArrays = { + emissionsTrajectory: ["Minor decrease", "Moderate decrease"], + modes: { emissionsTrajectory: "ANY" }, + }; + expect(ids(filterPathways(all, filters))).toEqual(["perSector"]); + }); + + it("ALL mode requires every selected value, which multi-scope data can satisfy", () => { + // Newly meaningful in v2: one pathway can genuinely hold two values at once. + const filters: FiltersWithArrays = { + emissionsTrajectory: ["Significant decrease", "Minor decrease"], + modes: { emissionsTrajectory: "ALL" }, + }; + expect(ids(filterPathways(all, filters))).toEqual(["perSector"]); + }); + + it("ALL mode excludes a pathway holding only one of the selected values", () => { + const filters: FiltersWithArrays = { + emissionsTrajectory: ["Significant decrease", "Minor decrease"], + modes: { emissionsTrajectory: "ALL" }, + }; + expect(ids(filterPathways(all, filters))).not.toContain("wide"); + }); + + it("cross-sector does not answer a sector the pathway never declares", () => { + const powerOnly = pathway("powerOnly", { + sectors: ["Power"], + emissionsTrajectory: [ + e("cross-sector", "Global", "Significant decrease"), + ], + }); + const filters: FiltersWithArrays = { + sector: ["Steel"], + emissionsTrajectory: ["Significant decrease"], + }; + // The sector facet excludes it too; asserted here so the scope rule is + // pinned independently of that. + expect(ids(filterPathways([powerOnly], filters))).toEqual([]); + }); +}); + +describe("policyAmbition facet over scoped entries", () => { + const p = pathway("p", { + policyAmbition: [ + e("Power", "Global", "High ambition policies"), + e("Steel", "Global", "Current/legislated policies"), + ], + }); + + it("respects the user's sector, same as emissionsTrajectory", () => { + expect( + ids( + filterPathways([p], { + sector: ["Steel"], + policyAmbition: ["High ambition policies"], + }), + ), + ).toEqual([]); + expect( + ids( + filterPathways([p], { + sector: ["Power"], + policyAmbition: ["High ambition policies"], + }), + ), + ).toEqual(["p"]); + }); + + it("applies independently of the emissionsTrajectory facet", () => { + const filters: FiltersWithArrays = { + policyAmbition: ["Current/legislated policies"], + emissionsTrajectory: ["Significant decrease"], + }; + // emissionsTrajectory is empty on this pathway, so the conjunction fails. + expect(ids(filterPathways([p], filters))).toEqual([]); + }); +}); + +describe("getGlobalFacetOptions over scoped entries", () => { + const all = [ + pathway("a", { + emissionsTrajectory: [ + e("cross-sector", "Global", "Significant decrease"), + ], + policyAmbition: [e("cross-sector", "Global", "High ambition policies")], + }), + pathway("b", { + emissionsTrajectory: [ + e("Power", "Global", "Minor decrease"), + e("Steel", "Global", "Significant decrease"), + ], + }), + ]; + + it("lists the union of values across every scope, deduplicated", () => { + const { emissionsTrajectoryOptions } = getGlobalFacetOptions(all); + const values = emissionsTrajectoryOptions + .map((o) => o.value) + .filter((v) => v !== ABSENT_FILTER_TOKEN); + expect([...values].sort()).toEqual([ + "Minor decrease", + "Significant decrease", + ]); + }); + + it("never emits an option built from an entry object", () => { + // The v1->v2 break turned these options into "[object Object]" because the + // entries were passed through verbatim instead of their values. + const { emissionsTrajectoryOptions, policyAmbitionOptions } = + getGlobalFacetOptions(all); + for (const opt of [ + ...emissionsTrajectoryOptions, + ...policyAmbitionOptions, + ]) { + expect(String(opt.label)).not.toContain("object"); + expect(String(opt.value)).not.toContain("object"); + } + }); + + it("offers the absent bucket when a pathway has no entries for the field", () => { + const { policyAmbitionOptions } = getGlobalFacetOptions(all); + expect(policyAmbitionOptions.map((o) => o.value)).toContain( + ABSENT_FILTER_TOKEN, + ); + }); +}); diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts index 1011ecc2..4c7ef059 100644 --- a/src/utils/searchUtils.ts +++ b/src/utils/searchUtils.ts @@ -14,6 +14,7 @@ import { selectedGeographyToISO, } from "./filterRegions"; import { matchesOptionalFacetAny, matchesOptionalFacetAll } from "./facets"; +import { entryValues, valuesInScope } from "./keyFeatureScope"; import { ABSENT_FILTER_TOKEN } from "./absent"; import { buildOptionsFromValues, hasAbsent, withAbsentOption } from "./facets"; import { sortPathwayType } from "./sortUtils"; @@ -81,15 +82,28 @@ export function getGlobalFacetOptions(pathways: PathwayMetadataType[]) { pathways.map((d) => d.metric).flat(), ); - // emissionsTrajectory - const emissionsTrajectoryOptions = buildOptionsFromValues( - pathways.map((d) => d.keyFeatures.emissionsTrajectory).flat(), - ); + // emissionsTrajectory / policyAmbition (#858: scoped entries, not scalars). + // Options are the union of the values across every scope a pathway declares — + // deliberately unfiltered, so the dropdown lists everything selectable rather + // than shifting as the user narrows sector/geography. + // + // The ABSENT bucket has to be added explicitly, as it is for sectors below. In + // v1 a pathway missing the field contributed `undefined`, which + // buildOptionsFromValues read as absent; in v2 an empty entries array + // contributes *no* elements, so nothing would signal absence and the "None" + // option would vanish even though the filter still honours the token. + const scopedFacetOptions = ( + field: "emissionsTrajectory" | "policyAmbition", + ) => { + const values = pathways.map((d) => entryValues(d.keyFeatures?.[field])); + return withAbsentOption( + buildOptionsFromValues(values.flat()), + values.some((v) => v.length === 0), + ); + }; - // Policy ambition - const policyAmbitionOptions = buildOptionsFromValues( - pathways.map((d) => d.keyFeatures.policyAmbition).flat(), - ); + const emissionsTrajectoryOptions = scopedFacetOptions("emissionsTrajectory"); + const policyAmbitionOptions = scopedFacetOptions("policyAmbition"); const dataAvailabilityOptions = buildOptionsFromValues( pathways.map((d) => availabilityFor(d)).flat(), @@ -435,68 +449,45 @@ export const filterPathways = ( if (!ok) return false; } - // emissionsTrajectory filter + // emissionsTrajectory / policyAmbition filters (#858). + // + // These two are keyFeature fields *and* search facets. In v1 each held one + // scalar; in v2 each holds scoped {sector, geography, value} entries, so a + // pathway can carry several values at once and the question becomes "which + // value, at which scope?". + // + // Answer, per the product decision: the value at the scope the user is + // looking at. `valuesInScope` narrows the entries to those whose scope + // contains the active sector/geography selection, and matching then works + // exactly like the sector and metric facets above — an ANY/ALL match over a + // list of values, with an empty list treated as the ABSENT bucket. A pathway + // holding the selected value only at some *other* scope is not a match. + // + // Containment only: no fallback ranking, no "how far did we broaden" cost. + // That is #869, and it is what will turn a non-match at the queried scope + // into a ranked broader-scope match rather than an exclusion. { - const selected = toArray(filters.emissionsTrajectory); - if (selected.length) { - const hasAbsent = selected.includes(ABSENT_FILTER_TOKEN); - const concrete = selected.filter((t) => t !== ABSENT_FILTER_TOKEN); - const v = pathway.keyFeatures?.emissionsTrajectory ?? null; - const mode = pickMode("emissionsTrajectory", filters.modes); - let ok = true; - - if (mode === "ANY") { - ok = - (v == null && hasAbsent) || - (v != null && (concrete.length ? concrete.includes(v) : false)); - } else { - // ALL: for single-valued fields, all selected tokens must hold. - // That is only possible when exactly one token is selected: - // - [ABSENT] -> v == null - // - [X] -> v == X - // Any combination (ABSENT + X, or X + Y) cannot be satisfied. - if (hasAbsent && concrete.length === 0) { - ok = v == null; - } else if (!hasAbsent && concrete.length === 1) { - ok = v != null && v === concrete[0]; - } else { - ok = false; - } - } - if (!ok) return false; - } - } - - // policyAmbition filter - { - const selected = toArray(filters.policyAmbition); - if (selected.length) { - const hasAbsent = selected.includes(ABSENT_FILTER_TOKEN); - const concrete = selected.filter((t) => t !== ABSENT_FILTER_TOKEN); - const v = pathway.keyFeatures?.policyAmbition ?? null; - const mode = pickMode("policyAmbition", filters.modes); - let ok = true; + const scopeQuery = { + sectors: toArray(filters.sector), + geographies: toArray(filters.geography), + }; + const scopedFacetOk = ( + facet: "emissionsTrajectory" | "policyAmbition", + ): boolean => { + const selected = toArray(filters[facet]); + if (selected.length === 0) return true; + const values = valuesInScope( + pathway.keyFeatures?.[facet], + scopeQuery, + pathway, + ); + return pickMode(facet, filters.modes) === "ALL" + ? matchesOptionalFacetAll(selected, values, (s) => s) + : matchesOptionalFacetAny(selected, values, (s) => s); + }; - if (mode === "ANY") { - ok = - (v == null && hasAbsent) || - (v != null && (concrete.length ? concrete.includes(v) : false)); - } else { - // ALL: for single-valued fields, all selected tokens must hold. - // That is only possible when exactly one token is selected: - // - [ABSENT] -> v == null - // - [X] -> v == X - // Any combination (ABSENT + X, or X + Y) cannot be satisfied. - if (hasAbsent && concrete.length === 0) { - ok = v == null; - } else if (!hasAbsent && concrete.length === 1) { - ok = v != null && v === concrete[0]; - } else { - ok = false; - } - } - if (!ok) return false; - } + if (!scopedFacetOk("emissionsTrajectory")) return false; + if (!scopedFacetOk("policyAmbition")) return false; } // dataAvailability filter From 4c8f8a60690490c6081064029920bf8648164183 Mon Sep 17 00:00:00 2001 From: repro Date: Fri, 14 Aug 2026 15:22:17 +0200 Subject: [PATCH 6/6] docs(data): document v2 authoring format; test v2 required fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out #858's checklist. src/data/README.md described a format that no longer exists — and in the R example's case, one that never validated. Beyond the expected v1 leftovers (expertOverview, npm run json:check, a pbtar_schema.json link), the example used `name` as a bare string, the pre-#783 flat geography array, top-level publisher/publicationYear, and a `dataSource` field absent from every version of the schema. It would have failed against v1 as readily as against v2. Rewritten around v2: the two coexisting schema versions and the fact that only v2 documents are loaded, the scoped keyFeatures shape with its sentinels and the widest-scope rule, coreDrivers/dependencies/pathwayDescription/ transitionAssessment, the codemod for migrating an existing file, and the commands that actually exist. The R example is now verified rather than asserted: its blocks were extracted from this file, executed, and the resulting JSON validated against v2. Doing that corrected a wrong claim in an earlier draft — R's list() preserves NULL elements; the actual pitfall is jsonlite writing NULL as {}, which is why the helper passes null = "null". The validate_json R helper is dropped rather than repaired. The schema is split across common/*.json with absolute $refs that a single-URL jsonvalidate::json_validate() cannot resolve, so it documented a validation route that cannot succeed. Authors are pointed at npm run schema:check, which resolves the refs and additionally runs the cross-field scope checks that JSON Schema draft-07 cannot express. Tests: adds v2 counterparts to the existing v1 required-field cases — all 12 required fields, each of the 7 coreDrivers keys, unknown keys, and the dependencies enums. Also pins that pathwayDescription accepts null but not absence, the nullable-but-required distinction v2 relies on. The v1 REQ array still lists expertOverview on purpose: those cases validate v1 documents against v1, where it remains required. Finally, migrates the seven `keyFeatures: { emissionsTrajectory: "foo" }` stubs in ComparisonPage and PathwaySearch tests to the v2 shape. They kept their deliberately-invalid values, which exist to test degradation; the point is that a v1-shaped scalar in a v2 fixture silently exercises nothing. Refs #858. Co-Authored-By: Claude Opus 5 --- src/data/README.md | 261 +++++++++++++++++++++--------- src/pages/ComparisonPage.test.tsx | 12 +- src/pages/PathwaySearch.test.tsx | 30 +++- src/utils/validateData.test.tsx | 128 +++++++++++++++ 4 files changed, 345 insertions(+), 86 deletions(-) diff --git a/src/data/README.md b/src/data/README.md index 1864b77f..dfcfa240 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -1,112 +1,215 @@ -# Pathway metadata for the pbtar repo +# Pathway metadata for the tpr repo -The `src/data` directory in the [pbtar](https://github.com/RMI/pbtar) repo contains all of the data shown on the Pathways-based transition assessment repository site. -Each JSON file in this directory contains one or more pathway definitions. +The `src/data` directory in the [tpr](https://github.com/RMI/tpr) repo contains all of the data shown on the Transition Pathways Repository site. +Each JSON file in this directory contains one pathway definition, alongside optional `*_timeseries.json` files holding that pathway's data series. ## Schema and Validation -The JSON files have a strict, specific format that needs to be followed, which is defined by the JSON schema file in this repo at [pbtar_schema.json](https://github.com/RMI/pbtar/blob/main/src/schema/schema.json). +The JSON files have a strict, specific format, defined by the JSON schema files in [`src/schema/`](https://github.com/RMI/tpr/tree/main/src/schema). +The schema is split across several files: the pathway metadata schema itself, plus shared definitions under `src/schema/common/` that it references (country codes, sector and technology names, publication details, and so on). -The schema file defines a number of mandatory fields which must be included. -Additionally, the structure, the data types, and in some cases the allowed values for a given key must be correct for things to work as expected. -This repo has CI/CD setup to validate any new JSON added in a PR against the schema. -Therefore, any new JSON added through a PR on main must pass all of the tests before being merged. +The schema defines a number of mandatory fields which must be included. +The structure, the data types, and in many cases the allowed values for a given key must all be correct for things to work as expected. +This repo has CI/CD set up to validate any new JSON added in a PR, so any new JSON added through a PR on `main` must pass before being merged. -After preparing a JSON file, you can run `npm run json:check`, which will trigger validation (locally) against all JSON files in this directory. -You can preview a JSON file as it will appear in the UI with `npm run dev`. -To add a new Pathway, a new JSON file in the correct format needs to be added to this repo in a pull request on `main`. +After preparing a JSON file, validate it locally with: -## Examples +```bash +npm run schema:check +``` -To see example files, look in this directory, or `testdata/valid/`. +That checks every file under `src/data` and `testdata/valid`. You can preview a file as it will appear in the UI with `npm run dev`. -## Creating new pathway files +If you have changed a schema rather than a data file, run `npm run schema` instead — it validates and then regenerates the TypeScript types in `src/types/` and the HTML schema reference in `public/schema/`. Both are checked in, and CI fails if they are out of date. -To facilitate creating a new JSON file in the appropriate format using R, we have created the following two functions to validate a `` object against the schema in this repo, and to write a valid nested `` to a JSON file. -These functions can be copy-pasted to your R console and then they're available to use on any `` you have in your environment. -These functions require the following R packages to be installed in your environment: `jsonvalidate`, `jsonlite`, `dplyr`, `tidyr`, `stringr`, and `purrr`. +### Two schema versions -```r -# if schema_url is not provided, it will validate against the current PROD schema. +Two versions of the metadata schema currently exist: -validate_json <- function(json_obj, schema_url = NULL) { - if (is.null(schema_url)) { - schema_url <- "https://raw.githubusercontent.com/RMI/pbtar/refs/heads/main/pbtar_schema.json" - } - json_schema <- readr::read_file(file = schema_url) - validation <- - jsonvalidate::json_validate( - json = jsonlite::toJSON(json_obj, auto_unbox = TRUE), - schema = json_schema, - verbose = TRUE, - greedy = TRUE, - engine = "ajv" - ) - if (!validation) { - errors <- - attr(validation, "errors") |> - dplyr::mutate(key = stringr::str_extract(instancePath, "[a-z]+")) |> - tidyr::unnest(params) |> - dplyr::rename(input = data) |> - dplyr::select(dplyr::any_of(c("input", "key", "message", "allowedValues"))) - return(errors) - } +- `pathwayMetadata.v2.json` — the current format. **New pathways should use this.** +- `pathwayMetadata.v1.json` — the previous format, still present so existing files remain valid. + +Every file declares which one it follows via its own `$schema` key, and the validator routes each file to the matching schema. A file on either version will pass `npm run schema:check`. + +**Only v2 files are loaded by the site.** A file still on v1 validates but does not appear in the app; the dev server logs how many were skipped. Migration of the remaining v1 files is in progress. + +## The v2 format + +A complete, valid example lives at [`testdata/valid/pathwayMetadata_v2_full.json`](../../testdata/valid/pathwayMetadata_v2_full.json), and a minimal one at `pathwayMetadata_v2_minimal.json`. Real pathways are in the publisher subdirectories here. + +If you are used to the v1 format, these are the differences that matter: + +### keyFeatures are scoped + +In v1 each of the 11 key features held a single value for the whole pathway. In v2 each holds an **array of entries**, so a pathway can record different values for different parts of its coverage: + +```json +"keyFeatures": { + "emissionsTrajectory": [ + { "sector": "cross-sector", "geography": "Global", "value": "Moderate decrease" }, + { "sector": "Power", "geography": "South East Asia", "value": "Significant decrease" } + ] } +``` -write_json <- function(json_obj, file) { - validation <- validate_json(json_obj) - if (!is.data.frame(validation)) { - jsonlite::write_json( - x = json_obj, - path = file, - auto_unbox = TRUE, - pretty = TRUE - ) - return(invisible()) +- `sector` is one of the sector names, or `"cross-sector"` meaning "all of the sectors this pathway covers". +- `geography` is `"Global"`, one of the region labels used in this pathway's own `geography.regions`, or one of its country codes. +- `value` is exactly what v1 held for that field — the same allowed values. For the two fields that were arrays in v1 (`policyTypes`, `newTechnologiesIncluded`), `value` is still an array. + +Both `sector` and `geography` must be something the pathway actually declares, or one of the widest sentinels. A region label that does not appear in the pathway's own `geography.regions` is rejected, which is what catches a typo like `"Southeast Asia"` where the pathway says `"South East Asia"`. + +If a feature does not vary, give it **one entry at the widest scope that applies** — `cross-sector` for a multi-sector pathway (otherwise its only sector), and `Global` for a global pathway (otherwise its region or country). + +An empty array means nothing is recorded at any scope. That is different from an entry whose `value` is `"No information"`, which is a deliberate statement that this scope has no data. + +### expertOverview is replaced by two fields + +v1's single `expertOverview` was one markdown document containing three sections. In v2: + +- `pathwayDescription` (required, may be `null`, max 2500 chars) — the narrative description. +- `transitionAssessment` (optional, may be `null`, max 3000 chars) — how the pathway can be used in transition assessment. +- the "Core Drivers" section becomes the structured `coreDrivers` object below. + +`pathwayOverview` is also gone; fold any such text into `pathwayDescription`. + +### coreDrivers and dependencies are new + +`coreDrivers` is an object with seven fields, all required but each allowed to be `null`. `null` means "not a core driver for this pathway", which is deliberately different from a driver that applies but has not been described. + +```json +"coreDrivers": { + "policies": "Carbon pricing across both covered sectors.", + "emissionsTargets": null, + "technologyCosts": null, + "investmentChange": null, + "macroeconomicDrivers": null, + "behavioralShifts": null, + "otherDrivers": null +} +``` + +`dependencies` is an array — use `[]` if there are none. Each entry names a category, describes the dependency, scopes it to one of the pathway's sectors, and states how strong the evidence is: + +```json +"dependencies": [ + { + "dependency_name": "Infrastructure and logistics", + "dependency_description": "Grid buildout must keep pace with renewable additions.", + "sector": "Power", + "evidence_type": "Quantitative" } - validation +] +``` + +The allowed values for `dependency_name` and `evidence_type` are listed in the schema. + +## Migrating an existing v1 file + +There is a script for this — don't do it by hand: + +```bash +npx ts-node --esm scripts/codemod-v1-to-v2.ts --dry-run src/data/ +``` + +Drop `--dry-run` to write the changes. It rewrites files in place, skips anything already on v2, and prints the scope it chose for each file. + +It does **not** fill in `coreDrivers` — the v1 "Core Drivers" prose does not map onto the seven named fields mechanically, so the script scaffolds them all to `null` and prints the original text for whoever authors them. Run `npx prettier --write` on the files afterwards, then `npm run schema:check`. + +## Creating new pathway files + +To create a new file in the appropriate format using R, the function below writes a nested `` out as JSON. +It can be copy-pasted to your R console and requires the `jsonlite` package. + +```r +write_json <- function(json_obj, file) { + jsonlite::write_json( + x = json_obj, + path = file, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) } ``` -Once the above functions have been loaded in your R environment, a new `` can be created, and then validated and exported as a JSON file using these functions like so... +Note on validating from R: the schema is split across several files that reference each other by URL, and the usual `jsonvalidate::json_validate()` call takes a single schema and will not fetch those references. Write the file first, then validate it with `npm run schema:check`, which resolves them correctly and also runs the checks that JSON Schema alone cannot express — such as confirming each `sector` and `geography` is one the pathway declares. + +Once the function above is loaded, a new pathway can be created and written out like so. ```r -# Note that single-element arrays must be wrapped with I(), the identity function, to ensure that `jsonlite` processes them as arrays, rather than length-1 vectors (everything is a vector in R). +# Single-element vectors must be wrapped with I(), the identity function, so that +# `jsonlite` writes them as arrays rather than as bare values (everything is a +# vector in R). Fields that must be `null` rather than absent use NA... see below. + +scoped <- function(value) { + list(list(sector = "Power", geography = "VN", value = value)) +} new_pathway_metadata <- list( - list( - id = "R-import-example", - name = "R Import Pathway", - description = "Pathway Imported from R", - pathwayType = "Normative", - modelYearEnd = 2050, - modelTempIncrease = 1.5, - geography = list("Global", "US", "Europe"), - sectors = list( - list(name = "Power", technologies = c("Coal", "Wind")), - list(name = "Steel", technologies = I(c("Other"))) - ), - publisher = "Example Publisher", - publicationYear = 2021, - metric = I(c("Capacity")), # `I()` is necessary so that jsonlite parses it as a length 1 array - expertOverview = "Text based expert recommendation long.", - dataSource = list( - description = "Data source description.", - url = "https://www.example.com/", - downloadAvailable = FALSE + `$schema` = "http://pathways.rmi.org/schema/pathwayMetadata.v2.json", + id = "R-import-example", + name = list(full = "R Import Pathway", short = "R Example"), + description = "Pathway imported from R.", # must end in a period + publication = list( + title = list(full = "Example Publication"), + publisher = list(full = "TransitionZero"), + year = 2021, + links = list( + list(description = "Report", url = "https://www.example.com/") + ) + ), + pathwayType = "Normative", + modelYearEnd = 2050, + modelTempIncrease = 1.5, + geography = list( + regions = list(`South East Asia` = I(c("VN", "TH"))), + country = I(c("VN")) + ), + sectors = list( + list(name = "Power", technologies = I(c("Coal", "Wind"))) + ), + pathwayDescription = "A short narrative description of the pathway.", + transitionAssessment = "How this pathway can be used in transition assessment.", + metric = I(c("Capacity")), + keyFeatures = list( + emissionsTrajectory = scoped("Moderate decrease"), + energyEfficiency = scoped("Moderate improvement"), + energyDemand = scoped("Minor increase"), + electrification = scoped("Significant increase"), + policyTypes = scoped(I(c("Carbon price"))), + technologyCostTrend = scoped("Decrease"), + emissionsScope = scoped("CO2"), + policyAmbition = scoped("Current/legislated policies"), + technologyCostsDetail = scoped("Total costs"), + newTechnologiesIncluded = scoped(I(c("Battery storage"))), + investmentNeeds = scoped("By sector") + ), + coreDrivers = list( + policies = NULL, emissionsTargets = NULL, technologyCosts = NULL, + investmentChange = NULL, macroeconomicDrivers = NULL, + behavioralShifts = NULL, otherDrivers = NULL + ), + dependencies = list( + list( + dependency_name = "Technology", + dependency_description = "Grid capacity must expand.", + sector = "Power", + evidence_type = "Qualitative" ) ) ) -validate_json(new_pathway_metadata) - -write_json(new_pathway_metadata, "test.json") +write_json(new_pathway_metadata, "src/data/example-publisher/EXAMPLE-2021.json") ``` -If the `` is not valid, the functions will return a data frame with information about what was invalid. +One R-specific note: `jsonlite` writes an R `NULL` as `{}` by default, which the schema rejects. That is why `write_json` above passes `null = "null"` — with it, the seven `NULL` entries in `coreDrivers` come out as JSON `null` as intended. All seven keys are required even when every one of them is null, so keep them all. + +The example above has been run as written, and the file it produces passes `npm run schema:check`. + +## Keeping this up to date This README should be the definitive source of information about these JSON files and how to add them or modify them. As this repo is currently under heavy development, such details may change rapidly, and this README should be kept up to date with those changes as they happen. If you're developing in this repo, please remember to make appropriate changes to this README when relevant changes are made to the underlying code. -If you're maintaining/modifying/adding the pathway data, please refer to the [live version of this README](https://github.com/RMI/pbtar/blob/main/src/data/README.md) on `main` for the most up-to-date details. +If you're maintaining/modifying/adding the pathway data, please refer to the [live version of this README](https://github.com/RMI/tpr/blob/main/src/data/README.md) on `main` for the most up-to-date details. diff --git a/src/pages/ComparisonPage.test.tsx b/src/pages/ComparisonPage.test.tsx index 3c763a54..f307d186 100644 --- a/src/pages/ComparisonPage.test.tsx +++ b/src/pages/ComparisonPage.test.tsx @@ -24,7 +24,11 @@ const fixtures = [ sectors: [{ name: "Power" }], metric: ["Capacity"], geography: { global: true, regions: { Europe: [] }, country: ["US"] }, - keyFeatures: { emissionsTrajectory: "foo" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "foo" }, + ], + }, }, { id: "cmp-b", @@ -41,7 +45,11 @@ const fixtures = [ sectors: [{ name: "Steel" }], metric: ["Generation"], geography: { country: ["DE", "FR"] }, - keyFeatures: { emissionsTrajectory: "bar" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "bar" }, + ], + }, }, ] as const; diff --git a/src/pages/PathwaySearch.test.tsx b/src/pages/PathwaySearch.test.tsx index 7a04b0e0..6ae4dadf 100644 --- a/src/pages/PathwaySearch.test.tsx +++ b/src/pages/PathwaySearch.test.tsx @@ -65,7 +65,11 @@ describe("PathwaySearch integration: dropdowns render and filter with 'None'", ( pathwayType: "Net Zero", modelYearNetzero: 2050, metric: [], - keyFeatures: { emissionsTrajectory: "foo" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "foo" }, + ], + }, }, { id: "B", @@ -78,7 +82,11 @@ describe("PathwaySearch integration: dropdowns render and filter with 'None'", ( pathwayType: "Net Zero", modelYearNetzero: 2050, metric: ["Capacity"], - keyFeatures: { emissionsTrajectory: "foo" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "foo" }, + ], + }, }, { id: "C", @@ -89,7 +97,11 @@ describe("PathwaySearch integration: dropdowns render and filter with 'None'", ( pathwayType: "NZi2050", modelYearNetzero: 2040, metric: [], - keyFeatures: { emissionsTrajectory: "foo" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "foo" }, + ], + }, }, { id: "D", @@ -100,7 +112,11 @@ describe("PathwaySearch integration: dropdowns render and filter with 'None'", ( pathwayType: "BAU", modelYearNetzero: 2030, metric: ["Capacity", "Generation"], - keyFeatures: { emissionsTrajectory: "bar" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "bar" }, + ], + }, }, { id: "E", @@ -111,7 +127,11 @@ describe("PathwaySearch integration: dropdowns render and filter with 'None'", ( pathwayType: "Net Zero", modelYearNetzero: 2050, metric: ["Generation"], - keyFeatures: { emissionsTrajectory: "bar" }, + keyFeatures: { + emissionsTrajectory: [ + { sector: "cross-sector", geography: "Global", value: "bar" }, + ], + }, }, ] as const; diff --git a/src/utils/validateData.test.tsx b/src/utils/validateData.test.tsx index 458aad9b..cad013e5 100644 --- a/src/utils/validateData.test.tsx +++ b/src/utils/validateData.test.tsx @@ -390,3 +390,131 @@ describe("v1 and v2 documents coexist (#858)", () => { ); }); }); + +describe("v2 enforces its own required fields (#858)", () => { + // The v1 REQ block above deliberately still lists `expertOverview`: those cases + // validate v1 documents against v1, where it remains required. These are the v2 + // counterparts. `coreDrivers` and `dependencies` matter most — the codemod + // scaffolds them to null/[], so they are the fields most likely to be omitted + // by hand-authoring or by a partial migration of the remaining files. + const V2_REQ = [ + "id", + "name", + "description", + "publication", + "pathwayType", + "geography", + "sectors", + "pathwayDescription", + "metric", + "keyFeatures", + "coreDrivers", + "dependencies", + ]; + + for (const key of V2_REQ) { + it(`fails when required property '${key}' is missing`, () => { + const rest: Record = { ...v2Full }; + delete rest[key]; + fail2({ name: "missing.json", data: rest }, new RegExp(key)); + }); + } + + it("accepts a null pathwayDescription, but not a missing one", () => { + // Nullable-but-required: "we have no description" is authored explicitly, + // which is the same distinction #858 draws for coreDrivers and keyFeatures. + const { invalid } = validateDataCollect( + [ + { + name: "null-desc.json", + data: { ...v2Full, pathwayDescription: null }, + }, + ], + pathwayMetadataV2, + commonSchemas, + ); + expect(invalid).toHaveLength(0); + }); + + it("requires every one of the 7 coreDrivers keys, nullable though they are", () => { + const { coreDrivers } = v2Full as unknown as { + coreDrivers: Record; + }; + for (const key of Object.keys(coreDrivers)) { + const partial = { ...coreDrivers }; + delete partial[key]; + fail2( + { + name: `missing-driver-${key}.json`, + data: { ...v2Full, coreDrivers: partial }, + }, + new RegExp(key), + ); + } + }); + + it("rejects an unknown coreDrivers key", () => { + fail2( + { + name: "extra-driver.json", + data: { + ...v2Full, + coreDrivers: { + ...(v2Full as unknown as { coreDrivers: object }).coreDrivers, + madeUpDriver: "Nope.", + }, + }, + }, + /must NOT have additional properties/, + ); + }); + + it("rejects a dependency scoped to an unknown sector", () => { + fail2( + { + name: "bad-dep-sector.json", + data: { + ...v2Full, + dependencies: [ + { + dependency_name: "Technology", + dependency_description: "Needs something.", + sector: "Yak Shaving", + evidence_type: "Qualitative", + }, + ], + }, + }, + /allowed values/, + ); + }); + + it("rejects an unknown dependency_name or evidence_type", () => { + const base = { + dependency_name: "Technology", + dependency_description: "Needs something.", + sector: "Power", + evidence_type: "Qualitative", + }; + fail2( + { + name: "bad-dep-name.json", + data: { + ...v2Full, + dependencies: [{ ...base, dependency_name: "Vibes" }], + }, + }, + /allowed values/, + ); + fail2( + { + name: "bad-evidence.json", + data: { + ...v2Full, + dependencies: [{ ...base, evidence_type: "Hearsay" }], + }, + }, + /allowed values/, + ); + }); +});