@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,159 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
const API_BASE = "https://api.terna.it/transparency/v1.0";
|
|
4
|
+
const TERNA_PUBLIC = "https://www.terna.it/en/electric-system";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
export const ternaSchema = z.object({
|
|
7
|
+
dataset: z
|
|
8
|
+
.enum(["generation", "demand", "exchanges", "market_data"])
|
|
9
|
+
.describe('"generation" = Italian electricity generation by source (MW). ' +
|
|
10
|
+
'"demand" = Italian electricity demand (MW). ' +
|
|
11
|
+
'"exchanges" = cross-border exchange flows with neighbours. ' +
|
|
12
|
+
'"market_data" = Italian zonal electricity market data.'),
|
|
13
|
+
date: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Date in YYYY-MM-DD format. Defaults to today."),
|
|
17
|
+
zone: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Italian market zone: NORD, CNOR, CSUD, SUD, SICI, SARD, or ITALY (national). " +
|
|
21
|
+
"Defaults to ITALY."),
|
|
22
|
+
});
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
async function fetchTerna(endpoint) {
|
|
25
|
+
const cached = cache.get(endpoint);
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
const response = await fetch(endpoint, {
|
|
29
|
+
headers: {
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
"User-Agent": "luminus-mcp/0.2",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const body = await response.text();
|
|
36
|
+
throw new Error(`Terna API returned ${response.status}: ${body.slice(0, 300)}`);
|
|
37
|
+
}
|
|
38
|
+
const json = await response.json();
|
|
39
|
+
cache.set(endpoint, json, TTL.REALTIME);
|
|
40
|
+
return json;
|
|
41
|
+
}
|
|
42
|
+
export async function getTernaData(params) {
|
|
43
|
+
const date = params.date ?? new Date().toISOString().slice(0, 10);
|
|
44
|
+
const zone = params.zone?.toUpperCase() ?? "ITALY";
|
|
45
|
+
try {
|
|
46
|
+
// Try Terna developer API first
|
|
47
|
+
const url = `${API_BASE}/${params.dataset}?date=${date}&zone=${zone}`;
|
|
48
|
+
const data = await fetchTerna(url);
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
const items = Array.isArray(data) ? data : data?.data ?? data?.records ?? [];
|
|
51
|
+
switch (params.dataset) {
|
|
52
|
+
case "generation": {
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
const records = items.slice(0, 24).map((r) => {
|
|
55
|
+
const thermal = Number(r.thermal ?? r.termoelettrico ?? 0);
|
|
56
|
+
const hydro = Number(r.hydro ?? r.idroelettrico ?? 0);
|
|
57
|
+
const wind = Number(r.wind ?? r.eolico ?? 0);
|
|
58
|
+
const solar = Number(r.solar ?? r.fotovoltaico ?? 0);
|
|
59
|
+
const geo = Number(r.geothermal ?? r.geotermico ?? 0);
|
|
60
|
+
const bio = Number(r.biomass ?? r.bioenergie ?? 0);
|
|
61
|
+
const total = thermal + hydro + wind + solar + geo + bio;
|
|
62
|
+
const renewable = hydro + wind + solar + geo + bio;
|
|
63
|
+
return {
|
|
64
|
+
timestamp: r.timestamp ?? r.datetime ?? "",
|
|
65
|
+
thermal_mw: Math.round(thermal),
|
|
66
|
+
hydro_mw: Math.round(hydro),
|
|
67
|
+
wind_mw: Math.round(wind),
|
|
68
|
+
solar_mw: Math.round(solar),
|
|
69
|
+
geothermal_mw: Math.round(geo),
|
|
70
|
+
biomass_mw: Math.round(bio),
|
|
71
|
+
total_mw: Math.round(total),
|
|
72
|
+
renewable_pct: total > 0 ? Math.round((renewable / total) * 1000) / 10 : 0,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
dataset: "generation",
|
|
77
|
+
source: "Terna Transparency (developer.terna.it)",
|
|
78
|
+
date,
|
|
79
|
+
zone,
|
|
80
|
+
records,
|
|
81
|
+
latest: records.length > 0 ? records[0] : null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case "demand": {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
const records = items.slice(0, 24).map((r) => ({
|
|
87
|
+
timestamp: r.timestamp ?? r.datetime ?? "",
|
|
88
|
+
demand_mw: Math.round(Number(r.demand ?? r.fabbisogno ?? 0)),
|
|
89
|
+
forecast_mw: Math.round(Number(r.forecast ?? r.previsione ?? 0)),
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
dataset: "demand",
|
|
93
|
+
source: "Terna Transparency (developer.terna.it)",
|
|
94
|
+
date,
|
|
95
|
+
zone,
|
|
96
|
+
records,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case "exchanges": {
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
const exchanges = items.slice(0, 20).map((r) => {
|
|
102
|
+
const flow = Number(r.flow ?? r.value ?? 0);
|
|
103
|
+
return {
|
|
104
|
+
border: r.border ?? r.country ?? r.confine ?? "Unknown",
|
|
105
|
+
flow_mw: Math.round(flow),
|
|
106
|
+
direction: flow >= 0 ? "import" : "export",
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
const netImport = exchanges.reduce((s, e) => s + e.flow_mw, 0);
|
|
110
|
+
return {
|
|
111
|
+
dataset: "exchanges",
|
|
112
|
+
source: "Terna Transparency (developer.terna.it)",
|
|
113
|
+
date,
|
|
114
|
+
exchanges,
|
|
115
|
+
net_import_mw: netImport,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
case "market_data": {
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
const records = items.slice(0, 24).map((r) => ({
|
|
121
|
+
zone: r.zone ?? r.zona ?? zone,
|
|
122
|
+
timestamp: r.timestamp ?? r.datetime ?? "",
|
|
123
|
+
price_eur_mwh: Math.round((Number(r.price ?? r.prezzo ?? 0)) * 100) / 100,
|
|
124
|
+
}));
|
|
125
|
+
return {
|
|
126
|
+
dataset: "market_data",
|
|
127
|
+
source: "Terna Transparency (developer.terna.it)",
|
|
128
|
+
date,
|
|
129
|
+
zone,
|
|
130
|
+
records,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Fallback with reference info
|
|
137
|
+
const description = `Italian ${params.dataset} data for ${date}. ` +
|
|
138
|
+
`Terna publishes real-time data at ${TERNA_PUBLIC}. ` +
|
|
139
|
+
"Developer API: developer.terna.it. " +
|
|
140
|
+
"Also available via ENTSO-E (get_generation_mix, get_day_ahead_prices) with zone IT.";
|
|
141
|
+
switch (params.dataset) {
|
|
142
|
+
case "generation":
|
|
143
|
+
return {
|
|
144
|
+
dataset: "generation",
|
|
145
|
+
source: "Terna Transparency (reference)",
|
|
146
|
+
date,
|
|
147
|
+
zone,
|
|
148
|
+
records: [],
|
|
149
|
+
latest: null,
|
|
150
|
+
};
|
|
151
|
+
case "demand":
|
|
152
|
+
return { dataset: "demand", source: "Terna (reference)", date, zone, records: [] };
|
|
153
|
+
case "exchanges":
|
|
154
|
+
return { dataset: "exchanges", source: "Terna (reference)", date, exchanges: [], net_import_mw: 0 };
|
|
155
|
+
case "market_data":
|
|
156
|
+
return { dataset: "market_data", source: "Terna (reference)", date, zone, records: [] };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type GisSourceMetadata } from "../lib/gis-sources.js";
|
|
3
|
+
export declare const terrainAnalysisSchema: z.ZodObject<{
|
|
4
|
+
lat: z.ZodNumber;
|
|
5
|
+
lon: z.ZodNumber;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
interface TerrainResult {
|
|
8
|
+
lat: number;
|
|
9
|
+
lon: number;
|
|
10
|
+
elevation_m: number;
|
|
11
|
+
slope_deg: number;
|
|
12
|
+
aspect_deg: number;
|
|
13
|
+
aspect_cardinal: string;
|
|
14
|
+
flatness_score: number;
|
|
15
|
+
source: string;
|
|
16
|
+
source_metadata: GisSourceMetadata;
|
|
17
|
+
}
|
|
18
|
+
export declare function getTerrainAnalysis(params: z.infer<typeof terrainAnalysisSchema>): Promise<TerrainResult>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
import { GIS_SOURCES } from "../lib/gis-sources.js";
|
|
4
|
+
const ELEVATION_URL = "https://api.open-meteo.com/v1/elevation";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
/** Spacing between grid points in degrees (~30m at mid-latitudes). */
|
|
7
|
+
const GRID_STEP_DEG = 0.0003;
|
|
8
|
+
export const terrainAnalysisSchema = z.object({
|
|
9
|
+
lat: z
|
|
10
|
+
.number()
|
|
11
|
+
.describe("Latitude (-90 to 90). WGS84."),
|
|
12
|
+
lon: z
|
|
13
|
+
.number()
|
|
14
|
+
.describe("Longitude (-180 to 180). WGS84."),
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Convert aspect angle (radians, math convention) to cardinal direction.
|
|
18
|
+
* 0° = North, 90° = East, 180° = South, 270° = West.
|
|
19
|
+
*/
|
|
20
|
+
function toCardinal(deg) {
|
|
21
|
+
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
22
|
+
const index = Math.round(deg / 45) % 8;
|
|
23
|
+
return directions[index];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute slope and aspect from a 3x3 elevation grid using Horn's method.
|
|
27
|
+
*
|
|
28
|
+
* Grid layout (row-major):
|
|
29
|
+
* NW N NE [0] [1] [2]
|
|
30
|
+
* W C E [3] [4] [5]
|
|
31
|
+
* SW S SE [6] [7] [8]
|
|
32
|
+
*
|
|
33
|
+
* cellSizeX/cellSizeY are the distances between adjacent cells in metres.
|
|
34
|
+
*/
|
|
35
|
+
function computeSlopeAspect(grid, cellSizeXM, cellSizeYM) {
|
|
36
|
+
const [nw, n, ne, w, , e, sw, s, se] = grid;
|
|
37
|
+
// Horn's method partial derivatives
|
|
38
|
+
const dzdx = ((ne + 2 * e + se) - (nw + 2 * w + sw)) / (8 * cellSizeXM);
|
|
39
|
+
const dzdy = ((nw + 2 * n + ne) - (sw + 2 * s + se)) / (8 * cellSizeYM);
|
|
40
|
+
const slopeRad = Math.atan(Math.sqrt(dzdx * dzdx + dzdy * dzdy));
|
|
41
|
+
const slopeDeg = slopeRad * (180 / Math.PI);
|
|
42
|
+
let aspectDeg;
|
|
43
|
+
if (dzdx === 0 && dzdy === 0) {
|
|
44
|
+
aspectDeg = 0; // flat — aspect undefined, default to north
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// atan2(-dzdy, -dzdx) gives angle from east; convert to compass bearing
|
|
48
|
+
let aspectRad = Math.atan2(-dzdy, -dzdx);
|
|
49
|
+
aspectDeg = aspectRad * (180 / Math.PI);
|
|
50
|
+
// Convert from math angle (E=0, CCW) to compass (N=0, CW)
|
|
51
|
+
aspectDeg = (90 - aspectDeg + 360) % 360;
|
|
52
|
+
}
|
|
53
|
+
// Flatness: 1.0 = perfectly flat, 0.0 = vertical (90° slope)
|
|
54
|
+
const flatness = 1 - slopeDeg / 90;
|
|
55
|
+
return {
|
|
56
|
+
slope_deg: Math.round(slopeDeg * 100) / 100,
|
|
57
|
+
aspect_deg: Math.round(aspectDeg * 100) / 100,
|
|
58
|
+
aspect_cardinal: toCardinal(aspectDeg),
|
|
59
|
+
flatness_score: Math.round(flatness * 100) / 100,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Approximate metres per degree of longitude at a given latitude.
|
|
64
|
+
*/
|
|
65
|
+
function metresPerDegreeLon(lat) {
|
|
66
|
+
return 111_320 * Math.cos(lat * (Math.PI / 180));
|
|
67
|
+
}
|
|
68
|
+
const METRES_PER_DEGREE_LAT = 111_320;
|
|
69
|
+
export async function getTerrainAnalysis(params) {
|
|
70
|
+
const { lat, lon } = params;
|
|
71
|
+
if (lat < -90 || lat > 90)
|
|
72
|
+
throw new Error("Latitude must be between -90 and 90.");
|
|
73
|
+
if (lon < -180 || lon > 180)
|
|
74
|
+
throw new Error("Longitude must be between -180 and 180.");
|
|
75
|
+
const cacheKey = `terrain:${lat}:${lon}`;
|
|
76
|
+
const cached = cache.get(cacheKey);
|
|
77
|
+
if (cached)
|
|
78
|
+
return cached;
|
|
79
|
+
// Build 3x3 grid coordinates around the target point.
|
|
80
|
+
// Row order: north to south (dy: +1, 0, -1) so indices match
|
|
81
|
+
// the NW/N/NE, W/C/E, SW/S/SE layout expected by computeSlopeAspect.
|
|
82
|
+
const lats = [];
|
|
83
|
+
const lons = [];
|
|
84
|
+
for (let dy = 1; dy >= -1; dy--) {
|
|
85
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
86
|
+
lats.push(lat + dy * GRID_STEP_DEG);
|
|
87
|
+
lons.push(lon + dx * GRID_STEP_DEG);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const url = new URL(ELEVATION_URL);
|
|
91
|
+
url.searchParams.set("latitude", lats.join(","));
|
|
92
|
+
url.searchParams.set("longitude", lons.join(","));
|
|
93
|
+
const response = await fetch(url.toString());
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const body = await response.text();
|
|
96
|
+
throw new Error(`Open-Meteo Elevation API returned ${response.status}: ${body.slice(0, 300)}`);
|
|
97
|
+
}
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
const json = await response.json();
|
|
100
|
+
const elevations = json.elevation;
|
|
101
|
+
if (!Array.isArray(elevations) || elevations.length !== 9) {
|
|
102
|
+
throw new Error("Unexpected response from Open-Meteo Elevation API: expected 9 elevation values.");
|
|
103
|
+
}
|
|
104
|
+
const cellSizeXM = GRID_STEP_DEG * metresPerDegreeLon(lat);
|
|
105
|
+
const cellSizeYM = GRID_STEP_DEG * METRES_PER_DEGREE_LAT;
|
|
106
|
+
const { slope_deg, aspect_deg, aspect_cardinal, flatness_score } = computeSlopeAspect(elevations, cellSizeXM, cellSizeYM);
|
|
107
|
+
const result = {
|
|
108
|
+
lat,
|
|
109
|
+
lon,
|
|
110
|
+
elevation_m: elevations[4], // centre point
|
|
111
|
+
slope_deg,
|
|
112
|
+
aspect_deg,
|
|
113
|
+
aspect_cardinal,
|
|
114
|
+
flatness_score,
|
|
115
|
+
source: "open-meteo-elevation",
|
|
116
|
+
source_metadata: GIS_SOURCES["open-meteo-elevation"],
|
|
117
|
+
};
|
|
118
|
+
cache.set(cacheKey, result, TTL.STATIC_DATA);
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const transferCapacitySchema: z.ZodObject<{
|
|
3
|
+
from_zone: z.ZodString;
|
|
4
|
+
to_zone: z.ZodString;
|
|
5
|
+
date: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
interface CapacityPoint {
|
|
8
|
+
hour: number;
|
|
9
|
+
ntc_mw: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function getTransferCapacity(params: z.infer<typeof transferCapacitySchema>): Promise<{
|
|
12
|
+
from_zone: string;
|
|
13
|
+
to_zone: string;
|
|
14
|
+
date: string;
|
|
15
|
+
capacities: CapacityPoint[];
|
|
16
|
+
stats: {
|
|
17
|
+
min: number;
|
|
18
|
+
max: number;
|
|
19
|
+
mean: number;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
22
|
+
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 transferCapacitySchema = z.object({
|
|
7
|
+
from_zone: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe(`Exporting zone. Available: ${AVAILABLE_ZONES}`),
|
|
10
|
+
to_zone: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe(`Importing zone. 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 getTransferCapacity(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: "A61",
|
|
25
|
+
processType: "A01",
|
|
26
|
+
in_Domain: toEic,
|
|
27
|
+
out_Domain: fromEic,
|
|
28
|
+
periodStart,
|
|
29
|
+
periodEnd,
|
|
30
|
+
}, TTL.PRICES);
|
|
31
|
+
const doc = data.Publication_MarketDocument;
|
|
32
|
+
if (!doc)
|
|
33
|
+
throw new Error("No transfer capacity data returned for this corridor/date.");
|
|
34
|
+
const timeSeries = ensureArray(doc.TimeSeries);
|
|
35
|
+
const capacities = [];
|
|
36
|
+
for (const ts of timeSeries) {
|
|
37
|
+
const periods = ensureArray(ts.Period);
|
|
38
|
+
for (const period of periods) {
|
|
39
|
+
const points = ensureArray(period.Point);
|
|
40
|
+
for (const point of points) {
|
|
41
|
+
const position = Number(point.position);
|
|
42
|
+
const ntcMw = Number(point.quantity ?? 0);
|
|
43
|
+
capacities.push({ hour: position - 1, ntc_mw: ntcMw });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
capacities.sort((a, b) => a.hour - b.hour);
|
|
48
|
+
const values = capacities.map((c) => c.ntc_mw);
|
|
49
|
+
const min = values.length > 0 ? Math.min(...values) : 0;
|
|
50
|
+
const max = values.length > 0 ? Math.max(...values) : 0;
|
|
51
|
+
const mean = values.length > 0
|
|
52
|
+
? Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 100) / 100
|
|
53
|
+
: 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
|
+
capacities,
|
|
59
|
+
stats: { min, max, mean },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const transmissionSchema: z.ZodObject<{
|
|
3
|
+
country: z.ZodOptional<z.ZodString>;
|
|
4
|
+
lat_min: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
lon_min: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
lat_max: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
lon_max: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
min_voltage_kv: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
interface TransmissionLine {
|
|
12
|
+
id: number;
|
|
13
|
+
voltage_kv: number;
|
|
14
|
+
operator: string | null;
|
|
15
|
+
cables: number | null;
|
|
16
|
+
coordinates: Array<{
|
|
17
|
+
lat: number;
|
|
18
|
+
lon: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
type Bbox = [number, number, number, number];
|
|
22
|
+
/** Split a bbox into smaller tiles of approximately TILE_SIZE_DEG x TILE_SIZE_DEG. */
|
|
23
|
+
export declare function generateTiles(bbox: Bbox): Bbox[];
|
|
24
|
+
export declare function getTransmissionLines(params: z.infer<typeof transmissionSchema>): Promise<{
|
|
25
|
+
bbox: Bbox;
|
|
26
|
+
line_count: number;
|
|
27
|
+
lines: TransmissionLine[];
|
|
28
|
+
}>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
import { COUNTRY_BBOXES } from "../lib/zone-codes.js";
|
|
4
|
+
import { fetchOverpassJson } from "../lib/overpass.js";
|
|
5
|
+
const cache = new TtlCache();
|
|
6
|
+
export const transmissionSchema = z.object({
|
|
7
|
+
country: z
|
|
8
|
+
.string()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe(`Country code to look up bounding box. Available: ${Object.keys(COUNTRY_BBOXES).join(", ")}. ` +
|
|
11
|
+
"Use this OR provide lat/lon bounds directly."),
|
|
12
|
+
lat_min: z.number().optional().describe("Southern latitude bound."),
|
|
13
|
+
lon_min: z.number().optional().describe("Western longitude bound."),
|
|
14
|
+
lat_max: z.number().optional().describe("Northern latitude bound."),
|
|
15
|
+
lon_max: z.number().optional().describe("Eastern longitude bound."),
|
|
16
|
+
min_voltage_kv: z
|
|
17
|
+
.number()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Minimum voltage in kV to filter lines. Defaults to 220 (high-voltage only)."),
|
|
20
|
+
limit: z
|
|
21
|
+
.number()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Maximum number of lines to return. Defaults to 100."),
|
|
24
|
+
});
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Tiling helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** Area threshold in square degrees above which we split into tiles. */
|
|
29
|
+
const TILE_AREA_THRESHOLD = 25;
|
|
30
|
+
/** Target tile size in degrees per side. */
|
|
31
|
+
const TILE_SIZE_DEG = 5;
|
|
32
|
+
/** Max concurrent tile queries to avoid Overpass rate limiting. */
|
|
33
|
+
const TILE_CONCURRENCY = 2;
|
|
34
|
+
/** Split a bbox into smaller tiles of approximately TILE_SIZE_DEG x TILE_SIZE_DEG. */
|
|
35
|
+
export function generateTiles(bbox) {
|
|
36
|
+
const [latMin, lonMin, latMax, lonMax] = bbox;
|
|
37
|
+
const latRange = latMax - latMin;
|
|
38
|
+
const lonRange = lonMax - lonMin;
|
|
39
|
+
const latSteps = Math.ceil(latRange / TILE_SIZE_DEG);
|
|
40
|
+
const lonSteps = Math.ceil(lonRange / TILE_SIZE_DEG);
|
|
41
|
+
const latStep = latRange / latSteps;
|
|
42
|
+
const lonStep = lonRange / lonSteps;
|
|
43
|
+
const tiles = [];
|
|
44
|
+
for (let i = 0; i < latSteps; i++) {
|
|
45
|
+
for (let j = 0; j < lonSteps; j++) {
|
|
46
|
+
tiles.push([
|
|
47
|
+
latMin + i * latStep,
|
|
48
|
+
lonMin + j * lonStep,
|
|
49
|
+
latMin + (i + 1) * latStep,
|
|
50
|
+
lonMin + (j + 1) * lonStep,
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return tiles;
|
|
55
|
+
}
|
|
56
|
+
/** Returns true if the bbox area exceeds the tiling threshold. */
|
|
57
|
+
function shouldTile(bbox) {
|
|
58
|
+
const latRange = bbox[2] - bbox[0];
|
|
59
|
+
const lonRange = bbox[3] - bbox[1];
|
|
60
|
+
return latRange * lonRange > TILE_AREA_THRESHOLD;
|
|
61
|
+
}
|
|
62
|
+
/** Query a single bbox tile and return raw Overpass elements. */
|
|
63
|
+
async function queryTile(bbox) {
|
|
64
|
+
const bboxStr = `${bbox[0]},${bbox[1]},${bbox[2]},${bbox[3]}`;
|
|
65
|
+
const query = `[out:json][timeout:30];way["power"="line"]["voltage"](${bboxStr});out geom;`;
|
|
66
|
+
const json = await fetchOverpassJson(query);
|
|
67
|
+
return Array.isArray(json.elements) ? json.elements : [];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Query multiple tiles with bounded concurrency.
|
|
71
|
+
* Runs at most `concurrency` queries in parallel.
|
|
72
|
+
*/
|
|
73
|
+
async function queryTilesWithConcurrency(tiles, concurrency) {
|
|
74
|
+
const allElements = [];
|
|
75
|
+
const remaining = [...tiles];
|
|
76
|
+
while (remaining.length > 0) {
|
|
77
|
+
const batch = remaining.splice(0, concurrency);
|
|
78
|
+
const results = await Promise.all(batch.map(queryTile));
|
|
79
|
+
for (const elements of results) {
|
|
80
|
+
allElements.push(...elements);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return allElements;
|
|
84
|
+
}
|
|
85
|
+
/** Parse raw Overpass elements into TransmissionLine[], filtering by voltage. */
|
|
86
|
+
function parseElements(elements, minVoltageV) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
for (const el of elements) {
|
|
89
|
+
const voltageStr = el.tags?.voltage ?? "0";
|
|
90
|
+
const voltageV = Number(voltageStr.split(";")[0]);
|
|
91
|
+
if (voltageV < minVoltageV)
|
|
92
|
+
continue;
|
|
93
|
+
const coords = Array.isArray(el.geometry)
|
|
94
|
+
? el.geometry.map((g) => ({ lat: g.lat, lon: g.lon }))
|
|
95
|
+
: [];
|
|
96
|
+
lines.push({
|
|
97
|
+
id: el.id,
|
|
98
|
+
voltage_kv: Math.round(voltageV / 1000),
|
|
99
|
+
operator: el.tags?.operator ?? null,
|
|
100
|
+
cables: el.tags?.cables ? Number(el.tags.cables) : null,
|
|
101
|
+
coordinates: coords,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
/** Deduplicate lines by OSM way ID (tiles may overlap at boundaries). */
|
|
107
|
+
function deduplicateById(lines) {
|
|
108
|
+
const seen = new Map();
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (!seen.has(line.id)) {
|
|
111
|
+
seen.set(line.id, line);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...seen.values()];
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Public API
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
export async function getTransmissionLines(params) {
|
|
120
|
+
let bbox;
|
|
121
|
+
if (params.country) {
|
|
122
|
+
const upper = params.country.toUpperCase();
|
|
123
|
+
const countryBbox = COUNTRY_BBOXES[upper];
|
|
124
|
+
if (!countryBbox) {
|
|
125
|
+
throw new Error(`No bounding box for "${upper}". Available: ${Object.keys(COUNTRY_BBOXES).join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
bbox = countryBbox;
|
|
128
|
+
}
|
|
129
|
+
else if (params.lat_min != null &&
|
|
130
|
+
params.lon_min != null &&
|
|
131
|
+
params.lat_max != null &&
|
|
132
|
+
params.lon_max != null) {
|
|
133
|
+
bbox = [params.lat_min, params.lon_min, params.lat_max, params.lon_max];
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
throw new Error("Provide either 'country' or all four bounding box parameters.");
|
|
137
|
+
}
|
|
138
|
+
const minVoltageV = (params.min_voltage_kv ?? 220) * 1000;
|
|
139
|
+
const maxResults = params.limit ?? 100;
|
|
140
|
+
const cacheKey = `overpass:${bbox.join(",")}:${minVoltageV}:${maxResults}`;
|
|
141
|
+
const cached = cache.get(cacheKey);
|
|
142
|
+
if (cached)
|
|
143
|
+
return cached;
|
|
144
|
+
let rawElements;
|
|
145
|
+
if (shouldTile(bbox)) {
|
|
146
|
+
const tiles = generateTiles(bbox);
|
|
147
|
+
rawElements = await queryTilesWithConcurrency(tiles, TILE_CONCURRENCY);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
rawElements = await queryTile(bbox);
|
|
151
|
+
}
|
|
152
|
+
let lines = parseElements(rawElements, minVoltageV);
|
|
153
|
+
lines = deduplicateById(lines);
|
|
154
|
+
lines.sort((a, b) => b.voltage_kv - a.voltage_kv);
|
|
155
|
+
lines = lines.slice(0, maxResults);
|
|
156
|
+
const result = { bbox, line_count: lines.length, lines };
|
|
157
|
+
cache.set(cacheKey, result, TTL.STATIC_DATA);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const ukCarbonSchema: z.ZodObject<{
|
|
3
|
+
action: z.ZodEnum<{
|
|
4
|
+
date: "date";
|
|
5
|
+
regional: "regional";
|
|
6
|
+
current: "current";
|
|
7
|
+
}>;
|
|
8
|
+
date: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
interface FuelMix {
|
|
11
|
+
fuel: string;
|
|
12
|
+
perc: number;
|
|
13
|
+
}
|
|
14
|
+
interface CarbonCurrent {
|
|
15
|
+
action: "current";
|
|
16
|
+
timestamp: string;
|
|
17
|
+
intensity_gco2_kwh: number;
|
|
18
|
+
index: string;
|
|
19
|
+
generation_mix: FuelMix[];
|
|
20
|
+
}
|
|
21
|
+
interface RegionEntry {
|
|
22
|
+
region: string;
|
|
23
|
+
intensity_gco2_kwh: number;
|
|
24
|
+
index: string;
|
|
25
|
+
generation_mix: FuelMix[];
|
|
26
|
+
}
|
|
27
|
+
interface CarbonRegional {
|
|
28
|
+
action: "regional";
|
|
29
|
+
timestamp: string;
|
|
30
|
+
regions: RegionEntry[];
|
|
31
|
+
}
|
|
32
|
+
interface HalfHourEntry {
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
intensity_forecast: number;
|
|
36
|
+
intensity_actual: number | null;
|
|
37
|
+
index: string;
|
|
38
|
+
}
|
|
39
|
+
interface CarbonDate {
|
|
40
|
+
action: "date";
|
|
41
|
+
date: string;
|
|
42
|
+
periods: HalfHourEntry[];
|
|
43
|
+
stats: {
|
|
44
|
+
mean_forecast: number;
|
|
45
|
+
min_forecast: number;
|
|
46
|
+
max_forecast: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
type UkCarbonResult = CarbonCurrent | CarbonRegional | CarbonDate;
|
|
50
|
+
export declare function getUkCarbonIntensity(params: z.infer<typeof ukCarbonSchema>): Promise<UkCarbonResult>;
|
|
51
|
+
export {};
|