@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,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TtlCache, TTL } from "../lib/cache.js";
|
|
3
|
+
const ECO2MIX_API = "https://odre.opendatasoft.com/api/explore/v2.1/catalog/datasets";
|
|
4
|
+
const cache = new TtlCache();
|
|
5
|
+
export const rteFranceSchema = z.object({
|
|
6
|
+
dataset: z
|
|
7
|
+
.enum(["generation", "consumption", "exchanges", "outages"])
|
|
8
|
+
.describe('"generation" = French real-time generation by source (nuclear, wind, solar, hydro, gas, etc.). ' +
|
|
9
|
+
'"consumption" = French electricity consumption (MW). ' +
|
|
10
|
+
'"exchanges" = Cross-border commercial exchanges with neighbours. ' +
|
|
11
|
+
'"outages" = French generation unavailability (nuclear outages drive EU prices).'),
|
|
12
|
+
date: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Date in YYYY-MM-DD format. Defaults to today."),
|
|
16
|
+
});
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
async function fetchOdre(datasetId, where, limit = 24) {
|
|
19
|
+
const url = `${ECO2MIX_API}/${datasetId}/records?` +
|
|
20
|
+
`where=${encodeURIComponent(where)}&limit=${limit}&order_by=date_heure%20DESC`;
|
|
21
|
+
const cached = cache.get(url);
|
|
22
|
+
if (cached)
|
|
23
|
+
return cached.results;
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
headers: { Accept: "application/json", "User-Agent": "luminus-mcp/0.2" },
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const body = await response.text();
|
|
29
|
+
throw new Error(`RTE/ODRE API returned ${response.status}: ${body.slice(0, 300)}`);
|
|
30
|
+
}
|
|
31
|
+
const json = await response.json();
|
|
32
|
+
cache.set(url, json, TTL.REALTIME);
|
|
33
|
+
return json.results ?? [];
|
|
34
|
+
}
|
|
35
|
+
async function fetchGeneration(date) {
|
|
36
|
+
const records = await fetchOdre("eco2mix-national-tr", `date_heure >= '${date}T00:00:00' AND date_heure <= '${date}T23:59:59'`, 48);
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
const parsed = records.map((r) => {
|
|
39
|
+
const f = r.fields ?? r;
|
|
40
|
+
return {
|
|
41
|
+
timestamp: f.date_heure ?? "",
|
|
42
|
+
nuclear_mw: Math.round(Number(f.nucleaire ?? 0)),
|
|
43
|
+
wind_mw: Math.round(Number(f.eolien ?? 0)),
|
|
44
|
+
solar_mw: Math.round(Number(f.solaire ?? 0)),
|
|
45
|
+
hydro_mw: Math.round(Number(f.hydraulique ?? 0)),
|
|
46
|
+
gas_mw: Math.round(Number(f.gaz ?? 0)),
|
|
47
|
+
coal_mw: Math.round(Number(f.charbon ?? 0)),
|
|
48
|
+
bioenergy_mw: Math.round(Number(f.bioenergies ?? 0)),
|
|
49
|
+
total_mw: Math.round(Number(f.nucleaire ?? 0) +
|
|
50
|
+
Number(f.eolien ?? 0) +
|
|
51
|
+
Number(f.solaire ?? 0) +
|
|
52
|
+
Number(f.hydraulique ?? 0) +
|
|
53
|
+
Number(f.gaz ?? 0) +
|
|
54
|
+
Number(f.charbon ?? 0) +
|
|
55
|
+
Number(f.bioenergies ?? 0)),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
dataset: "generation",
|
|
60
|
+
source: "RTE France (eco2mix via ODRE)",
|
|
61
|
+
date,
|
|
62
|
+
records: parsed.slice(0, 24),
|
|
63
|
+
latest: parsed.length > 0 ? parsed[0] : null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function fetchConsumption(date) {
|
|
67
|
+
const records = await fetchOdre("eco2mix-national-tr", `date_heure >= '${date}T00:00:00' AND date_heure <= '${date}T23:59:59'`, 48);
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
const parsed = records.map((r) => {
|
|
70
|
+
const f = r.fields ?? r;
|
|
71
|
+
return {
|
|
72
|
+
timestamp: f.date_heure ?? "",
|
|
73
|
+
consumption_mw: Math.round(Number(f.consommation ?? 0)),
|
|
74
|
+
forecast_mw: Math.round(Number(f.prevision_j ?? f.prevision_j1 ?? 0)),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
dataset: "consumption",
|
|
79
|
+
source: "RTE France (eco2mix via ODRE)",
|
|
80
|
+
date,
|
|
81
|
+
records: parsed.slice(0, 24),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function fetchExchanges(date) {
|
|
85
|
+
const records = await fetchOdre("eco2mix-national-tr", `date_heure >= '${date}T00:00:00' AND date_heure <= '${date}T23:59:59'`, 48);
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
const parsed = records.map((r) => {
|
|
88
|
+
const f = r.fields ?? r;
|
|
89
|
+
const gb = Number(f.ech_comm_angleterre ?? 0);
|
|
90
|
+
const es = Number(f.ech_comm_espagne ?? 0);
|
|
91
|
+
const it = Number(f.ech_comm_italie ?? 0);
|
|
92
|
+
const ch = Number(f.ech_comm_suisse ?? 0);
|
|
93
|
+
const de = Number(f.ech_comm_allemagne_belgique ?? 0);
|
|
94
|
+
return {
|
|
95
|
+
timestamp: f.date_heure ?? "",
|
|
96
|
+
gb_mw: Math.round(gb),
|
|
97
|
+
spain_mw: Math.round(es),
|
|
98
|
+
italy_mw: Math.round(it),
|
|
99
|
+
switzerland_mw: Math.round(ch),
|
|
100
|
+
germany_belgium_mw: Math.round(de),
|
|
101
|
+
net_mw: Math.round(gb + es + it + ch + de),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
dataset: "exchanges",
|
|
106
|
+
source: "RTE France (eco2mix via ODRE)",
|
|
107
|
+
date,
|
|
108
|
+
records: parsed.slice(0, 24),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function fetchOutages(date) {
|
|
112
|
+
// Use REMIT-style outage data from ODRE if available
|
|
113
|
+
const records = await fetchOdre("registre-national-installation-production-stockage-electricite-agrege", `commune IS NOT NULL`, 30).catch(() => []);
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
const parsed = records.slice(0, 20).map((r) => {
|
|
116
|
+
const f = r.fields ?? r;
|
|
117
|
+
return {
|
|
118
|
+
unit_name: f.nominstallation ?? f.nom ?? "Unknown",
|
|
119
|
+
fuel_type: f.filiere ?? f.combustible ?? "Unknown",
|
|
120
|
+
unavailable_mw: Math.round(Number(f.puismaxcharge ?? 0) - Number(f.puismaxrac ?? 0)),
|
|
121
|
+
available_mw: Math.round(Number(f.puismaxrac ?? 0)),
|
|
122
|
+
start_date: f.datamiseenservice ?? date,
|
|
123
|
+
end_date: "",
|
|
124
|
+
reason: f.regime ?? "",
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
dataset: "outages",
|
|
129
|
+
source: "RTE France (ODRE)",
|
|
130
|
+
description: "French generation availability. For detailed REMIT outage data, " +
|
|
131
|
+
"see ENTSO-E get_outages with country=FR or REMIT UMM platforms.",
|
|
132
|
+
records: parsed,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export async function getRteFrance(params) {
|
|
136
|
+
const date = params.date ?? new Date().toISOString().slice(0, 10);
|
|
137
|
+
switch (params.dataset) {
|
|
138
|
+
case "generation":
|
|
139
|
+
return fetchGeneration(date);
|
|
140
|
+
case "consumption":
|
|
141
|
+
return fetchConsumption(date);
|
|
142
|
+
case "exchanges":
|
|
143
|
+
return fetchExchanges(date);
|
|
144
|
+
case "outages":
|
|
145
|
+
return fetchOutages(date);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type GisSourceMetadata } from "../lib/gis-sources.js";
|
|
3
|
+
/** EU member-state country codes supported for reduced screening. */
|
|
4
|
+
export declare const EU_COUNTRY_CODES: Set<string>;
|
|
5
|
+
export declare const screenSiteSchema: z.ZodObject<{
|
|
6
|
+
lat: z.ZodNumber;
|
|
7
|
+
lon: z.ZodNumber;
|
|
8
|
+
radius_km: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
country: z.ZodString;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
type Verdict = "pass" | "warn" | "fail";
|
|
12
|
+
interface VerdictFlag {
|
|
13
|
+
category: "terrain" | "grid" | "solar" | "constraints" | "agricultural_land" | "flood_risk" | "land_cover";
|
|
14
|
+
level: "warn" | "fail";
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
interface ScreenSiteVerdict {
|
|
18
|
+
overall: Verdict;
|
|
19
|
+
flags: VerdictFlag[];
|
|
20
|
+
}
|
|
21
|
+
interface ScreenSiteSourceMetadata {
|
|
22
|
+
terrain: GisSourceMetadata;
|
|
23
|
+
grid: GisSourceMetadata;
|
|
24
|
+
solar: GisSourceMetadata;
|
|
25
|
+
constraints: GisSourceMetadata;
|
|
26
|
+
agricultural_land?: GisSourceMetadata;
|
|
27
|
+
flood_risk?: GisSourceMetadata;
|
|
28
|
+
land_cover?: GisSourceMetadata;
|
|
29
|
+
}
|
|
30
|
+
interface ScreenSiteResult {
|
|
31
|
+
lat: number;
|
|
32
|
+
lon: number;
|
|
33
|
+
radius_km: number;
|
|
34
|
+
country: string;
|
|
35
|
+
terrain: any | null;
|
|
36
|
+
grid: any | null;
|
|
37
|
+
solar: any | null;
|
|
38
|
+
constraints: any | null;
|
|
39
|
+
agricultural_land: any | null;
|
|
40
|
+
flood_risk: any | null;
|
|
41
|
+
land_cover?: any | null;
|
|
42
|
+
verdict: ScreenSiteVerdict;
|
|
43
|
+
layers_available: string[];
|
|
44
|
+
layers_unavailable: Record<string, string>;
|
|
45
|
+
source_metadata: ScreenSiteSourceMetadata;
|
|
46
|
+
warnings?: string[];
|
|
47
|
+
disclaimer: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function screenSite(params: z.infer<typeof screenSiteSchema>): Promise<ScreenSiteResult>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getTerrainAnalysis } from "./terrain-analysis.js";
|
|
3
|
+
import { getGridProximity } from "./grid-proximity.js";
|
|
4
|
+
import { getSolarIrradiance } from "./solar.js";
|
|
5
|
+
import { getLandConstraints } from "./land-constraints.js";
|
|
6
|
+
import { getAgriculturalLand } from "./agricultural-land.js";
|
|
7
|
+
import { getFloodRisk } from "./flood-risk.js";
|
|
8
|
+
import { getLandCover } from "./land-cover.js";
|
|
9
|
+
import { GIS_SOURCES } from "../lib/gis-sources.js";
|
|
10
|
+
/** EU member-state country codes supported for reduced screening. */
|
|
11
|
+
export const EU_COUNTRY_CODES = new Set([
|
|
12
|
+
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
|
13
|
+
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
|
|
14
|
+
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
|
|
15
|
+
]);
|
|
16
|
+
export const screenSiteSchema = z.object({
|
|
17
|
+
lat: z.number().describe("Latitude (-90 to 90). WGS84."),
|
|
18
|
+
lon: z.number().describe("Longitude (-180 to 180). WGS84."),
|
|
19
|
+
radius_km: z
|
|
20
|
+
.number()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Search radius in km for grid and constraints (default 2, max 10)."),
|
|
23
|
+
country: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe('ISO 3166-1 alpha-2 country code. "GB" and EU member states are supported.'),
|
|
26
|
+
});
|
|
27
|
+
// --- Heuristic thresholds ---
|
|
28
|
+
/** Slope above this is flagged as a terrain warning (degrees). */
|
|
29
|
+
const SLOPE_WARN_DEG = 10;
|
|
30
|
+
/** Annual irradiance below this is flagged as a solar warning (kWh/m2). */
|
|
31
|
+
const IRRADIANCE_WARN_KWH = 900;
|
|
32
|
+
const DISCLAIMER = "This is an automated screening summary using public data. " +
|
|
33
|
+
"It is not investment advice, planning consent, or a final site feasibility assessment. " +
|
|
34
|
+
"Professional due diligence is required before any development decision.";
|
|
35
|
+
// --- Heuristic evaluation ---
|
|
36
|
+
function evaluateVerdict(terrain, grid, solar, constraints, agriculturalLand, floodRisk, landCover) {
|
|
37
|
+
const flags = [];
|
|
38
|
+
// Constraints: hard constraint = fail
|
|
39
|
+
if (constraints?.summary?.has_hard_constraint) {
|
|
40
|
+
flags.push({
|
|
41
|
+
category: "constraints",
|
|
42
|
+
level: "fail",
|
|
43
|
+
reason: `${constraints.summary.constraint_count} protected area(s) found within search radius`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Terrain: steep slope = warn
|
|
47
|
+
if (terrain && terrain.slope_deg > SLOPE_WARN_DEG) {
|
|
48
|
+
flags.push({
|
|
49
|
+
category: "terrain",
|
|
50
|
+
level: "warn",
|
|
51
|
+
reason: `Slope is ${terrain.slope_deg} deg (threshold: ${SLOPE_WARN_DEG} deg)`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Grid: no substations and no lines = warn
|
|
55
|
+
if (grid && grid.summary.nearest_substation_km === null && grid.summary.nearest_line_km === null) {
|
|
56
|
+
flags.push({
|
|
57
|
+
category: "grid",
|
|
58
|
+
level: "warn",
|
|
59
|
+
reason: "No substations or HV lines found within search radius",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Solar: low irradiance = warn
|
|
63
|
+
if (solar && solar.annual_irradiance_kwh_m2 < IRRADIANCE_WARN_KWH) {
|
|
64
|
+
flags.push({
|
|
65
|
+
category: "solar",
|
|
66
|
+
level: "warn",
|
|
67
|
+
reason: `Annual irradiance is ${solar.annual_irradiance_kwh_m2} kWh/m2 (threshold: ${IRRADIANCE_WARN_KWH} kWh/m2)`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Agricultural land: BMV is a planning-risk warning, not a hard exclusion.
|
|
71
|
+
if (agriculturalLand?.bmv_status === "yes") {
|
|
72
|
+
flags.push({
|
|
73
|
+
category: "agricultural_land",
|
|
74
|
+
level: "warn",
|
|
75
|
+
reason: `Best and Most Versatile agricultural land flagged (${agriculturalLand.effective_grade ?? "unknown grade"})`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else if (agriculturalLand?.bmv_status === "uncertain") {
|
|
79
|
+
flags.push({
|
|
80
|
+
category: "agricultural_land",
|
|
81
|
+
level: "warn",
|
|
82
|
+
reason: `Agricultural land classification is uncertain (${agriculturalLand.effective_grade ?? "unknown grade"}); Grade 3 may split into 3a or 3b`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Flood risk: Flood Zone 3 and flood storage areas are treated as fail,
|
|
86
|
+
// Flood Zone 2 as warn. This is a screening heuristic, not a legal rule.
|
|
87
|
+
if (floodRisk?.flood_storage_area) {
|
|
88
|
+
flags.push({
|
|
89
|
+
category: "flood_risk",
|
|
90
|
+
level: "fail",
|
|
91
|
+
reason: "Point intersects an Environment Agency flood storage area",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else if (floodRisk?.flood_zone === "3") {
|
|
95
|
+
flags.push({
|
|
96
|
+
category: "flood_risk",
|
|
97
|
+
level: "fail",
|
|
98
|
+
reason: "Point is in Flood Zone 3 (high probability floodplain)",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else if (floodRisk?.flood_zone === "2") {
|
|
102
|
+
flags.push({
|
|
103
|
+
category: "flood_risk",
|
|
104
|
+
level: "warn",
|
|
105
|
+
reason: "Point is in Flood Zone 2 (medium probability floodplain)",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Land cover: CORINE planning exclusion = warn (EU only)
|
|
109
|
+
if (landCover?.land_cover?.is_planning_exclusion) {
|
|
110
|
+
flags.push({
|
|
111
|
+
category: "land_cover",
|
|
112
|
+
level: "warn",
|
|
113
|
+
reason: `Land cover type "${landCover.land_cover.label}" is typically excluded from PV/BESS development`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// Overall: fail > warn > pass
|
|
117
|
+
let overall = "pass";
|
|
118
|
+
if (flags.some((f) => f.level === "warn"))
|
|
119
|
+
overall = "warn";
|
|
120
|
+
if (flags.some((f) => f.level === "fail"))
|
|
121
|
+
overall = "fail";
|
|
122
|
+
return { flags, overall };
|
|
123
|
+
}
|
|
124
|
+
// --- Main tool function ---
|
|
125
|
+
export async function screenSite(params) {
|
|
126
|
+
const { lat, lon } = params;
|
|
127
|
+
const country = params.country.toUpperCase();
|
|
128
|
+
const radiusKm = params.radius_km ?? 2;
|
|
129
|
+
// Validation
|
|
130
|
+
const isEu = EU_COUNTRY_CODES.has(country);
|
|
131
|
+
if (country !== "GB" && !isEu) {
|
|
132
|
+
throw new Error(`Country "${params.country}" is not supported. "GB" and EU member states are available.`);
|
|
133
|
+
}
|
|
134
|
+
if (lat < -90 || lat > 90) {
|
|
135
|
+
throw new Error("Latitude must be between -90 and 90.");
|
|
136
|
+
}
|
|
137
|
+
if (lon < -180 || lon > 180) {
|
|
138
|
+
throw new Error("Longitude must be between -180 and 180.");
|
|
139
|
+
}
|
|
140
|
+
if (radiusKm <= 0 || radiusKm > 10) {
|
|
141
|
+
throw new Error("radius_km must be between 0 and 10.");
|
|
142
|
+
}
|
|
143
|
+
if (isEu) {
|
|
144
|
+
return screenSiteEu(lat, lon, radiusKm, country);
|
|
145
|
+
}
|
|
146
|
+
return screenSiteGb(lat, lon, radiusKm);
|
|
147
|
+
}
|
|
148
|
+
// --- GB flow (unchanged logic) ---
|
|
149
|
+
async function screenSiteGb(lat, lon, radiusKm) {
|
|
150
|
+
// Run all sub-tools in parallel with allSettled for resilience
|
|
151
|
+
const [terrainResult, gridResult, solarResult, constraintsResult, agriculturalLandResult, floodRiskResult] = await Promise.allSettled([
|
|
152
|
+
getTerrainAnalysis({ lat, lon }),
|
|
153
|
+
getGridProximity({ lat, lon, radius_km: radiusKm }),
|
|
154
|
+
getSolarIrradiance({ lat, lon }),
|
|
155
|
+
getLandConstraints({ lat, lon, radius_km: radiusKm, country: "GB" }),
|
|
156
|
+
getAgriculturalLand({ lat, lon, country: "GB" }),
|
|
157
|
+
getFloodRisk({ lat, lon, country: "GB" }),
|
|
158
|
+
]);
|
|
159
|
+
const warnings = [];
|
|
160
|
+
const terrain = terrainResult.status === "fulfilled" ? terrainResult.value : null;
|
|
161
|
+
if (terrainResult.status === "rejected") {
|
|
162
|
+
warnings.push(`terrain: ${terrainResult.reason instanceof Error ? terrainResult.reason.message : String(terrainResult.reason)}`);
|
|
163
|
+
}
|
|
164
|
+
const grid = gridResult.status === "fulfilled" ? gridResult.value : null;
|
|
165
|
+
if (gridResult.status === "rejected") {
|
|
166
|
+
warnings.push(`grid: ${gridResult.reason instanceof Error ? gridResult.reason.message : String(gridResult.reason)}`);
|
|
167
|
+
}
|
|
168
|
+
const solar = solarResult.status === "fulfilled" ? solarResult.value : null;
|
|
169
|
+
if (solarResult.status === "rejected") {
|
|
170
|
+
warnings.push(`solar: ${solarResult.reason instanceof Error ? solarResult.reason.message : String(solarResult.reason)}`);
|
|
171
|
+
}
|
|
172
|
+
const constraints = constraintsResult.status === "fulfilled" ? constraintsResult.value : null;
|
|
173
|
+
if (constraintsResult.status === "rejected") {
|
|
174
|
+
warnings.push(`constraints: ${constraintsResult.reason instanceof Error ? constraintsResult.reason.message : String(constraintsResult.reason)}`);
|
|
175
|
+
}
|
|
176
|
+
const agriculturalLand = agriculturalLandResult.status === "fulfilled" ? agriculturalLandResult.value : null;
|
|
177
|
+
if (agriculturalLandResult.status === "rejected") {
|
|
178
|
+
warnings.push(`agricultural_land: ${agriculturalLandResult.reason instanceof Error ? agriculturalLandResult.reason.message : String(agriculturalLandResult.reason)}`);
|
|
179
|
+
}
|
|
180
|
+
const floodRisk = floodRiskResult.status === "fulfilled" ? floodRiskResult.value : null;
|
|
181
|
+
if (floodRiskResult.status === "rejected") {
|
|
182
|
+
warnings.push(`flood_risk: ${floodRiskResult.reason instanceof Error ? floodRiskResult.reason.message : String(floodRiskResult.reason)}`);
|
|
183
|
+
}
|
|
184
|
+
// If all sub-queries failed, throw
|
|
185
|
+
if (terrain === null && grid === null && solar === null && constraints === null && agriculturalLand === null && floodRisk === null) {
|
|
186
|
+
throw new Error(`All sub-queries failed for screen_site: ${warnings.join("; ")}`);
|
|
187
|
+
}
|
|
188
|
+
const { flags, overall } = evaluateVerdict(terrain, grid, solar, constraints, agriculturalLand, floodRisk);
|
|
189
|
+
const result = {
|
|
190
|
+
lat,
|
|
191
|
+
lon,
|
|
192
|
+
radius_km: radiusKm,
|
|
193
|
+
country: "GB",
|
|
194
|
+
terrain,
|
|
195
|
+
grid,
|
|
196
|
+
solar,
|
|
197
|
+
constraints,
|
|
198
|
+
agricultural_land: agriculturalLand,
|
|
199
|
+
flood_risk: floodRisk,
|
|
200
|
+
verdict: { overall, flags },
|
|
201
|
+
layers_available: ["terrain", "grid", "solar", "constraints", "agricultural_land", "flood_risk"],
|
|
202
|
+
layers_unavailable: {
|
|
203
|
+
land_cover: "CORINE 2018 does not cover Great Britain. " +
|
|
204
|
+
"Agricultural land classification (agricultural_land layer) provides partial land-use context for England. " +
|
|
205
|
+
"A future integration with the UKCEH Land Cover Map could fill this gap.",
|
|
206
|
+
},
|
|
207
|
+
source_metadata: {
|
|
208
|
+
terrain: GIS_SOURCES["open-meteo-elevation"],
|
|
209
|
+
grid: GIS_SOURCES["overpass-osm"],
|
|
210
|
+
solar: GIS_SOURCES["pvgis"],
|
|
211
|
+
constraints: GIS_SOURCES["natural-england"],
|
|
212
|
+
agricultural_land: GIS_SOURCES["natural-england-alc"],
|
|
213
|
+
flood_risk: GIS_SOURCES["ea-flood-map"],
|
|
214
|
+
},
|
|
215
|
+
disclaimer: DISCLAIMER,
|
|
216
|
+
};
|
|
217
|
+
if (warnings.length > 0) {
|
|
218
|
+
result.warnings = warnings;
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
// --- EU flow (reduced layer set) ---
|
|
223
|
+
async function screenSiteEu(lat, lon, radiusKm, country) {
|
|
224
|
+
const [terrainResult, gridResult, solarResult, constraintsResult, landCoverResult] = await Promise.allSettled([
|
|
225
|
+
getTerrainAnalysis({ lat, lon }),
|
|
226
|
+
getGridProximity({ lat, lon, radius_km: radiusKm }),
|
|
227
|
+
getSolarIrradiance({ lat, lon }),
|
|
228
|
+
getLandConstraints({ lat, lon, radius_km: radiusKm, country }),
|
|
229
|
+
getLandCover({ lat, lon, country }),
|
|
230
|
+
]);
|
|
231
|
+
const warnings = [];
|
|
232
|
+
const terrain = terrainResult.status === "fulfilled" ? terrainResult.value : null;
|
|
233
|
+
if (terrainResult.status === "rejected") {
|
|
234
|
+
warnings.push(`terrain: ${terrainResult.reason instanceof Error ? terrainResult.reason.message : String(terrainResult.reason)}`);
|
|
235
|
+
}
|
|
236
|
+
const grid = gridResult.status === "fulfilled" ? gridResult.value : null;
|
|
237
|
+
if (gridResult.status === "rejected") {
|
|
238
|
+
warnings.push(`grid: ${gridResult.reason instanceof Error ? gridResult.reason.message : String(gridResult.reason)}`);
|
|
239
|
+
}
|
|
240
|
+
const solar = solarResult.status === "fulfilled" ? solarResult.value : null;
|
|
241
|
+
if (solarResult.status === "rejected") {
|
|
242
|
+
warnings.push(`solar: ${solarResult.reason instanceof Error ? solarResult.reason.message : String(solarResult.reason)}`);
|
|
243
|
+
}
|
|
244
|
+
const constraints = constraintsResult.status === "fulfilled" ? constraintsResult.value : null;
|
|
245
|
+
if (constraintsResult.status === "rejected") {
|
|
246
|
+
warnings.push(`constraints: ${constraintsResult.reason instanceof Error ? constraintsResult.reason.message : String(constraintsResult.reason)}`);
|
|
247
|
+
}
|
|
248
|
+
const landCover = landCoverResult.status === "fulfilled" ? landCoverResult.value : null;
|
|
249
|
+
if (landCoverResult.status === "rejected") {
|
|
250
|
+
warnings.push(`land_cover: ${landCoverResult.reason instanceof Error ? landCoverResult.reason.message : String(landCoverResult.reason)}`);
|
|
251
|
+
}
|
|
252
|
+
// If all sub-queries failed, throw
|
|
253
|
+
if (terrain === null && grid === null && solar === null && constraints === null && landCover === null) {
|
|
254
|
+
throw new Error(`All sub-queries failed for screen_site: ${warnings.join("; ")}`);
|
|
255
|
+
}
|
|
256
|
+
const { flags, overall } = evaluateVerdict(terrain, grid, solar, constraints, null, null, landCover);
|
|
257
|
+
const result = {
|
|
258
|
+
lat,
|
|
259
|
+
lon,
|
|
260
|
+
radius_km: radiusKm,
|
|
261
|
+
country,
|
|
262
|
+
terrain,
|
|
263
|
+
grid,
|
|
264
|
+
solar,
|
|
265
|
+
constraints,
|
|
266
|
+
agricultural_land: null,
|
|
267
|
+
flood_risk: null,
|
|
268
|
+
land_cover: landCover,
|
|
269
|
+
verdict: { overall, flags },
|
|
270
|
+
layers_available: ["terrain", "grid", "solar", "constraints", "land_cover"],
|
|
271
|
+
layers_unavailable: {
|
|
272
|
+
agricultural_land: "England only — no equivalent EU source",
|
|
273
|
+
flood_risk: "England only — no equivalent EU source",
|
|
274
|
+
},
|
|
275
|
+
source_metadata: {
|
|
276
|
+
terrain: GIS_SOURCES["open-meteo-elevation"],
|
|
277
|
+
grid: GIS_SOURCES["overpass-osm"],
|
|
278
|
+
solar: GIS_SOURCES["pvgis"],
|
|
279
|
+
constraints: GIS_SOURCES["eea-natura2000"],
|
|
280
|
+
land_cover: GIS_SOURCES["corine-land-cover"],
|
|
281
|
+
},
|
|
282
|
+
disclaimer: DISCLAIMER,
|
|
283
|
+
};
|
|
284
|
+
if (warnings.length > 0) {
|
|
285
|
+
result.warnings = warnings;
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const siteRevenueSchema: z.ZodObject<{
|
|
3
|
+
lat: z.ZodNumber;
|
|
4
|
+
lon: z.ZodNumber;
|
|
5
|
+
zone: z.ZodString;
|
|
6
|
+
technology: z.ZodEnum<{
|
|
7
|
+
bess: "bess";
|
|
8
|
+
pv: "pv";
|
|
9
|
+
}>;
|
|
10
|
+
capacity_mw: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
date: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
interface TerrainSnapshot {
|
|
14
|
+
elevation_m: number;
|
|
15
|
+
slope_deg: number;
|
|
16
|
+
aspect_cardinal: string;
|
|
17
|
+
}
|
|
18
|
+
interface PriceSnapshot {
|
|
19
|
+
date: string;
|
|
20
|
+
peak_eur_mwh: number;
|
|
21
|
+
off_peak_eur_mwh: number;
|
|
22
|
+
mean_eur_mwh: number;
|
|
23
|
+
}
|
|
24
|
+
interface SiteRevenueResult {
|
|
25
|
+
lat: number;
|
|
26
|
+
lon: number;
|
|
27
|
+
zone: string;
|
|
28
|
+
technology: "pv" | "bess";
|
|
29
|
+
capacity_mw: number;
|
|
30
|
+
terrain: TerrainSnapshot | null;
|
|
31
|
+
revenue: {
|
|
32
|
+
annual_generation_mwh?: number;
|
|
33
|
+
capacity_factor?: number;
|
|
34
|
+
capture_price_eur_mwh?: number;
|
|
35
|
+
daily_spread_eur_mwh?: number;
|
|
36
|
+
daily_revenue_eur?: number;
|
|
37
|
+
arb_signal?: string;
|
|
38
|
+
estimated_annual_revenue_eur: number;
|
|
39
|
+
};
|
|
40
|
+
price_snapshot: PriceSnapshot | null;
|
|
41
|
+
caveats: string[];
|
|
42
|
+
disclaimer: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Estimate annual PV generation revenue or BESS arbitrage revenue for a
|
|
46
|
+
* candidate site. Combines GIS data (solar resource, terrain) with market
|
|
47
|
+
* data (day-ahead prices, spread analysis).
|
|
48
|
+
*/
|
|
49
|
+
export declare function estimateSiteRevenue(params: z.infer<typeof siteRevenueSchema>): Promise<SiteRevenueResult>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getSolarIrradiance } from "./solar.js";
|
|
3
|
+
import { getDayAheadPrices } from "./prices.js";
|
|
4
|
+
import { getPriceSpreadAnalysis } from "./price-spread-analysis.js";
|
|
5
|
+
import { getTerrainAnalysis } from "./terrain-analysis.js";
|
|
6
|
+
export const siteRevenueSchema = z.object({
|
|
7
|
+
lat: z.number().describe("Latitude (-90 to 90). WGS84."),
|
|
8
|
+
lon: z.number().describe("Longitude (-180 to 180). WGS84."),
|
|
9
|
+
zone: z.string().describe("Bidding zone for price data (e.g. GB, DE, FR)."),
|
|
10
|
+
technology: z.enum(["pv", "bess"]).describe("PV (solar) or BESS (battery storage)."),
|
|
11
|
+
capacity_mw: z
|
|
12
|
+
.number()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Installed capacity in MW (default 10)."),
|
|
15
|
+
date: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Price date for BESS arbitrage (YYYY-MM-DD, default today)."),
|
|
19
|
+
});
|
|
20
|
+
const DISCLAIMER = "This is a screening-level estimate only. It is not a financial model, investment advice, or a substitute for a bankable feasibility study.";
|
|
21
|
+
const COMMON_CAVEATS = [
|
|
22
|
+
"Revenue estimate uses a single day's price curve, not historical averages",
|
|
23
|
+
"No degradation, curtailment, or network losses modeled",
|
|
24
|
+
"Grid connection costs are not included",
|
|
25
|
+
];
|
|
26
|
+
const PV_CAVEATS = [
|
|
27
|
+
"Capture price uses daylight hours (07:00-19:00) as a simple proxy",
|
|
28
|
+
];
|
|
29
|
+
const BESS_CAVEATS = [
|
|
30
|
+
"Arbitrage assumes optimal dispatch with perfect foresight",
|
|
31
|
+
];
|
|
32
|
+
/** Round to 2 decimal places. */
|
|
33
|
+
function round2(n) {
|
|
34
|
+
return Math.round(n * 100) / 100;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Estimate annual PV generation revenue or BESS arbitrage revenue for a
|
|
38
|
+
* candidate site. Combines GIS data (solar resource, terrain) with market
|
|
39
|
+
* data (day-ahead prices, spread analysis).
|
|
40
|
+
*/
|
|
41
|
+
export async function estimateSiteRevenue(params) {
|
|
42
|
+
const { lat, lon, zone, technology } = params;
|
|
43
|
+
const capacity_mw = params.capacity_mw ?? 10;
|
|
44
|
+
const date = params.date ?? new Date().toISOString().slice(0, 10);
|
|
45
|
+
if (technology === "pv") {
|
|
46
|
+
return estimatePvRevenue({ lat, lon, zone, capacity_mw, date });
|
|
47
|
+
}
|
|
48
|
+
return estimateBessRevenue({ lat, lon, zone, capacity_mw, date });
|
|
49
|
+
}
|
|
50
|
+
async function estimatePvRevenue(p) {
|
|
51
|
+
// Fire all three requests in parallel; terrain is non-critical.
|
|
52
|
+
const [solarResult, pricesResult, terrainResult] = await Promise.allSettled([
|
|
53
|
+
getSolarIrradiance({ lat: p.lat, lon: p.lon }),
|
|
54
|
+
getDayAheadPrices({ zone: p.zone, start_date: p.date }),
|
|
55
|
+
getTerrainAnalysis({ lat: p.lat, lon: p.lon }),
|
|
56
|
+
]);
|
|
57
|
+
// Solar is required.
|
|
58
|
+
if (solarResult.status === "rejected") {
|
|
59
|
+
throw new Error(`Solar irradiance lookup failed: ${solarResult.reason instanceof Error ? solarResult.reason.message : String(solarResult.reason)}`);
|
|
60
|
+
}
|
|
61
|
+
// Price data is required.
|
|
62
|
+
if (pricesResult.status === "rejected") {
|
|
63
|
+
throw new Error(`Day-ahead price lookup failed: ${pricesResult.reason instanceof Error ? pricesResult.reason.message : String(pricesResult.reason)}`);
|
|
64
|
+
}
|
|
65
|
+
const solar = solarResult.value;
|
|
66
|
+
const prices = pricesResult.value;
|
|
67
|
+
const terrain = terrainResult.status === "fulfilled" ? terrainResult.value : null;
|
|
68
|
+
// annual_generation_mwh = capacity_mw * 1000 * (annual_yield_kwh / 1000)
|
|
69
|
+
// annual_yield_kwh is per 1 kWp, so for capacity_mw MW:
|
|
70
|
+
const annual_generation_mwh = round2(p.capacity_mw * 1000 * (solar.annual_yield_kwh / 1000));
|
|
71
|
+
// Capacity factor: actual generation / (capacity * 8760 hours)
|
|
72
|
+
const capacity_factor = round2(annual_generation_mwh / (p.capacity_mw * 8760));
|
|
73
|
+
// Capture price: weighted average of daylight hours (7-19)
|
|
74
|
+
const daylightPrices = prices.prices.filter((pp) => pp.hour >= 7 && pp.hour <= 19);
|
|
75
|
+
const capture_price_eur_mwh = daylightPrices.length > 0
|
|
76
|
+
? round2(daylightPrices.reduce((s, pp) => s + pp.price_eur_mwh, 0) / daylightPrices.length)
|
|
77
|
+
: prices.stats.mean;
|
|
78
|
+
// Annual revenue in EUR. annual_generation_mwh is already in MWh.
|
|
79
|
+
const estimated_annual_revenue_eur = round2(annual_generation_mwh * capture_price_eur_mwh);
|
|
80
|
+
const price_snapshot = {
|
|
81
|
+
date: prices.start_date,
|
|
82
|
+
peak_eur_mwh: prices.stats.max,
|
|
83
|
+
off_peak_eur_mwh: prices.stats.min,
|
|
84
|
+
mean_eur_mwh: prices.stats.mean,
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
lat: p.lat,
|
|
88
|
+
lon: p.lon,
|
|
89
|
+
zone: p.zone.toUpperCase(),
|
|
90
|
+
technology: "pv",
|
|
91
|
+
capacity_mw: p.capacity_mw,
|
|
92
|
+
terrain: terrain
|
|
93
|
+
? { elevation_m: terrain.elevation_m, slope_deg: terrain.slope_deg, aspect_cardinal: terrain.aspect_cardinal }
|
|
94
|
+
: null,
|
|
95
|
+
revenue: {
|
|
96
|
+
annual_generation_mwh,
|
|
97
|
+
capacity_factor,
|
|
98
|
+
capture_price_eur_mwh,
|
|
99
|
+
estimated_annual_revenue_eur,
|
|
100
|
+
},
|
|
101
|
+
price_snapshot,
|
|
102
|
+
caveats: [...COMMON_CAVEATS, ...PV_CAVEATS],
|
|
103
|
+
disclaimer: DISCLAIMER,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// BESS revenue
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
async function estimateBessRevenue(p) {
|
|
110
|
+
const [spreadResult, terrainResult] = await Promise.allSettled([
|
|
111
|
+
getPriceSpreadAnalysis({ zone: p.zone, date: p.date, efficiency: 0.88, cycles: 2 }),
|
|
112
|
+
getTerrainAnalysis({ lat: p.lat, lon: p.lon }),
|
|
113
|
+
]);
|
|
114
|
+
// Spread is required.
|
|
115
|
+
if (spreadResult.status === "rejected") {
|
|
116
|
+
throw new Error(`Price spread analysis failed: ${spreadResult.reason instanceof Error ? spreadResult.reason.message : String(spreadResult.reason)}`);
|
|
117
|
+
}
|
|
118
|
+
const spread = spreadResult.value;
|
|
119
|
+
const terrain = terrainResult.status === "fulfilled" ? terrainResult.value : null;
|
|
120
|
+
const daily_revenue_eur = round2(spread.revenuePerMwDay * p.capacity_mw);
|
|
121
|
+
const estimated_annual_revenue_eur = round2(daily_revenue_eur * 365);
|
|
122
|
+
const price_snapshot = {
|
|
123
|
+
date: spread.date,
|
|
124
|
+
peak_eur_mwh: spread.peakPrice,
|
|
125
|
+
off_peak_eur_mwh: spread.offPeakPrice,
|
|
126
|
+
mean_eur_mwh: round2((spread.peakPrice + spread.offPeakPrice) / 2),
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
lat: p.lat,
|
|
130
|
+
lon: p.lon,
|
|
131
|
+
zone: p.zone.toUpperCase(),
|
|
132
|
+
technology: "bess",
|
|
133
|
+
capacity_mw: p.capacity_mw,
|
|
134
|
+
terrain: terrain
|
|
135
|
+
? { elevation_m: terrain.elevation_m, slope_deg: terrain.slope_deg, aspect_cardinal: terrain.aspect_cardinal }
|
|
136
|
+
: null,
|
|
137
|
+
revenue: {
|
|
138
|
+
daily_spread_eur_mwh: spread.netSpread,
|
|
139
|
+
daily_revenue_eur,
|
|
140
|
+
arb_signal: spread.signal,
|
|
141
|
+
estimated_annual_revenue_eur,
|
|
142
|
+
},
|
|
143
|
+
price_snapshot,
|
|
144
|
+
caveats: [...COMMON_CAVEATS, ...BESS_CAVEATS],
|
|
145
|
+
disclaimer: DISCLAIMER,
|
|
146
|
+
};
|
|
147
|
+
}
|