@iflow-mcp/kitfunso-luminus 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +488 -0
- package/dist/lib/audit.d.ts +3 -0
- package/dist/lib/audit.js +66 -0
- package/dist/lib/auth.d.ts +26 -0
- package/dist/lib/auth.js +199 -0
- package/dist/lib/cache.d.ts +25 -0
- package/dist/lib/cache.js +38 -0
- package/dist/lib/cli.d.ts +1 -0
- package/dist/lib/cli.js +10 -0
- package/dist/lib/corine.d.ts +31 -0
- package/dist/lib/corine.js +137 -0
- package/dist/lib/eea-natura2000.d.ts +7 -0
- package/dist/lib/eea-natura2000.js +53 -0
- package/dist/lib/entsoe-client.d.ts +22 -0
- package/dist/lib/entsoe-client.js +69 -0
- package/dist/lib/gis-sources.d.ts +33 -0
- package/dist/lib/gis-sources.js +392 -0
- package/dist/lib/natural-england.d.ts +27 -0
- package/dist/lib/natural-england.js +105 -0
- package/dist/lib/neso-gsp.d.ts +18 -0
- package/dist/lib/neso-gsp.js +113 -0
- package/dist/lib/overpass.d.ts +13 -0
- package/dist/lib/overpass.js +193 -0
- package/dist/lib/profiles.d.ts +23 -0
- package/dist/lib/profiles.js +149 -0
- package/dist/lib/schema-guard.d.ts +22 -0
- package/dist/lib/schema-guard.js +38 -0
- package/dist/lib/tool-handler.d.ts +15 -0
- package/dist/lib/tool-handler.js +95 -0
- package/dist/lib/xml-parser.d.ts +4 -0
- package/dist/lib/xml-parser.js +34 -0
- package/dist/lib/zone-codes.d.ts +12 -0
- package/dist/lib/zone-codes.js +127 -0
- package/dist/tools/acer-remit.d.ts +60 -0
- package/dist/tools/acer-remit.js +154 -0
- package/dist/tools/agricultural-land.d.ts +31 -0
- package/dist/tools/agricultural-land.js +210 -0
- package/dist/tools/ancillary-prices.d.ts +27 -0
- package/dist/tools/ancillary-prices.js +70 -0
- package/dist/tools/auctions.d.ts +15 -0
- package/dist/tools/auctions.js +89 -0
- package/dist/tools/balancing-actions.d.ts +22 -0
- package/dist/tools/balancing-actions.js +151 -0
- package/dist/tools/balancing.d.ts +21 -0
- package/dist/tools/balancing.js +56 -0
- package/dist/tools/carbon.d.ts +21 -0
- package/dist/tools/carbon.js +68 -0
- package/dist/tools/commodity-prices.d.ts +26 -0
- package/dist/tools/commodity-prices.js +100 -0
- package/dist/tools/compare-sites.d.ts +41 -0
- package/dist/tools/compare-sites.js +237 -0
- package/dist/tools/demand-forecast.d.ts +21 -0
- package/dist/tools/demand-forecast.js +56 -0
- package/dist/tools/elexon-bmrs.d.ts +72 -0
- package/dist/tools/elexon-bmrs.js +117 -0
- package/dist/tools/energi-data.d.ts +72 -0
- package/dist/tools/energi-data.js +170 -0
- package/dist/tools/energy-charts.d.ts +103 -0
- package/dist/tools/energy-charts.js +411 -0
- package/dist/tools/entsog.d.ts +71 -0
- package/dist/tools/entsog.js +159 -0
- package/dist/tools/era5-weather.d.ts +39 -0
- package/dist/tools/era5-weather.js +117 -0
- package/dist/tools/eu-gas-price.d.ts +38 -0
- package/dist/tools/eu-gas-price.js +110 -0
- package/dist/tools/fingrid.d.ts +39 -0
- package/dist/tools/fingrid.js +158 -0
- package/dist/tools/flood-risk.d.ts +33 -0
- package/dist/tools/flood-risk.js +166 -0
- package/dist/tools/flows.d.ts +23 -0
- package/dist/tools/flows.js +61 -0
- package/dist/tools/frequency.d.ts +10 -0
- package/dist/tools/frequency.js +35 -0
- package/dist/tools/gas-storage.d.ts +18 -0
- package/dist/tools/gas-storage.js +72 -0
- package/dist/tools/generation.d.ts +17 -0
- package/dist/tools/generation.js +80 -0
- package/dist/tools/grid-connection-intelligence.d.ts +42 -0
- package/dist/tools/grid-connection-intelligence.js +122 -0
- package/dist/tools/grid-connection-queue.d.ts +64 -0
- package/dist/tools/grid-connection-queue.js +198 -0
- package/dist/tools/grid-proximity.d.ts +38 -0
- package/dist/tools/grid-proximity.js +123 -0
- package/dist/tools/hydro-inflows.d.ts +34 -0
- package/dist/tools/hydro-inflows.js +114 -0
- package/dist/tools/hydro.d.ts +18 -0
- package/dist/tools/hydro.js +85 -0
- package/dist/tools/imbalance-prices.d.ts +21 -0
- package/dist/tools/imbalance-prices.js +56 -0
- package/dist/tools/intraday-prices.d.ts +21 -0
- package/dist/tools/intraday-prices.js +57 -0
- package/dist/tools/intraday-spread.d.ts +24 -0
- package/dist/tools/intraday-spread.js +55 -0
- package/dist/tools/land-constraints.d.ts +25 -0
- package/dist/tools/land-constraints.js +148 -0
- package/dist/tools/land-cover.d.ts +18 -0
- package/dist/tools/land-cover.js +64 -0
- package/dist/tools/lng-terminals.d.ts +22 -0
- package/dist/tools/lng-terminals.js +75 -0
- package/dist/tools/net-positions.d.ts +19 -0
- package/dist/tools/net-positions.js +74 -0
- package/dist/tools/nordpool-prices.d.ts +29 -0
- package/dist/tools/nordpool-prices.js +80 -0
- package/dist/tools/outages.d.ts +28 -0
- package/dist/tools/outages.js +107 -0
- package/dist/tools/power-plants.d.ts +26 -0
- package/dist/tools/power-plants.js +224 -0
- package/dist/tools/price-spread-analysis.d.ts +27 -0
- package/dist/tools/price-spread-analysis.js +97 -0
- package/dist/tools/prices.d.ts +23 -0
- package/dist/tools/prices.js +79 -0
- package/dist/tools/realtime-generation.d.ts +19 -0
- package/dist/tools/realtime-generation.js +141 -0
- package/dist/tools/ree-esios.d.ts +78 -0
- package/dist/tools/ree-esios.js +216 -0
- package/dist/tools/regelleistung.d.ts +28 -0
- package/dist/tools/regelleistung.js +71 -0
- package/dist/tools/remit-messages.d.ts +23 -0
- package/dist/tools/remit-messages.js +110 -0
- package/dist/tools/renewable-forecast.d.ts +23 -0
- package/dist/tools/renewable-forecast.js +75 -0
- package/dist/tools/rte-france.d.ts +72 -0
- package/dist/tools/rte-france.js +147 -0
- package/dist/tools/screen-site.d.ts +50 -0
- package/dist/tools/screen-site.js +288 -0
- package/dist/tools/site-revenue.d.ts +50 -0
- package/dist/tools/site-revenue.js +147 -0
- package/dist/tools/smard-data.d.ts +34 -0
- package/dist/tools/smard-data.js +155 -0
- package/dist/tools/solar.d.ts +23 -0
- package/dist/tools/solar.js +69 -0
- package/dist/tools/stormglass.d.ts +56 -0
- package/dist/tools/stormglass.js +172 -0
- package/dist/tools/terna.d.ts +69 -0
- package/dist/tools/terna.js +159 -0
- package/dist/tools/terrain-analysis.d.ts +19 -0
- package/dist/tools/terrain-analysis.js +120 -0
- package/dist/tools/transfer-capacity.d.ts +22 -0
- package/dist/tools/transfer-capacity.js +61 -0
- package/dist/tools/transmission.d.ts +29 -0
- package/dist/tools/transmission.js +159 -0
- package/dist/tools/uk-carbon.d.ts +51 -0
- package/dist/tools/uk-carbon.js +109 -0
- package/dist/tools/uk-grid.d.ts +28 -0
- package/dist/tools/uk-grid.js +70 -0
- package/dist/tools/us-gas.d.ts +30 -0
- package/dist/tools/us-gas.js +100 -0
- package/dist/tools/verify-gis-sources.d.ts +25 -0
- package/dist/tools/verify-gis-sources.js +119 -0
- package/dist/tools/weather.d.ts +27 -0
- package/dist/tools/weather.js +120 -0
- package/package.json +62 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
import { resolveApiKey } from "../lib/auth.js";
|
|
4
|
+
const API_BASE = "https://data.fingrid.fi/api/datasets";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const PAGE_SIZE = 200;
|
|
8
|
+
/** Fingrid dataset IDs */
|
|
9
|
+
const DATASET_IDS = {
|
|
10
|
+
consumption: 124,
|
|
11
|
+
production: 74,
|
|
12
|
+
wind_production: 75,
|
|
13
|
+
solar_production: 248,
|
|
14
|
+
nuclear_production: 188,
|
|
15
|
+
hydro_production: 191,
|
|
16
|
+
imports: 87,
|
|
17
|
+
exports: 89,
|
|
18
|
+
frequency: 177,
|
|
19
|
+
reserve_prices: 244,
|
|
20
|
+
};
|
|
21
|
+
export const fingridSchema = z.object({
|
|
22
|
+
dataset: z
|
|
23
|
+
.enum([
|
|
24
|
+
"consumption",
|
|
25
|
+
"production",
|
|
26
|
+
"wind_production",
|
|
27
|
+
"solar_production",
|
|
28
|
+
"nuclear_production",
|
|
29
|
+
"hydro_production",
|
|
30
|
+
"imports",
|
|
31
|
+
"exports",
|
|
32
|
+
"frequency",
|
|
33
|
+
"reserve_prices",
|
|
34
|
+
])
|
|
35
|
+
.describe('"consumption" = Finnish electricity consumption (MW, 3-min). ' +
|
|
36
|
+
'"production" = total electricity production (MW, 3-min). ' +
|
|
37
|
+
'"wind_production" = wind power generation (MW, 3-min). ' +
|
|
38
|
+
'"solar_production" = solar power estimate (MW). ' +
|
|
39
|
+
'"nuclear_production" = nuclear generation (MW, hourly). ' +
|
|
40
|
+
'"hydro_production" = hydro generation (MW, hourly). ' +
|
|
41
|
+
'"imports" = electricity imports to Finland (MW). ' +
|
|
42
|
+
'"exports" = electricity exports from Finland (MW). ' +
|
|
43
|
+
'"frequency" = grid frequency measurements (Hz, 3-min). ' +
|
|
44
|
+
'"reserve_prices" = balancing reserve market prices.'),
|
|
45
|
+
start_date: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Start datetime ISO-8601 (e.g. 2025-01-15T00:00:00Z). Defaults to 24h ago."),
|
|
49
|
+
end_date: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("End datetime ISO-8601. Defaults to now."),
|
|
53
|
+
});
|
|
54
|
+
async function getApiKey() {
|
|
55
|
+
try {
|
|
56
|
+
return await resolveApiKey("FINGRID_API_KEY");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error("FINGRID_API_KEY is required. Set it as an environment variable or in ~/.luminus/keys.json. " +
|
|
60
|
+
"Get one free at https://data.fingrid.fi/ (register for API access).");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const UNITS = {
|
|
64
|
+
consumption: "MW",
|
|
65
|
+
production: "MW",
|
|
66
|
+
wind_production: "MW",
|
|
67
|
+
solar_production: "MW",
|
|
68
|
+
nuclear_production: "MW",
|
|
69
|
+
hydro_production: "MW",
|
|
70
|
+
imports: "MW",
|
|
71
|
+
exports: "MW",
|
|
72
|
+
frequency: "Hz",
|
|
73
|
+
reserve_prices: "EUR/MW",
|
|
74
|
+
};
|
|
75
|
+
function alignToBucket(date, bucketMs) {
|
|
76
|
+
return new Date(Math.floor(date.getTime() / bucketMs) * bucketMs);
|
|
77
|
+
}
|
|
78
|
+
function getEffectiveWindow(params) {
|
|
79
|
+
if (params.start_date && params.end_date) {
|
|
80
|
+
return { startDate: params.start_date, endDate: params.end_date };
|
|
81
|
+
}
|
|
82
|
+
const alignedNow = alignToBucket(new Date(), TTL.REALTIME);
|
|
83
|
+
const alignedDayAgo = new Date(alignedNow.getTime() - DEFAULT_WINDOW_MS);
|
|
84
|
+
return {
|
|
85
|
+
startDate: params.start_date ?? alignedDayAgo.toISOString(),
|
|
86
|
+
endDate: params.end_date ?? alignedNow.toISOString(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function buildPageUrl(datasetId, startDate, endDate, page) {
|
|
90
|
+
return (`${API_BASE}/${datasetId}/data?startTime=${encodeURIComponent(startDate)}` +
|
|
91
|
+
`&endTime=${encodeURIComponent(endDate)}&format=json&pageSize=${PAGE_SIZE}&page=${page}`);
|
|
92
|
+
}
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
function extractRows(json) {
|
|
95
|
+
return Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
|
|
96
|
+
}
|
|
97
|
+
async function fetchAllRows(datasetId, startDate, endDate, apiKey) {
|
|
98
|
+
const rows = [];
|
|
99
|
+
for (let page = 1; page <= 50; page++) {
|
|
100
|
+
const url = buildPageUrl(datasetId, startDate, endDate, page);
|
|
101
|
+
const response = await fetch(url, {
|
|
102
|
+
headers: {
|
|
103
|
+
"x-api-key": apiKey,
|
|
104
|
+
Accept: "application/json",
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const body = await response.text();
|
|
109
|
+
throw new Error(`Fingrid API returned ${response.status}: ${body.slice(0, 300)}`);
|
|
110
|
+
}
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
const json = await response.json();
|
|
113
|
+
const pageRows = extractRows(json);
|
|
114
|
+
rows.push(...pageRows.map((r) => ({
|
|
115
|
+
timestamp: r.startTime ?? r.start_time ?? r.timestamp ?? "",
|
|
116
|
+
value: Math.round(Number(r.value ?? 0) * 100) / 100,
|
|
117
|
+
})));
|
|
118
|
+
if (pageRows.length < PAGE_SIZE) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return rows
|
|
123
|
+
.filter((row) => row.timestamp)
|
|
124
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
125
|
+
}
|
|
126
|
+
export async function getFingridData(params) {
|
|
127
|
+
const apiKey = await getApiKey();
|
|
128
|
+
const datasetId = DATASET_IDS[params.dataset];
|
|
129
|
+
const unit = UNITS[params.dataset] ?? "MW";
|
|
130
|
+
const { startDate, endDate } = getEffectiveWindow(params);
|
|
131
|
+
const cacheKey = `fingrid:${datasetId}:${startDate}:${endDate}`;
|
|
132
|
+
const cached = cache.get(cacheKey);
|
|
133
|
+
if (cached)
|
|
134
|
+
return cached;
|
|
135
|
+
const data = await fetchAllRows(datasetId, startDate, endDate, apiKey);
|
|
136
|
+
const values = data.map((d) => d.value);
|
|
137
|
+
const stats = values.length > 0
|
|
138
|
+
? {
|
|
139
|
+
min: Math.round(Math.min(...values) * 100) / 100,
|
|
140
|
+
max: Math.round(Math.max(...values) * 100) / 100,
|
|
141
|
+
mean: Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 100) / 100,
|
|
142
|
+
}
|
|
143
|
+
: null;
|
|
144
|
+
const result = {
|
|
145
|
+
source: "Fingrid Open Data",
|
|
146
|
+
dataset: params.dataset,
|
|
147
|
+
dataset_id: datasetId,
|
|
148
|
+
unit,
|
|
149
|
+
start_date: startDate,
|
|
150
|
+
end_date: endDate,
|
|
151
|
+
count: data.length,
|
|
152
|
+
latest: data.length > 0 ? data[data.length - 1] : null,
|
|
153
|
+
data,
|
|
154
|
+
stats,
|
|
155
|
+
};
|
|
156
|
+
cache.set(cacheKey, result, TTL.REALTIME);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type GisSourceMetadata } from "../lib/gis-sources.js";
|
|
3
|
+
export declare const floodRiskSchema: z.ZodObject<{
|
|
4
|
+
lat: z.ZodNumber;
|
|
5
|
+
lon: z.ZodNumber;
|
|
6
|
+
country: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
type FloodZone = "1" | "2" | "3" | "unknown";
|
|
9
|
+
type PlanningRisk = "low" | "medium" | "high" | "unknown";
|
|
10
|
+
type FloodLayerKey = "flood_storage_area" | "flood_zone_3" | "flood_zone_2";
|
|
11
|
+
interface FloodMatch {
|
|
12
|
+
layer: FloodLayerKey;
|
|
13
|
+
label: string;
|
|
14
|
+
type: string | null;
|
|
15
|
+
area_ha: number | null;
|
|
16
|
+
}
|
|
17
|
+
interface FloodRiskResult {
|
|
18
|
+
lat: number;
|
|
19
|
+
lon: number;
|
|
20
|
+
country: string;
|
|
21
|
+
flood_zone: FloodZone;
|
|
22
|
+
flood_storage_area: boolean;
|
|
23
|
+
planning_risk: PlanningRisk;
|
|
24
|
+
flood_zone_3: FloodMatch[];
|
|
25
|
+
flood_zone_2: FloodMatch[];
|
|
26
|
+
flood_storage_areas: FloodMatch[];
|
|
27
|
+
explanation: string;
|
|
28
|
+
source_metadata: GisSourceMetadata;
|
|
29
|
+
warnings?: string[];
|
|
30
|
+
disclaimer: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function getFloodRisk(params: z.infer<typeof floodRiskSchema>): Promise<FloodRiskResult>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
import { GIS_SOURCES } from "../lib/gis-sources.js";
|
|
4
|
+
import { guardArcGisFields } from "../lib/schema-guard.js";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
const EA_FLOOD_MAP_BASE = "https://environment.data.gov.uk/KB6uNVj5ZcJr7jUP/ArcGIS/rest/services/Flood_Map_for_Planning/FeatureServer";
|
|
7
|
+
export const floodRiskSchema = z.object({
|
|
8
|
+
lat: z.number().describe("Latitude (-90 to 90). WGS84."),
|
|
9
|
+
lon: z.number().describe("Longitude (-180 to 180). WGS84."),
|
|
10
|
+
country: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('ISO 3166-1 alpha-2 country code. Only "GB" is supported in this version.'),
|
|
13
|
+
});
|
|
14
|
+
const DISCLAIMER = "This is an automated flood-planning screen using the Environment Agency Flood Map for Planning. " +
|
|
15
|
+
"It is not a site-specific flood risk assessment or planning determination.";
|
|
16
|
+
const FLOOD_LAYERS = [
|
|
17
|
+
{ id: 0, key: "flood_storage_area", label: "Flood Storage Areas" },
|
|
18
|
+
{ id: 1, key: "flood_zone_3", label: "Flood Zone 3" },
|
|
19
|
+
{ id: 2, key: "flood_zone_2", label: "Flood Zone 2" },
|
|
20
|
+
];
|
|
21
|
+
function buildPointQueryUrl(layerId, lon, lat) {
|
|
22
|
+
const url = new URL(`${EA_FLOOD_MAP_BASE}/${layerId}/query`);
|
|
23
|
+
const p = url.searchParams;
|
|
24
|
+
p.set("where", "1=1");
|
|
25
|
+
p.set("geometry", `${lon},${lat}`);
|
|
26
|
+
p.set("geometryType", "esriGeometryPoint");
|
|
27
|
+
p.set("inSR", "4326");
|
|
28
|
+
p.set("spatialRel", "esriSpatialRelIntersects");
|
|
29
|
+
p.set("outFields", "layer,type,Shape__Area");
|
|
30
|
+
p.set("returnGeometry", "false");
|
|
31
|
+
p.set("resultRecordCount", "10");
|
|
32
|
+
p.set("f", "json");
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
async function queryLayer(layer, lon, lat) {
|
|
36
|
+
const response = await fetch(buildPointQueryUrl(layer.id, lon, lat));
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const body = await response.text();
|
|
39
|
+
throw new Error(`Environment Agency Flood Map API returned ${response.status} for ${layer.label}: ${body.slice(0, 300)}`);
|
|
40
|
+
}
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
const json = await response.json();
|
|
43
|
+
if (json.error) {
|
|
44
|
+
throw new Error(`Environment Agency Flood Map API error for ${layer.label}: ${json.error.message ?? JSON.stringify(json.error)}`);
|
|
45
|
+
}
|
|
46
|
+
const features = Array.isArray(json.features) ? json.features : [];
|
|
47
|
+
guardArcGisFields(features, ["layer", "Shape__Area"], `Environment Agency Flood Map (${layer.label})`);
|
|
48
|
+
return features;
|
|
49
|
+
}
|
|
50
|
+
function toRoundedHectares(value) {
|
|
51
|
+
if (typeof value !== "number")
|
|
52
|
+
return null;
|
|
53
|
+
return Math.round((value / 10_000) * 100) / 100;
|
|
54
|
+
}
|
|
55
|
+
function toOptionalString(value) {
|
|
56
|
+
if (typeof value !== "string")
|
|
57
|
+
return null;
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
60
|
+
}
|
|
61
|
+
function mapMatches(layer, features) {
|
|
62
|
+
return features.map((feature) => {
|
|
63
|
+
const attrs = feature.attributes ?? {};
|
|
64
|
+
return {
|
|
65
|
+
layer: layer.key,
|
|
66
|
+
label: toOptionalString(attrs.layer) ?? layer.label,
|
|
67
|
+
type: toOptionalString(attrs.type),
|
|
68
|
+
area_ha: toRoundedHectares(attrs.Shape__Area),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function buildExplanation(floodZone, hasFloodStorageArea, planningRisk) {
|
|
73
|
+
if (hasFloodStorageArea && floodZone === "3") {
|
|
74
|
+
return "Point intersects a flood storage area and Flood Zone 3. Treat this as a high planning-risk site for development screening.";
|
|
75
|
+
}
|
|
76
|
+
if (hasFloodStorageArea && floodZone === "2") {
|
|
77
|
+
return "Point intersects a flood storage area and Flood Zone 2. Treat this as a high planning-risk site for development screening.";
|
|
78
|
+
}
|
|
79
|
+
if (hasFloodStorageArea) {
|
|
80
|
+
return "Point intersects a flood storage area. Treat this as a high planning-risk site for development screening.";
|
|
81
|
+
}
|
|
82
|
+
if (floodZone === "3") {
|
|
83
|
+
return "Point is in Flood Zone 3, the high-probability flood zone in the Environment Agency Flood Map for Planning.";
|
|
84
|
+
}
|
|
85
|
+
if (floodZone === "2") {
|
|
86
|
+
return "Point is in Flood Zone 2, but not Flood Zone 3, in the Environment Agency Flood Map for Planning.";
|
|
87
|
+
}
|
|
88
|
+
if (floodZone === "1" && planningRisk === "low") {
|
|
89
|
+
return "Point does not intersect Flood Zone 2, Flood Zone 3, or a flood storage area in the Environment Agency Flood Map for Planning.";
|
|
90
|
+
}
|
|
91
|
+
return "Flood classification is unknown because one or more Environment Agency flood layers could not be checked.";
|
|
92
|
+
}
|
|
93
|
+
export async function getFloodRisk(params) {
|
|
94
|
+
const { lat, lon } = params;
|
|
95
|
+
const country = params.country.toUpperCase();
|
|
96
|
+
if (country !== "GB") {
|
|
97
|
+
throw new Error(`Country "${params.country}" is not supported. Only "GB" (Great Britain) is available in this version. England flood-planning coverage is implemented first via the Environment Agency Flood Map for Planning.`);
|
|
98
|
+
}
|
|
99
|
+
if (lat < -90 || lat > 90) {
|
|
100
|
+
throw new Error("Latitude must be between -90 and 90.");
|
|
101
|
+
}
|
|
102
|
+
if (lon < -180 || lon > 180) {
|
|
103
|
+
throw new Error("Longitude must be between -180 and 180.");
|
|
104
|
+
}
|
|
105
|
+
const cacheKey = `flood-risk:${lat}:${lon}:${country}`;
|
|
106
|
+
const cached = cache.get(cacheKey);
|
|
107
|
+
if (cached)
|
|
108
|
+
return cached;
|
|
109
|
+
const results = await Promise.allSettled(FLOOD_LAYERS.map((layer) => queryLayer(layer, lon, lat)));
|
|
110
|
+
const warnings = [];
|
|
111
|
+
const storageAreas = results[0].status === "fulfilled" ? mapMatches(FLOOD_LAYERS[0], results[0].value) : [];
|
|
112
|
+
if (results[0].status === "rejected") {
|
|
113
|
+
warnings.push(`flood_storage_area: ${results[0].reason instanceof Error ? results[0].reason.message : String(results[0].reason)}`);
|
|
114
|
+
}
|
|
115
|
+
const zone3 = results[1].status === "fulfilled" ? mapMatches(FLOOD_LAYERS[1], results[1].value) : [];
|
|
116
|
+
if (results[1].status === "rejected") {
|
|
117
|
+
warnings.push(`flood_zone_3: ${results[1].reason instanceof Error ? results[1].reason.message : String(results[1].reason)}`);
|
|
118
|
+
}
|
|
119
|
+
const zone2 = results[2].status === "fulfilled" ? mapMatches(FLOOD_LAYERS[2], results[2].value) : [];
|
|
120
|
+
if (results[2].status === "rejected") {
|
|
121
|
+
warnings.push(`flood_zone_2: ${results[2].reason instanceof Error ? results[2].reason.message : String(results[2].reason)}`);
|
|
122
|
+
}
|
|
123
|
+
if (results.every((result) => result.status === "rejected")) {
|
|
124
|
+
throw new Error(`All Environment Agency flood queries failed: ${warnings.join("; ")}`);
|
|
125
|
+
}
|
|
126
|
+
const hasFloodStorageArea = storageAreas.length > 0;
|
|
127
|
+
const hasZone3 = zone3.length > 0;
|
|
128
|
+
const hasZone2 = zone2.length > 0;
|
|
129
|
+
const hasWarnings = warnings.length > 0;
|
|
130
|
+
let floodZone = "1";
|
|
131
|
+
let planningRisk = "low";
|
|
132
|
+
if (hasZone3) {
|
|
133
|
+
floodZone = "3";
|
|
134
|
+
planningRisk = "high";
|
|
135
|
+
}
|
|
136
|
+
else if (hasZone2) {
|
|
137
|
+
floodZone = "2";
|
|
138
|
+
planningRisk = "medium";
|
|
139
|
+
}
|
|
140
|
+
else if (hasWarnings) {
|
|
141
|
+
floodZone = "unknown";
|
|
142
|
+
planningRisk = "unknown";
|
|
143
|
+
}
|
|
144
|
+
if (hasFloodStorageArea) {
|
|
145
|
+
planningRisk = "high";
|
|
146
|
+
}
|
|
147
|
+
const result = {
|
|
148
|
+
lat,
|
|
149
|
+
lon,
|
|
150
|
+
country: "GB",
|
|
151
|
+
flood_zone: floodZone,
|
|
152
|
+
flood_storage_area: hasFloodStorageArea,
|
|
153
|
+
planning_risk: planningRisk,
|
|
154
|
+
flood_zone_3: zone3,
|
|
155
|
+
flood_zone_2: zone2,
|
|
156
|
+
flood_storage_areas: storageAreas,
|
|
157
|
+
explanation: buildExplanation(floodZone, hasFloodStorageArea, planningRisk),
|
|
158
|
+
source_metadata: GIS_SOURCES["ea-flood-map"],
|
|
159
|
+
disclaimer: DISCLAIMER,
|
|
160
|
+
};
|
|
161
|
+
if (warnings.length > 0) {
|
|
162
|
+
result.warnings = warnings;
|
|
163
|
+
}
|
|
164
|
+
cache.set(cacheKey, result, TTL.STATIC_DATA);
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const flowsSchema: z.ZodObject<{
|
|
3
|
+
from_zone: z.ZodString;
|
|
4
|
+
to_zone: z.ZodString;
|
|
5
|
+
date: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
interface FlowPoint {
|
|
8
|
+
hour: number;
|
|
9
|
+
mw: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function getCrossBorderFlows(params: z.infer<typeof flowsSchema>): Promise<{
|
|
12
|
+
from_zone: string;
|
|
13
|
+
to_zone: string;
|
|
14
|
+
date: string;
|
|
15
|
+
flows: FlowPoint[];
|
|
16
|
+
stats: {
|
|
17
|
+
min: number;
|
|
18
|
+
max: number;
|
|
19
|
+
mean: number;
|
|
20
|
+
net_mwh: number;
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { queryEntsoe, dayRange } from "../lib/entsoe-client.js";
|
|
3
|
+
import { resolveZone, AVAILABLE_ZONES } from "../lib/zone-codes.js";
|
|
4
|
+
import { ensureArray } from "../lib/xml-parser.js";
|
|
5
|
+
import { TTL } from "../lib/cache.js";
|
|
6
|
+
export const flowsSchema = z.object({
|
|
7
|
+
from_zone: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe(`Exporting zone. Examples: FR, DE. Available: ${AVAILABLE_ZONES}`),
|
|
10
|
+
to_zone: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe(`Importing zone. Examples: DE, GB. Available: ${AVAILABLE_ZONES}`),
|
|
13
|
+
date: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Date in YYYY-MM-DD format. Defaults to today."),
|
|
17
|
+
});
|
|
18
|
+
export async function getCrossBorderFlows(params) {
|
|
19
|
+
const fromEic = resolveZone(params.from_zone);
|
|
20
|
+
const toEic = resolveZone(params.to_zone);
|
|
21
|
+
const { periodStart, periodEnd } = dayRange(params.date);
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
const data = await queryEntsoe({
|
|
24
|
+
documentType: "A11",
|
|
25
|
+
in_Domain: toEic,
|
|
26
|
+
out_Domain: fromEic,
|
|
27
|
+
periodStart,
|
|
28
|
+
periodEnd,
|
|
29
|
+
}, TTL.FLOWS);
|
|
30
|
+
const doc = data.GL_MarketDocument;
|
|
31
|
+
if (!doc)
|
|
32
|
+
throw new Error("No flow data returned for this corridor/date.");
|
|
33
|
+
const timeSeries = ensureArray(doc.TimeSeries);
|
|
34
|
+
const flows = [];
|
|
35
|
+
for (const ts of timeSeries) {
|
|
36
|
+
const periods = ensureArray(ts.Period);
|
|
37
|
+
for (const period of periods) {
|
|
38
|
+
const points = ensureArray(period.Point);
|
|
39
|
+
for (const point of points) {
|
|
40
|
+
const position = Number(point.position);
|
|
41
|
+
const mw = Number(point.quantity);
|
|
42
|
+
flows.push({ hour: position - 1, mw });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
flows.sort((a, b) => a.hour - b.hour);
|
|
47
|
+
const values = flows.map((f) => f.mw);
|
|
48
|
+
const min = Math.min(...values);
|
|
49
|
+
const max = Math.max(...values);
|
|
50
|
+
const mean = values.length > 0
|
|
51
|
+
? Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 100) / 100
|
|
52
|
+
: 0;
|
|
53
|
+
const net_mwh = values.reduce((s, v) => s + v, 0);
|
|
54
|
+
return {
|
|
55
|
+
from_zone: params.from_zone.toUpperCase(),
|
|
56
|
+
to_zone: params.to_zone.toUpperCase(),
|
|
57
|
+
date: params.date ?? new Date().toISOString().slice(0, 10),
|
|
58
|
+
flows,
|
|
59
|
+
stats: { min, max, mean, net_mwh },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const frequencySchema: z.ZodObject<{}, z.core.$strip>;
|
|
3
|
+
interface FrequencyResult {
|
|
4
|
+
frequency_hz: number;
|
|
5
|
+
deviation_mhz: number;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
status: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getEuFrequency(_params: z.infer<typeof frequencySchema>): Promise<FrequencyResult>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
const API_URL = "https://www.mainsfrequency.com/api.php?unit=mHz";
|
|
4
|
+
const cache = new TtlCache();
|
|
5
|
+
export const frequencySchema = z.object({});
|
|
6
|
+
export async function getEuFrequency(_params) {
|
|
7
|
+
const cacheKey = "eu-frequency";
|
|
8
|
+
const cached = cache.get(cacheKey);
|
|
9
|
+
if (cached)
|
|
10
|
+
return cached;
|
|
11
|
+
const response = await fetch(API_URL);
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
throw new Error(`Frequency API returned ${response.status}`);
|
|
14
|
+
}
|
|
15
|
+
const text = await response.text();
|
|
16
|
+
const mhz = Number(text.trim());
|
|
17
|
+
if (isNaN(mhz)) {
|
|
18
|
+
throw new Error(`Unexpected frequency API response: ${text.slice(0, 100)}`);
|
|
19
|
+
}
|
|
20
|
+
const frequencyHz = mhz / 1000;
|
|
21
|
+
const nominalHz = 50.0;
|
|
22
|
+
const deviationMhz = mhz - nominalHz * 1000;
|
|
23
|
+
const status = Math.abs(deviationMhz) <= 20 ? "normal" :
|
|
24
|
+
Math.abs(deviationMhz) <= 50 ? "minor deviation" :
|
|
25
|
+
Math.abs(deviationMhz) <= 100 ? "significant deviation" :
|
|
26
|
+
"critical deviation";
|
|
27
|
+
const result = {
|
|
28
|
+
frequency_hz: Math.round(frequencyHz * 1000) / 1000,
|
|
29
|
+
deviation_mhz: Math.round(deviationMhz),
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
status,
|
|
32
|
+
};
|
|
33
|
+
cache.set(cacheKey, result, TTL.FREQUENCY);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const gasStorageSchema: z.ZodObject<{
|
|
3
|
+
country: z.ZodString;
|
|
4
|
+
date: z.ZodOptional<z.ZodString>;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
interface StorageData {
|
|
7
|
+
country: string;
|
|
8
|
+
date: string;
|
|
9
|
+
gas_in_storage_twh: number;
|
|
10
|
+
full_pct: number;
|
|
11
|
+
injection_gwh: number;
|
|
12
|
+
withdrawal_gwh: number;
|
|
13
|
+
net_gwh: number;
|
|
14
|
+
trend_vs_last_year_pct: number | null;
|
|
15
|
+
working_volume_twh: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function getGasStorage(params: z.infer<typeof gasStorageSchema>): Promise<StorageData>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
import { resolveApiKey } from "../lib/auth.js";
|
|
4
|
+
const BASE_URL = "https://agsi.gie.eu/api";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
const GIE_COUNTRIES = [
|
|
7
|
+
"EU", "AT", "BE", "BG", "HR", "CZ", "DK", "FR", "DE",
|
|
8
|
+
"HU", "IE", "IT", "LV", "NL", "PL", "PT", "RO", "SK", "ES", "SE", "GB",
|
|
9
|
+
];
|
|
10
|
+
export const gasStorageSchema = z.object({
|
|
11
|
+
country: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe(`Country code for gas storage data. Available: ${GIE_COUNTRIES.join(", ")}`),
|
|
14
|
+
date: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Date in YYYY-MM-DD format. Defaults to today."),
|
|
18
|
+
});
|
|
19
|
+
async function getApiKey() {
|
|
20
|
+
try {
|
|
21
|
+
return await resolveApiKey("GIE_API_KEY");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("GIE_API_KEY is required. Set it as an environment variable or in ~/.luminus/keys.json. " +
|
|
25
|
+
"Get one at https://agsi.gie.eu/ (register for API access).");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function validateCountry(code) {
|
|
29
|
+
const upper = code.toUpperCase();
|
|
30
|
+
if (!GIE_COUNTRIES.includes(upper)) {
|
|
31
|
+
throw new Error(`Unknown country "${code}". Available: ${GIE_COUNTRIES.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
return upper;
|
|
34
|
+
}
|
|
35
|
+
export async function getGasStorage(params) {
|
|
36
|
+
const country = validateCountry(params.country);
|
|
37
|
+
const date = params.date ?? new Date().toISOString().slice(0, 10);
|
|
38
|
+
const cacheKey = `gie:${country}:${date}`;
|
|
39
|
+
const cached = cache.get(cacheKey);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const url = `${BASE_URL}/data/${country}?date=${date}`;
|
|
43
|
+
const response = await fetch(url, {
|
|
44
|
+
headers: { "x-key": await getApiKey() },
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const body = await response.text();
|
|
48
|
+
throw new Error(`GIE API returned ${response.status}: ${body.slice(0, 300)}`);
|
|
49
|
+
}
|
|
50
|
+
const json = await response.json();
|
|
51
|
+
// AGSI+ returns { data: [...] } with storage entries
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
const entries = Array.isArray(json.data) ? json.data : [];
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
throw new Error(`No gas storage data for ${country} on ${date}.`);
|
|
56
|
+
}
|
|
57
|
+
// First entry is the aggregate for the country
|
|
58
|
+
const entry = entries[0];
|
|
59
|
+
const result = {
|
|
60
|
+
country,
|
|
61
|
+
date: entry.gasDayStart ?? date,
|
|
62
|
+
gas_in_storage_twh: Number(entry.gasInStorage ?? 0),
|
|
63
|
+
full_pct: Number(entry.full ?? 0),
|
|
64
|
+
injection_gwh: Number(entry.injection ?? 0),
|
|
65
|
+
withdrawal_gwh: Number(entry.withdrawal ?? 0),
|
|
66
|
+
net_gwh: Number(entry.netWithdrawal ?? 0),
|
|
67
|
+
trend_vs_last_year_pct: entry.trend != null ? Number(entry.trend) : null,
|
|
68
|
+
working_volume_twh: Number(entry.workingGasVolume ?? 0),
|
|
69
|
+
};
|
|
70
|
+
cache.set(cacheKey, result, TTL.STORAGE);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const generationSchema: z.ZodObject<{
|
|
3
|
+
zone: z.ZodString;
|
|
4
|
+
date: z.ZodOptional<z.ZodString>;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
interface GenerationEntry {
|
|
7
|
+
fuel_type: string;
|
|
8
|
+
psr_code: string;
|
|
9
|
+
mw: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function getGenerationMix(params: z.infer<typeof generationSchema>): Promise<{
|
|
12
|
+
zone: string;
|
|
13
|
+
date: string;
|
|
14
|
+
generation: GenerationEntry[];
|
|
15
|
+
total_mw: number;
|
|
16
|
+
}>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { queryEntsoe, dayRange } from "../lib/entsoe-client.js";
|
|
3
|
+
import { resolveZone, AVAILABLE_ZONES } from "../lib/zone-codes.js";
|
|
4
|
+
import { ensureArray } from "../lib/xml-parser.js";
|
|
5
|
+
import { TTL } from "../lib/cache.js";
|
|
6
|
+
export const generationSchema = z.object({
|
|
7
|
+
zone: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe(`Country/zone code. Examples: DE, FR, GB, NL, ES. Available: ${AVAILABLE_ZONES}`),
|
|
10
|
+
date: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Date in YYYY-MM-DD format. Defaults to today."),
|
|
14
|
+
});
|
|
15
|
+
/** ENTSO-E PSR type codes to human-readable fuel names */
|
|
16
|
+
const PSR_TYPES = {
|
|
17
|
+
B01: "Biomass",
|
|
18
|
+
B02: "Fossil Brown coal/Lignite",
|
|
19
|
+
B03: "Fossil Coal-derived gas",
|
|
20
|
+
B04: "Fossil Gas",
|
|
21
|
+
B05: "Fossil Hard coal",
|
|
22
|
+
B06: "Fossil Oil",
|
|
23
|
+
B07: "Fossil Oil shale",
|
|
24
|
+
B08: "Fossil Peat",
|
|
25
|
+
B09: "Geothermal",
|
|
26
|
+
B10: "Hydro Pumped Storage",
|
|
27
|
+
B11: "Hydro Run-of-river and poundage",
|
|
28
|
+
B12: "Hydro Water Reservoir",
|
|
29
|
+
B13: "Marine",
|
|
30
|
+
B14: "Nuclear",
|
|
31
|
+
B15: "Other renewable",
|
|
32
|
+
B16: "Solar",
|
|
33
|
+
B17: "Waste",
|
|
34
|
+
B18: "Wind Offshore",
|
|
35
|
+
B19: "Wind Onshore",
|
|
36
|
+
B20: "Other",
|
|
37
|
+
};
|
|
38
|
+
export async function getGenerationMix(params) {
|
|
39
|
+
const eic = resolveZone(params.zone);
|
|
40
|
+
const { periodStart, periodEnd } = dayRange(params.date);
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
const data = await queryEntsoe({
|
|
43
|
+
documentType: "A75",
|
|
44
|
+
processType: "A16",
|
|
45
|
+
in_Domain: eic,
|
|
46
|
+
periodStart,
|
|
47
|
+
periodEnd,
|
|
48
|
+
}, TTL.REALTIME);
|
|
49
|
+
const doc = data.GL_MarketDocument;
|
|
50
|
+
if (!doc)
|
|
51
|
+
throw new Error("No generation data returned for this zone/date.");
|
|
52
|
+
const timeSeries = ensureArray(doc.TimeSeries);
|
|
53
|
+
const generation = [];
|
|
54
|
+
for (const ts of timeSeries) {
|
|
55
|
+
const psrCode = ts.MktPSRType?.psrType ?? "unknown";
|
|
56
|
+
const fuelType = PSR_TYPES[psrCode] ?? psrCode;
|
|
57
|
+
const periods = ensureArray(ts.Period);
|
|
58
|
+
// Take the latest period's points and average them
|
|
59
|
+
const lastPeriod = periods[periods.length - 1];
|
|
60
|
+
if (!lastPeriod)
|
|
61
|
+
continue;
|
|
62
|
+
const points = ensureArray(lastPeriod.Point);
|
|
63
|
+
if (points.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
// Use the most recent point's quantity
|
|
66
|
+
const latestPoint = points[points.length - 1];
|
|
67
|
+
const mw = Number(latestPoint["quantity"] ?? 0);
|
|
68
|
+
if (mw > 0) {
|
|
69
|
+
generation.push({ fuel_type: fuelType, psr_code: psrCode, mw });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
generation.sort((a, b) => b.mw - a.mw);
|
|
73
|
+
const total_mw = generation.reduce((sum, g) => sum + g.mw, 0);
|
|
74
|
+
return {
|
|
75
|
+
zone: params.zone.toUpperCase(),
|
|
76
|
+
date: params.date ?? new Date().toISOString().slice(0, 10),
|
|
77
|
+
generation,
|
|
78
|
+
total_mw,
|
|
79
|
+
};
|
|
80
|
+
}
|