mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(electricity): correct EIA-930 respondent codes for NYISO and SPP NYISO and SPP returned empty data because the EIA API uses abbreviated BA e-tag IDs (NYIS, SWPP), not public-facing acronyms. * fix(electricity): separate EIA respondent codes from stable region IDs EIA-930 uses abbreviated BA e-tag IDs (NYIS, SWPP) as API respondent codes, not the public-facing acronyms (NYISO, SPP). Added a separate respondent field for the API query while keeping region stable for Redis keys and downstream consumers. * test(electricity): add EIA respondent code mapping coverage Export EIA_REGIONS and add tests verifying the region-to-respondent mapping so a future refactor cannot silently regress the codes.
166 lines
6.6 KiB
JavaScript
166 lines
6.6 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
parseEntsoEPrice,
|
|
buildElectricityIndex,
|
|
EIA_REGIONS,
|
|
ELECTRICITY_INDEX_KEY,
|
|
ELECTRICITY_KEY_PREFIX,
|
|
ELECTRICITY_TTL_SECONDS,
|
|
} from '../scripts/seed-electricity-prices.mjs';
|
|
|
|
// ── parseEntsoEPrice ──────────────────────────────────────────────────────────
|
|
|
|
describe('parseEntsoEPrice', () => {
|
|
it('extracts average from XML with 24 hourly price.amount values', () => {
|
|
const prices = Array.from({ length: 24 }, (_, i) => 80 + i); // 80..103
|
|
const xml = prices
|
|
.map((p, i) => `<Point><position>${i + 1}</position><price.amount>${p}.00</price.amount></Point>`)
|
|
.join('\n');
|
|
const result = parseEntsoEPrice(xml);
|
|
const expected = +(prices.reduce((a, b) => a + b, 0) / prices.length).toFixed(2);
|
|
assert.equal(result, expected);
|
|
});
|
|
|
|
it('returns null when no price.amount tags present', () => {
|
|
const xml = '<TimeSeries><Period><resolution>PT60M</resolution></Period></TimeSeries>';
|
|
assert.equal(parseEntsoEPrice(xml), null);
|
|
});
|
|
|
|
it('handles a single price value', () => {
|
|
const xml = '<price.amount>87.30</price.amount>';
|
|
assert.equal(parseEntsoEPrice(xml), 87.3);
|
|
});
|
|
|
|
it('ignores non-numeric price values', () => {
|
|
const xml = '<price.amount>abc</price.amount><price.amount>50.00</price.amount>';
|
|
assert.equal(parseEntsoEPrice(xml), 50);
|
|
});
|
|
|
|
it('handles negative prices (common in EU wholesale markets)', () => {
|
|
const xml = '<price.amount>-10.00</price.amount><price.amount>20.00</price.amount>';
|
|
assert.equal(parseEntsoEPrice(xml), 5);
|
|
});
|
|
|
|
it('handles all-negative prices', () => {
|
|
const xml = '<price.amount>-5.00</price.amount><price.amount>-15.00</price.amount>';
|
|
assert.equal(parseEntsoEPrice(xml), -10);
|
|
});
|
|
});
|
|
|
|
// ── buildElectricityIndex ─────────────────────────────────────────────────────
|
|
|
|
describe('buildElectricityIndex', () => {
|
|
function makeRegions(count, base = 100) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
region: `R${i}`,
|
|
source: 'entso-e',
|
|
priceMwhEur: base - i,
|
|
priceMwhUsd: null,
|
|
date: '2026-04-05',
|
|
unit: 'EUR/MWh',
|
|
seededAt: new Date().toISOString(),
|
|
}));
|
|
}
|
|
|
|
it('returns only regions with valid priceMwhEur, sorted descending', () => {
|
|
const regions = [
|
|
{ region: 'DE', source: 'entso-e', priceMwhEur: 87.3, priceMwhUsd: null, date: '2026-04-05', unit: 'EUR/MWh', seededAt: '' },
|
|
{ region: 'FR', source: 'entso-e', priceMwhEur: 62.1, priceMwhUsd: null, date: '2026-04-05', unit: 'EUR/MWh', seededAt: '' },
|
|
{ region: 'CISO', source: 'eia-930', priceMwhEur: null, priceMwhUsd: null, date: '2026-04-05', unit: 'MWh', seededAt: '' },
|
|
];
|
|
const index = buildElectricityIndex(regions, '2026-04-05');
|
|
assert.equal(index.regions.length, 2, 'should exclude null priceMwhEur entries');
|
|
assert.equal(index.regions[0].region, 'DE', 'highest price first');
|
|
assert.equal(index.regions[1].region, 'FR', 'second highest price second');
|
|
});
|
|
|
|
it('caps at 20 entries', () => {
|
|
const regions = makeRegions(25);
|
|
const index = buildElectricityIndex(regions, '2026-04-05');
|
|
assert.equal(index.regions.length, 20);
|
|
});
|
|
|
|
it('returns updatedAt and date fields', () => {
|
|
const regions = makeRegions(3);
|
|
const index = buildElectricityIndex(regions, '2026-04-05');
|
|
assert.ok(typeof index.updatedAt === 'string');
|
|
assert.equal(index.date, '2026-04-05');
|
|
assert.ok(Array.isArray(index.regions));
|
|
});
|
|
|
|
it('returns empty regions array when no valid prices exist', () => {
|
|
const regions = [
|
|
{ region: 'CISO', source: 'eia-930', priceMwhEur: null, priceMwhUsd: null, date: '2026-04-05', unit: 'MWh', seededAt: '' },
|
|
];
|
|
const index = buildElectricityIndex(regions, '2026-04-05');
|
|
assert.equal(index.regions.length, 0);
|
|
});
|
|
});
|
|
|
|
// ── Missing ENTSO_E_TOKEN path ────────────────────────────────────────────────
|
|
|
|
describe('ENTSO_E_TOKEN handling', () => {
|
|
it('ELECTRICITY_INDEX_KEY is defined as a string (token absence not needed at module import level)', () => {
|
|
// The absence-of-token path is a runtime branch in main().
|
|
// We verify the key constant is defined so the module imported cleanly
|
|
// even without ENTSO_E_TOKEN set.
|
|
assert.equal(typeof ELECTRICITY_INDEX_KEY, 'string');
|
|
});
|
|
});
|
|
|
|
// ── Key constants ─────────────────────────────────────────────────────────────
|
|
|
|
describe('exported key constants', () => {
|
|
it('ELECTRICITY_INDEX_KEY matches expected pattern', () => {
|
|
assert.equal(ELECTRICITY_INDEX_KEY, 'energy:electricity:v1:index');
|
|
});
|
|
|
|
it('ELECTRICITY_KEY_PREFIX matches expected pattern', () => {
|
|
assert.equal(ELECTRICITY_KEY_PREFIX, 'energy:electricity:v1:');
|
|
});
|
|
|
|
it('ELECTRICITY_TTL_SECONDS is at least 3 days', () => {
|
|
assert.ok(
|
|
ELECTRICITY_TTL_SECONDS >= 3 * 24 * 3600,
|
|
`TTL ${ELECTRICITY_TTL_SECONDS}s is less than 3 days`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── EIA region/respondent mapping ────────────────────────────────────────────
|
|
|
|
describe('EIA_REGIONS respondent codes', () => {
|
|
const EXPECTED = {
|
|
CISO: 'CISO',
|
|
MISO: 'MISO',
|
|
PJM: 'PJM',
|
|
NYISO: 'NYIS',
|
|
ERCO: 'ERCO',
|
|
SPP: 'SWPP',
|
|
};
|
|
|
|
it('every entry has distinct region and respondent fields', () => {
|
|
for (const entry of EIA_REGIONS) {
|
|
assert.ok(typeof entry.region === 'string' && entry.region.length > 0, `missing region`);
|
|
assert.ok(typeof entry.respondent === 'string' && entry.respondent.length > 0, `missing respondent for ${entry.region}`);
|
|
}
|
|
});
|
|
|
|
it('maps public region IDs to correct EIA respondent codes', () => {
|
|
for (const [region, respondent] of Object.entries(EXPECTED)) {
|
|
const entry = EIA_REGIONS.find((r) => r.region === region);
|
|
assert.ok(entry, `missing EIA_REGIONS entry for ${region}`);
|
|
assert.equal(entry.respondent, respondent, `${region} should use respondent ${respondent}, got ${entry.respondent}`);
|
|
}
|
|
});
|
|
|
|
it('covers all expected regions', () => {
|
|
const regions = EIA_REGIONS.map((r) => r.region);
|
|
for (const expected of Object.keys(EXPECTED)) {
|
|
assert.ok(regions.includes(expected), `EIA_REGIONS missing ${expected}`);
|
|
}
|
|
});
|
|
});
|