Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion scripts/codemod-v1-to-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,38 @@ describe("upgradeV1ToV2", () => {
const { doc } = upgradeV1ToV2(v1({}));
const kf = doc.keyFeatures;
expect(Object.keys(kf)).toHaveLength(11);
for (const entries of Object.values(kf)) {
for (const [field, entries] of Object.entries(kf)) {
// Power-only, so Electrification has no scope it can apply to: see below.
if (field === "electrification") continue;
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("emits no Electrification for a Power-only pathway, which it cannot apply to", () => {
expect(upgradeV1ToV2(v1({})).doc.keyFeatures.electrification).toEqual([]);
});

it("keeps Electrification across sectors for a multi-sector pathway", () => {
const { doc } = upgradeV1ToV2(
v1({
sectors: [
{ name: "Power", technologies: [] },
{ name: "Buildings", technologies: [] },
],
} as Partial<PathwayMetadataV1>),
);
expect(doc.keyFeatures.electrification).toEqual([
{
sector: "across sectors",
geography: "Global",
value: "Significant increase",
},
]);
});

it("nests an array-valued field rather than splatting it into entries", () => {
const doc = upgradeV1ToV2(
v1({
Expand Down
10 changes: 9 additions & 1 deletion scripts/codemod-v1-to-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ 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";
import { technologyBelongsToSector } from "../src/utils/timeseriesTaxonomy.ts";
import { isForbiddenKeyFeatureSector } from "../src/utils/keyFeatureRules.ts";

const V1_ID = "http://pathways.rmi.org/schema/pathwayMetadata.v1.json";
const V2_ID = "http://pathways.rmi.org/schema/pathwayMetadata.v2.json";
Expand Down Expand Up @@ -223,7 +224,14 @@ export function upgradeV1ToV2(doc: PathwayMetadataV1): UpgradeResult {
),
energyEfficiency: scoped(sector, geography, kf.energyEfficiency),
energyDemand: scoped(sector, geography, kf.energyDemand),
electrification: scoped(sector, geography, kf.electrification),
// A Power-only pathway's widest scope is Power, which electrification
// cannot apply to: the pair carries no value at all.
electrification: isForbiddenKeyFeatureSector(
"electrification",
sector,
)
? []
: scoped(sector, geography, kf.electrification),
policyTypes: scoped(sector, geography, kf.policyTypes),
technologyCostTrend: scoped(
sector,
Expand Down
107 changes: 103 additions & 4 deletions scripts/import-pathway-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
buildDataAvailability,
descriptionFromV1,
keyFeaturesFromV1,
buildKeyFeatures,
charCount,
withinLimit,
buildCoreDrivers,
Expand Down Expand Up @@ -164,12 +165,17 @@ describe("value normalization", () => {
expect(isAbsent("No information")).toBe(false);
});

it("does not treat the scope sentinels as absent", () => {
// Cookbook decision 0023 made these authored values, and #858 added them to
// the schema. They used to be swallowed here, which discarded the very
// distinction they exist to draw: an explicit value terminates #869's
it("does not treat the scope sentinel as absent", () => {
// Cookbook decision 0023 made it an authored value, and #858 added it to
// the schema. It used to be swallowed here, which discarded the very
// distinction it exists to draw: an explicit value terminates #869's
// fallback chain where an absent entry keeps broadening.
expect(isAbsent("Not applicable at this scope level")).toBe(false);
});

it("does not treat 0023's measure-level Not Applicable as absent either", () => {
// It means something only at a forbidden pair, where buildKeyFeatures
// drops it; anywhere else it must reach the off-enum report.
expect(isAbsent("Not Applicable")).toBe(false);
});

Expand Down Expand Up @@ -490,6 +496,99 @@ describe("keyFeaturesFromV1", () => {
expect(keyFeaturesFromV1(v2, new Report(), "T")).toBeNull();
expect(keyFeaturesFromV1(null, new Report(), "T")).toBeNull();
});
it("drops Electrification of a Power-only pathway, which cannot apply", () => {
const kf = keyFeaturesFromV1(
{
...v1,
keyFeatures: { ...v1.keyFeatures, electrification: "Minor increase" },
},
new Report(),
"T",
);
expect(kf?.electrification).toEqual([]);
expect(kf?.emissionsTrajectory).toHaveLength(1);
});
});

describe("buildKeyFeatures", () => {
const SECTORS = new Set(["Power", "Buildings"]);
const GEOS = new Set(["across regions", "TH", "VN"]);
const row = (sector: string, geography: string, electrification: string) => ({
"Key features sector scope": sector,
"Key features regional scope": geography,
"Emissions trajectory": "Moderate decrease",
"Electrification": electrification,
});

it.each(["No information", "Moderate increase"])(
"refuses an Electrification cell on a Power row holding %s",
(value) => {
const report = new Report();
const kf = buildKeyFeatures(
[row("Power", "TH", value), row("Power", "VN", value)],
SECTORS,
GEOS,
report,
"T",
);
// One error naming both rows, not one per cell.
expect(report.errors).toHaveLength(1);
expect(report.errors[0]).toContain(
"T: Electrification cannot apply to the Power sector",
);
expect(report.errors[0]).toContain("{TH, VN}");
expect(kf.electrification).toEqual([]);
// The rest of the row still imports.
expect(kf.emissionsTrajectory).toHaveLength(2);
},
);

it.each(["", "Not Applicable", " not applicable "])(
"accepts and drops an Electrification cell on a Power row holding %j",
(value) => {
const report = new Report();
const kf = buildKeyFeatures(
[row("Power", "TH", value)],
SECTORS,
GEOS,
report,
"T",
);
expect(report.errors).toEqual([]);
expect(report.lines).toEqual([]);
expect(kf.electrification).toEqual([]);
},
);

it("still reports Not Applicable off-enum away from a forbidden pair", () => {
const report = new Report();
const kf = buildKeyFeatures(
[row("Buildings", "TH", "Not Applicable")],
SECTORS,
GEOS,
report,
"T",
);
expect(report.errors).toEqual([]);
expect(report.lines.join("\n")).toContain("off-enum value(s) dropped");
expect(kf.electrification).toEqual([]);
});

it("imports Electrification for other sectors and across sectors", () => {
const report = new Report();
const kf = buildKeyFeatures(
[
row("Buildings", "TH", "Minor increase"),
row("across sectors", "across regions", "Moderate increase"),
],
SECTORS,
GEOS,
report,
"T",
);
expect(report.errors).toEqual([]);
expect(kf.electrification).toHaveLength(2);
});
});

describe("data-availability cell parsing", () => {
Expand Down
53 changes: 46 additions & 7 deletions scripts/import-pathway-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import {
availabilityMetricsForSector,
technologyBelongsToSector,
} from "../src/utils/timeseriesTaxonomy.ts";
import {
isForbiddenKeyFeatureSector,
type KeyFeatureKey,
} from "../src/utils/keyFeatureRules.ts";
import countryCodeSchema from "../src/schema/common/countryCode.v1.json" with { type: "json" };
import pathwayMetadataV2Schema from "../src/schema/pathwayMetadata.v2.json" with { type: "json" };
import sectorSchema from "../src/schema/common/sector.v1.json" with { type: "json" };
Expand Down Expand Up @@ -764,20 +768,28 @@ async function readWorkbook(path: string, prefix: string): Promise<Sheets> {
// Value normalization helpers.
// --------------------------------------------------------------------------

/** 0023's measure-level marker, lower-cased; meaningful only at a forbidden pair. */
const NOT_APPLICABLE_CELL = "not applicable";

/** Cells that mean "no value at this scope" -- the entry is omitted entirely. */
/**
* Whether a cell records nothing at all, as against recording an absence.
*
* The `not applicable` / `not available` prefixes used to be here, and taking
* them out is the point of the change. Cookbook decision 0023 made those
* authored values rather than empty cells: `Not applicable at this scope level`
* says this row is not the widest scope the field is calculated for, and
* `Not Applicable` says the measure is meaningless for the sector at any scope.
* #858 added both to the schema, and the epic turns on the distinction — an
* them out is the point of the change. Cookbook decision 0023 made
* `Not applicable at this scope level` an authored value rather than an empty
* cell: it says this row is not the widest scope the field is calculated for.
* #858 added it to the schema, and the epic turns on the distinction — an
* explicit value terminates #869's fallback chain where an absent entry keeps
* broadening. Swallowing them here threw away exactly what they were added to
* broadening. Swallowing it here threw away exactly what it was added to
* express.
*
* 0023's other value, `Not Applicable` for a measure that is meaningless for
* the sector (Electrification × Power), stays in the workbook but not in the
* data: such a pair has no entry at all. It is deliberately not absent here,
* because it means something only at a forbidden pair; {@link buildKeyFeatures}
* drops it there, and anywhere else it is off-enum and reported.
*
* A consequence worth keeping: a cell still carrying a pre-0023 spelling now
* fails `matchEnum` and is reported in `badValues` instead of vanishing, so the
* importer's own report names the cells left to correct.
Expand Down Expand Up @@ -1046,8 +1058,16 @@ type ScopedEntry = {
* Emits every resolvable (sector, geography) scope. Entries whose sector or
* geography does not resolve against the pathway's declared coverage are
* omitted and reported, so the file still passes validateScopedEntries.
*
* A cell at a field × sector that cannot apply (Electrification × Power, see
* `FORBIDDEN_KEY_FEATURE_SECTORS`) is written as no entry at all. The workbook
* may say so with 0023's `Not Applicable` or leave the cell blank; either is
* dropped. Any other value there -- `No information` included -- is an
* authoring error that stops the import, as validateScopedEntries would reject
* the entry. Refused rather than dropped, so the workbook and the data cannot
* disagree about what the pathway reports.
*/
function buildKeyFeatures(
export function buildKeyFeatures(
rows: Row[],
declaredSectors: Set<string>,
geoAllowed: Set<string>,
Expand All @@ -1063,6 +1083,8 @@ function buildKeyFeatures(
const badSectors = new Set<string>();
const badGeos = new Set<string>();
const badValues = new Set<string>();
// "<column>\0<sector>" -> the geographies of rows with a value there.
const forbidden = new Map<string, string[]>();
let dupScopes = 0;

for (const row of rows) {
Expand Down Expand Up @@ -1096,6 +1118,12 @@ function buildKeyFeatures(
for (const [column, field] of Object.entries(KF_COLUMN_TO_FIELD)) {
const raw = row[column] ?? "";
if (isAbsent(raw)) continue;
if (isForbiddenKeyFeatureSector(field as KeyFeatureKey, sector)) {
if (raw.trim().toLowerCase() === NOT_APPLICABLE_CELL) continue;
const key = `${column}\u0000${sector}`;
forbidden.set(key, [...(forbidden.get(key) ?? []), geography]);
continue;
}
let value: string | string[] | null = null;
if (KF_ARRAY_FIELDS.has(field)) {
const tokens = splitList(raw);
Expand Down Expand Up @@ -1141,6 +1169,17 @@ function buildKeyFeatures(
if (dupScopes) parts.push(`${dupScopes} duplicate-scope value(s) collapsed`);
if (parts.length > 1) report.note(`${id}: ${parts.join("; ")}`);

// One error per column and sector, naming its rows, rather than one per cell:
// a pathway can have dozens of Power rows.
for (const [key, geographies] of forbidden) {
const [column, sector] = key.split("\u0000");
report.error(
`${id}: ${column} cannot apply to the ${sector} sector, but ${geographies.length}` +
` ${sector} row(s) give it a value {${geographies.join(", ")}} -- leave` +
` those ${column} cells blank or "Not Applicable" (not "No information")`,
);
}

for (const field of Object.keys(fields)) {
fields[field].sort(
(a, b) =>
Expand Down
22 changes: 15 additions & 7 deletions src/components/KeyFeatures.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,6 @@ describe("FeatureItem with a resolution (#869)", () => {

it("inherits Electrification of Power from across sectors, and says why", async () => {
renderResolved("electrification", [
e("Power", "TH", "Not Applicable"),
e("across sectors", "South East Asia", "Moderate increase"),
]);

Expand All @@ -474,11 +473,23 @@ describe("FeatureItem with a resolution (#869)", () => {
);
});

it("shows No information for Electrification of Power with nothing across sectors", async () => {
renderResolved("electrification", [e("Power", "TH", "Not Applicable")]);
it("shows Not applicable for Electrification of Power with nothing across sectors", async () => {
renderResolved("electrification", []);

expect(screen.queryByText(/not applicable/i)).not.toBeInTheDocument();
expect(screen.queryByText("No information")).not.toBeInTheDocument();
expect(queryScopeBadge()).not.toBeInTheDocument();
expect(await tooltipOf("Not applicable")).toBe(
"Electrification does not apply to the Power sector.",
);
});

it("still shows No information for Electrification of another sector", async () => {
renderResolved("electrification", [], {
sector: "Buildings",
geography: "TH",
});

expect(screen.queryByText("Not applicable")).not.toBeInTheDocument();
expect(await tooltipOf("No information")).toBe(
"Nothing is recorded for this pathway at any scope.",
);
Expand Down Expand Up @@ -644,9 +655,6 @@ describe("feature config against the schema", () => {
"No information",
// Skipped by the resolver, never rendered.
"Not applicable at this scope level",
// Skipped by the resolver like the sentinel above. The validator (PR 2)
// limits it to Electrification × Power.
"Not Applicable",
]);

type ValueSchema = {
Expand Down
Loading
Loading