@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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +454 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +488 -0
  5. package/dist/lib/audit.d.ts +3 -0
  6. package/dist/lib/audit.js +66 -0
  7. package/dist/lib/auth.d.ts +26 -0
  8. package/dist/lib/auth.js +199 -0
  9. package/dist/lib/cache.d.ts +25 -0
  10. package/dist/lib/cache.js +38 -0
  11. package/dist/lib/cli.d.ts +1 -0
  12. package/dist/lib/cli.js +10 -0
  13. package/dist/lib/corine.d.ts +31 -0
  14. package/dist/lib/corine.js +137 -0
  15. package/dist/lib/eea-natura2000.d.ts +7 -0
  16. package/dist/lib/eea-natura2000.js +53 -0
  17. package/dist/lib/entsoe-client.d.ts +22 -0
  18. package/dist/lib/entsoe-client.js +69 -0
  19. package/dist/lib/gis-sources.d.ts +33 -0
  20. package/dist/lib/gis-sources.js +392 -0
  21. package/dist/lib/natural-england.d.ts +27 -0
  22. package/dist/lib/natural-england.js +105 -0
  23. package/dist/lib/neso-gsp.d.ts +18 -0
  24. package/dist/lib/neso-gsp.js +113 -0
  25. package/dist/lib/overpass.d.ts +13 -0
  26. package/dist/lib/overpass.js +193 -0
  27. package/dist/lib/profiles.d.ts +23 -0
  28. package/dist/lib/profiles.js +149 -0
  29. package/dist/lib/schema-guard.d.ts +22 -0
  30. package/dist/lib/schema-guard.js +38 -0
  31. package/dist/lib/tool-handler.d.ts +15 -0
  32. package/dist/lib/tool-handler.js +95 -0
  33. package/dist/lib/xml-parser.d.ts +4 -0
  34. package/dist/lib/xml-parser.js +34 -0
  35. package/dist/lib/zone-codes.d.ts +12 -0
  36. package/dist/lib/zone-codes.js +127 -0
  37. package/dist/tools/acer-remit.d.ts +60 -0
  38. package/dist/tools/acer-remit.js +154 -0
  39. package/dist/tools/agricultural-land.d.ts +31 -0
  40. package/dist/tools/agricultural-land.js +210 -0
  41. package/dist/tools/ancillary-prices.d.ts +27 -0
  42. package/dist/tools/ancillary-prices.js +70 -0
  43. package/dist/tools/auctions.d.ts +15 -0
  44. package/dist/tools/auctions.js +89 -0
  45. package/dist/tools/balancing-actions.d.ts +22 -0
  46. package/dist/tools/balancing-actions.js +151 -0
  47. package/dist/tools/balancing.d.ts +21 -0
  48. package/dist/tools/balancing.js +56 -0
  49. package/dist/tools/carbon.d.ts +21 -0
  50. package/dist/tools/carbon.js +68 -0
  51. package/dist/tools/commodity-prices.d.ts +26 -0
  52. package/dist/tools/commodity-prices.js +100 -0
  53. package/dist/tools/compare-sites.d.ts +41 -0
  54. package/dist/tools/compare-sites.js +237 -0
  55. package/dist/tools/demand-forecast.d.ts +21 -0
  56. package/dist/tools/demand-forecast.js +56 -0
  57. package/dist/tools/elexon-bmrs.d.ts +72 -0
  58. package/dist/tools/elexon-bmrs.js +117 -0
  59. package/dist/tools/energi-data.d.ts +72 -0
  60. package/dist/tools/energi-data.js +170 -0
  61. package/dist/tools/energy-charts.d.ts +103 -0
  62. package/dist/tools/energy-charts.js +411 -0
  63. package/dist/tools/entsog.d.ts +71 -0
  64. package/dist/tools/entsog.js +159 -0
  65. package/dist/tools/era5-weather.d.ts +39 -0
  66. package/dist/tools/era5-weather.js +117 -0
  67. package/dist/tools/eu-gas-price.d.ts +38 -0
  68. package/dist/tools/eu-gas-price.js +110 -0
  69. package/dist/tools/fingrid.d.ts +39 -0
  70. package/dist/tools/fingrid.js +158 -0
  71. package/dist/tools/flood-risk.d.ts +33 -0
  72. package/dist/tools/flood-risk.js +166 -0
  73. package/dist/tools/flows.d.ts +23 -0
  74. package/dist/tools/flows.js +61 -0
  75. package/dist/tools/frequency.d.ts +10 -0
  76. package/dist/tools/frequency.js +35 -0
  77. package/dist/tools/gas-storage.d.ts +18 -0
  78. package/dist/tools/gas-storage.js +72 -0
  79. package/dist/tools/generation.d.ts +17 -0
  80. package/dist/tools/generation.js +80 -0
  81. package/dist/tools/grid-connection-intelligence.d.ts +42 -0
  82. package/dist/tools/grid-connection-intelligence.js +122 -0
  83. package/dist/tools/grid-connection-queue.d.ts +64 -0
  84. package/dist/tools/grid-connection-queue.js +198 -0
  85. package/dist/tools/grid-proximity.d.ts +38 -0
  86. package/dist/tools/grid-proximity.js +123 -0
  87. package/dist/tools/hydro-inflows.d.ts +34 -0
  88. package/dist/tools/hydro-inflows.js +114 -0
  89. package/dist/tools/hydro.d.ts +18 -0
  90. package/dist/tools/hydro.js +85 -0
  91. package/dist/tools/imbalance-prices.d.ts +21 -0
  92. package/dist/tools/imbalance-prices.js +56 -0
  93. package/dist/tools/intraday-prices.d.ts +21 -0
  94. package/dist/tools/intraday-prices.js +57 -0
  95. package/dist/tools/intraday-spread.d.ts +24 -0
  96. package/dist/tools/intraday-spread.js +55 -0
  97. package/dist/tools/land-constraints.d.ts +25 -0
  98. package/dist/tools/land-constraints.js +148 -0
  99. package/dist/tools/land-cover.d.ts +18 -0
  100. package/dist/tools/land-cover.js +64 -0
  101. package/dist/tools/lng-terminals.d.ts +22 -0
  102. package/dist/tools/lng-terminals.js +75 -0
  103. package/dist/tools/net-positions.d.ts +19 -0
  104. package/dist/tools/net-positions.js +74 -0
  105. package/dist/tools/nordpool-prices.d.ts +29 -0
  106. package/dist/tools/nordpool-prices.js +80 -0
  107. package/dist/tools/outages.d.ts +28 -0
  108. package/dist/tools/outages.js +107 -0
  109. package/dist/tools/power-plants.d.ts +26 -0
  110. package/dist/tools/power-plants.js +224 -0
  111. package/dist/tools/price-spread-analysis.d.ts +27 -0
  112. package/dist/tools/price-spread-analysis.js +97 -0
  113. package/dist/tools/prices.d.ts +23 -0
  114. package/dist/tools/prices.js +79 -0
  115. package/dist/tools/realtime-generation.d.ts +19 -0
  116. package/dist/tools/realtime-generation.js +141 -0
  117. package/dist/tools/ree-esios.d.ts +78 -0
  118. package/dist/tools/ree-esios.js +216 -0
  119. package/dist/tools/regelleistung.d.ts +28 -0
  120. package/dist/tools/regelleistung.js +71 -0
  121. package/dist/tools/remit-messages.d.ts +23 -0
  122. package/dist/tools/remit-messages.js +110 -0
  123. package/dist/tools/renewable-forecast.d.ts +23 -0
  124. package/dist/tools/renewable-forecast.js +75 -0
  125. package/dist/tools/rte-france.d.ts +72 -0
  126. package/dist/tools/rte-france.js +147 -0
  127. package/dist/tools/screen-site.d.ts +50 -0
  128. package/dist/tools/screen-site.js +288 -0
  129. package/dist/tools/site-revenue.d.ts +50 -0
  130. package/dist/tools/site-revenue.js +147 -0
  131. package/dist/tools/smard-data.d.ts +34 -0
  132. package/dist/tools/smard-data.js +155 -0
  133. package/dist/tools/solar.d.ts +23 -0
  134. package/dist/tools/solar.js +69 -0
  135. package/dist/tools/stormglass.d.ts +56 -0
  136. package/dist/tools/stormglass.js +172 -0
  137. package/dist/tools/terna.d.ts +69 -0
  138. package/dist/tools/terna.js +159 -0
  139. package/dist/tools/terrain-analysis.d.ts +19 -0
  140. package/dist/tools/terrain-analysis.js +120 -0
  141. package/dist/tools/transfer-capacity.d.ts +22 -0
  142. package/dist/tools/transfer-capacity.js +61 -0
  143. package/dist/tools/transmission.d.ts +29 -0
  144. package/dist/tools/transmission.js +159 -0
  145. package/dist/tools/uk-carbon.d.ts +51 -0
  146. package/dist/tools/uk-carbon.js +109 -0
  147. package/dist/tools/uk-grid.d.ts +28 -0
  148. package/dist/tools/uk-grid.js +70 -0
  149. package/dist/tools/us-gas.d.ts +30 -0
  150. package/dist/tools/us-gas.js +100 -0
  151. package/dist/tools/verify-gis-sources.d.ts +25 -0
  152. package/dist/tools/verify-gis-sources.js +119 -0
  153. package/dist/tools/weather.d.ts +27 -0
  154. package/dist/tools/weather.js +120 -0
  155. package/package.json +62 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * GIS data source metadata — provenance, reliability, and caveats.
3
+ *
4
+ * Each GIS tool response includes a `source_metadata` block drawn from
5
+ * these definitions. The goal is to make data quality and upstream
6
+ * limitations visible to callers, not hidden behind a clean API surface.
7
+ */
8
+ export const GIS_SOURCES = {
9
+ "open-meteo-elevation": {
10
+ id: "open-meteo-elevation",
11
+ name: "Open-Meteo Elevation API",
12
+ provider: "Open-Meteo / Copernicus EU-DEM",
13
+ licence: "Copernicus Licence (free, attribution required)",
14
+ url: "https://open-meteo.com/en/docs/elevation-api",
15
+ api_key_required: false,
16
+ coverage: "Global (EU-DEM ~30m resolution over Europe)",
17
+ update_frequency: "Static dataset, updated infrequently",
18
+ reliability: "high",
19
+ caveats: [
20
+ "Resolution is ~30m — fine for site-level screening, not parcel-level",
21
+ "Slope/aspect derived from a 3x3 grid around the point (Horn's method)",
22
+ "Urban areas and steep valleys may have elevation artefacts",
23
+ ],
24
+ attribution: "Data: Copernicus EU-DEM via Open-Meteo",
25
+ },
26
+ "overpass-osm": {
27
+ id: "overpass-osm",
28
+ name: "Overpass API (OpenStreetMap)",
29
+ provider: "OpenStreetMap contributors",
30
+ licence: "ODbL (Open Data Commons Open Database License)",
31
+ url: "https://wiki.openstreetmap.org/wiki/Overpass_API",
32
+ api_key_required: false,
33
+ coverage: "Global — but completeness varies by region",
34
+ update_frequency: "Near-real-time (OSM edits propagate within minutes to hours)",
35
+ reliability: "medium",
36
+ caveats: [
37
+ "Public endpoints can be slow or rate-limited under load",
38
+ "Substation and line tagging is volunteer-maintained — some assets may be missing or incorrectly tagged",
39
+ "Voltage values are often absent or imprecise for lower-voltage infrastructure",
40
+ "Three fallback endpoints are used, but all can be degraded simultaneously",
41
+ ],
42
+ attribution: "Data: OpenStreetMap contributors (ODbL)",
43
+ },
44
+ "natural-england": {
45
+ id: "natural-england",
46
+ name: "Natural England Open Data Geoportal",
47
+ provider: "Natural England / ArcGIS Online",
48
+ licence: "OGL v3 (Open Government Licence)",
49
+ url: "https://naturalengland-defra.opendata.arcgis.com/",
50
+ api_key_required: false,
51
+ coverage: "England only (not Scotland, Wales, or Northern Ireland)",
52
+ update_frequency: "Updated periodically — typically monthly to quarterly",
53
+ reliability: "medium",
54
+ caveats: [
55
+ "Covers England only — Scotland (NatureScot) and Wales (NRW) use separate services",
56
+ "ArcGIS field names and service slugs can change between service versions",
57
+ "Individual layers may be temporarily unavailable while the service is updated",
58
+ "Bounding-box queries may return features that only partially intersect the search area",
59
+ ],
60
+ attribution: "Contains Natural England data. Contains Ordnance Survey data. Crown copyright and database rights.",
61
+ },
62
+ "eea-natura2000": {
63
+ id: "eea-natura2000",
64
+ name: "EEA Natura 2000 Protected Sites",
65
+ provider: "European Environment Agency / ArcGIS REST",
66
+ licence: "EEA public environmental data access",
67
+ url: "https://bio.discomap.eea.europa.eu/arcgis/rest/services/ProtectedSites/Natura2000_Dyna_WM/MapServer",
68
+ api_key_required: false,
69
+ coverage: "EU Natura 2000 network coverage via EEA protected sites service",
70
+ update_frequency: "Updated when EEA refreshes Natura 2000 releases",
71
+ reliability: "medium",
72
+ caveats: [
73
+ "Covers Natura 2000 protected sites only, not the full set of national planning designations in each EU country",
74
+ "Site type codes are simplified into birds or habitats directive groupings for fast screening",
75
+ "This is a coarse screening layer, not a legal boundary determination or permitting decision",
76
+ "ArcGIS service structure and field names can change between publishing cycles",
77
+ ],
78
+ attribution: "Contains European Environment Agency Natura 2000 protected sites data.",
79
+ },
80
+ "natural-england-alc": {
81
+ id: "natural-england-alc",
82
+ name: "Natural England Agricultural Land Classification",
83
+ provider: "Natural England / ArcGIS Online",
84
+ licence: "OGL v3 (Open Government Licence)",
85
+ url: "https://naturalengland-defra.opendata.arcgis.com/",
86
+ api_key_required: false,
87
+ coverage: "England only, with patchy detailed-survey coverage and provisional fallback",
88
+ update_frequency: "Updated periodically — typically monthly to quarterly",
89
+ reliability: "medium",
90
+ caveats: [
91
+ "Detailed post-1988 surveys are incomplete, so many locations fall back to provisional ALC",
92
+ "Provisional Grade 3 does not distinguish 3a from 3b, so BMV status can be uncertain",
93
+ "England only — there is no equivalent coverage in this tool yet for Scotland, Wales, or Northern Ireland",
94
+ "ArcGIS field names and service slugs can change between service versions",
95
+ ],
96
+ attribution: "Contains Natural England Agricultural Land Classification data. Contains Ordnance Survey data. Crown copyright and database rights.",
97
+ },
98
+ "ea-flood-map": {
99
+ id: "ea-flood-map",
100
+ name: "Environment Agency Flood Map for Planning",
101
+ provider: "Environment Agency / DEFRA ArcGIS",
102
+ licence: "Open Government Licence v3",
103
+ url: "https://environment.data.gov.uk/dataset/04532375-a198-476e-985e-0579a0a11b47",
104
+ api_key_required: false,
105
+ coverage: "England only for Flood Map for Planning layers",
106
+ update_frequency: "Updated periodically as national and local flood models are refreshed",
107
+ reliability: "medium",
108
+ caveats: [
109
+ "England only — separate services are needed for Scotland, Wales, and Northern Ireland",
110
+ "Flood Map for Planning is for development screening, not property-level flood advice",
111
+ "Flood Zone 2 should be interpreted together with Flood Zone 3 and flood storage areas",
112
+ "ArcGIS service structure and field names can change between publishing cycles",
113
+ ],
114
+ attribution: "Contains Environment Agency information licensed under the Open Government Licence v3.0.",
115
+ },
116
+ "corine-land-cover": {
117
+ id: "corine-land-cover",
118
+ name: "CORINE Land Cover 2018",
119
+ provider: "European Environment Agency / Copernicus Land Monitoring Service",
120
+ licence: "Copernicus Land Monitoring Service (free, attribution required)",
121
+ url: "https://land.copernicus.eu/pan-european/corine-land-cover/clc2018",
122
+ api_key_required: false,
123
+ coverage: "EU27 member states plus EEA/EFTA countries. Great Britain not covered (UK withdrew after CLC 2012).",
124
+ update_frequency: "Static — CLC 2018 is the most recent release; next update expected as CLC 2024",
125
+ reliability: "high",
126
+ caveats: [
127
+ "Minimum mapping unit is 25 hectares — small parcels may not appear",
128
+ "Great Britain is not covered; use get_agricultural_land for England instead",
129
+ "Classification is based on 2018 satellite imagery; recent land-use changes will not be reflected",
130
+ "ArcGIS service structure or field names may change between EEA publishing cycles",
131
+ ],
132
+ attribution: "Contains CORINE Land Cover 2018 data from the Copernicus Land Monitoring Service, " +
133
+ "© European Environment Agency (EEA).",
134
+ },
135
+ "neso-gsp-lookup": {
136
+ id: "neso-gsp-lookup",
137
+ name: "NESO GSP-Gnode Region Lookup",
138
+ provider: "National Energy System Operator (NESO)",
139
+ licence: "NESO Open Data Licence",
140
+ url: "https://api.neso.energy/dataset/2810092e-d4b2-472f-b955-d8bea01f9ec0",
141
+ api_key_required: false,
142
+ coverage: "Great Britain Grid Supply Point regions",
143
+ update_frequency: "Updated infrequently — GSP boundaries change rarely",
144
+ reliability: "high",
145
+ caveats: [
146
+ "Uses nearest-GSP by haversine distance, not polygon containment — an approximation",
147
+ "GSP coordinates are centroid-like reference points, not precise boundary anchors",
148
+ "Some GSP names may not match TEC register connection site names exactly",
149
+ "Coverage is GB only — does not include Northern Ireland or offshore",
150
+ ],
151
+ attribution: "Contains data from the National Energy System Operator (NESO) GSP-Gnode lookup.",
152
+ },
153
+ "neso-tec-register": {
154
+ id: "neso-tec-register",
155
+ name: "NESO Transmission Entry Capacity Register",
156
+ provider: "National Energy System Operator (NESO)",
157
+ licence: "NESO Open Data Licence",
158
+ url: "https://api.neso.energy/api/3/action/package_show?id=transmission-entry-capacity-tec-register",
159
+ api_key_required: false,
160
+ coverage: "Great Britain transmission-level connection register. Covers TEC-holding projects, not the full DNO queue.",
161
+ update_frequency: "Twice weekly",
162
+ reliability: "high",
163
+ caveats: [
164
+ "Transmission-level signal only — it is not a GB-wide DNO headroom or flexibility map",
165
+ "Register entries reflect contracted TEC positions and project statuses, not guaranteed connection availability",
166
+ "Connection site names in the register may not match local substation naming exactly",
167
+ "Projects can appear multiple times because staged or technology-split agreements are still being refined by NESO",
168
+ ],
169
+ attribution: "Contains data from the National Energy System Operator (NESO) TEC register.",
170
+ },
171
+ "pvgis": {
172
+ id: "pvgis",
173
+ name: "PVGIS (Photovoltaic Geographical Information System)",
174
+ provider: "European Commission Joint Research Centre",
175
+ licence: "Free access, no key required",
176
+ url: "https://re.jrc.ec.europa.eu/pvg_tools/",
177
+ api_key_required: false,
178
+ coverage: "Europe, Africa, parts of Asia and Americas",
179
+ update_frequency: "Updated with new satellite data roughly annually",
180
+ reliability: "high",
181
+ caveats: [
182
+ "Optimal tilt angle sometimes returns 0 for UK latitudes — appears to be a PVGIS default",
183
+ "Monthly averages are long-term climatological values, not current-year",
184
+ "Coastal and mountainous sites may differ from the grid-cell average",
185
+ ],
186
+ attribution: "Data: PVGIS, European Commission Joint Research Centre",
187
+ },
188
+ };
189
+ export const GIS_HEALTH_CHECKS = [
190
+ {
191
+ source_id: "open-meteo-elevation",
192
+ url: "https://api.open-meteo.com/v1/elevation?latitude=51.5&longitude=-0.1",
193
+ method: "GET",
194
+ timeout_ms: 10_000,
195
+ validate: (status, body) => {
196
+ if (status !== 200)
197
+ return `HTTP ${status}`;
198
+ try {
199
+ const json = JSON.parse(body);
200
+ if (!Array.isArray(json.elevation) || json.elevation.length === 0) {
201
+ return "Response missing elevation array";
202
+ }
203
+ return null;
204
+ }
205
+ catch {
206
+ return "Response is not valid JSON";
207
+ }
208
+ },
209
+ },
210
+ {
211
+ source_id: "overpass-osm",
212
+ url: "https://overpass-api.de/api/interpreter",
213
+ method: "POST",
214
+ body: `data=${encodeURIComponent('[out:json][timeout:10];node["power"="substation"](around:1000,51.5,-0.1);out count;')}`,
215
+ timeout_ms: 15_000,
216
+ validate: (status, body) => {
217
+ if (status !== 200)
218
+ return `HTTP ${status}`;
219
+ try {
220
+ const json = JSON.parse(body);
221
+ if (!Array.isArray(json.elements)) {
222
+ return "Response missing elements array";
223
+ }
224
+ return null;
225
+ }
226
+ catch {
227
+ return "Response is not valid JSON";
228
+ }
229
+ },
230
+ },
231
+ {
232
+ source_id: "natural-england",
233
+ url: "https://services.arcgis.com/JJzESW51TqeY9uat/arcgis/rest/services/SSSI_England/FeatureServer/0/query?where=1%3D1&resultRecordCount=1&outFields=NAME&returnGeometry=false&f=json",
234
+ method: "GET",
235
+ timeout_ms: 15_000,
236
+ validate: (status, body) => {
237
+ if (status !== 200)
238
+ return `HTTP ${status}`;
239
+ try {
240
+ const json = JSON.parse(body);
241
+ if (json.error)
242
+ return `ArcGIS error: ${json.error.message ?? "unknown"}`;
243
+ if (!Array.isArray(json.features))
244
+ return "Response missing features array";
245
+ return null;
246
+ }
247
+ catch {
248
+ return "Response is not valid JSON";
249
+ }
250
+ },
251
+ },
252
+ {
253
+ source_id: "eea-natura2000",
254
+ url: "https://bio.discomap.eea.europa.eu/arcgis/rest/services/ProtectedSites/Natura2000_Dyna_WM/MapServer/0/query?where=1%3D1&resultRecordCount=1&outFields=SITECODE%2CSITENAME%2CSITETYPE&returnGeometry=false&f=json",
255
+ method: "GET",
256
+ timeout_ms: 15_000,
257
+ validate: (status, body) => {
258
+ if (status !== 200)
259
+ return `HTTP ${status}`;
260
+ try {
261
+ const json = JSON.parse(body);
262
+ if (json.error)
263
+ return `ArcGIS error: ${json.error.message ?? "unknown"}`;
264
+ if (!Array.isArray(json.features))
265
+ return "Response missing features array";
266
+ return null;
267
+ }
268
+ catch {
269
+ return "Response is not valid JSON";
270
+ }
271
+ },
272
+ },
273
+ {
274
+ source_id: "natural-england-alc",
275
+ url: "https://services.arcgis.com/JJzESW51TqeY9uat/arcgis/rest/services/Agricultural_Land_Classification_Post_1988/FeatureServer/0/query?where=1%3D1&resultRecordCount=1&outFields=ALC_GRADE&returnGeometry=false&f=json",
276
+ method: "GET",
277
+ timeout_ms: 15_000,
278
+ validate: (status, body) => {
279
+ if (status !== 200)
280
+ return `HTTP ${status}`;
281
+ try {
282
+ const json = JSON.parse(body);
283
+ if (json.error)
284
+ return `ArcGIS error: ${json.error.message ?? "unknown"}`;
285
+ if (!Array.isArray(json.features))
286
+ return "Response missing features array";
287
+ return null;
288
+ }
289
+ catch {
290
+ return "Response is not valid JSON";
291
+ }
292
+ },
293
+ },
294
+ {
295
+ source_id: "corine-land-cover",
296
+ url: "https://image.discomap.eea.europa.eu/arcgis/rest/services/Corine/CLC2018_WM/MapServer/0/query?geometry=2.35%2C48.85&geometryType=esriGeometryPoint&inSR=4326&spatialRel=esriSpatialRelIntersects&outFields=Code_18&returnGeometry=false&resultRecordCount=1&f=json",
297
+ method: "GET",
298
+ timeout_ms: 15_000,
299
+ validate: (status, body) => {
300
+ if (status !== 200)
301
+ return `HTTP ${status}`;
302
+ try {
303
+ const json = JSON.parse(body);
304
+ if (json.error)
305
+ return `ArcGIS error: ${json.error.message ?? "unknown"}`;
306
+ if (!Array.isArray(json.features))
307
+ return "Response missing features array";
308
+ const code = json.features[0]?.attributes?.Code_18;
309
+ if (typeof code !== "string" || code.length === 0)
310
+ return "Response missing Code_18 value";
311
+ return null;
312
+ }
313
+ catch {
314
+ return "Response is not valid JSON";
315
+ }
316
+ },
317
+ },
318
+ {
319
+ source_id: "ea-flood-map",
320
+ url: "https://environment.data.gov.uk/KB6uNVj5ZcJr7jUP/ArcGIS/rest/services/Flood_Map_for_Planning/FeatureServer/1/query?where=1%3D1&resultRecordCount=1&outFields=layer,type&returnGeometry=false&f=json",
321
+ method: "GET",
322
+ timeout_ms: 15_000,
323
+ validate: (status, body) => {
324
+ if (status !== 200)
325
+ return `HTTP ${status}`;
326
+ try {
327
+ const json = JSON.parse(body);
328
+ if (json.error)
329
+ return `ArcGIS error: ${json.error.message ?? "unknown"}`;
330
+ if (!Array.isArray(json.features))
331
+ return "Response missing features array";
332
+ return null;
333
+ }
334
+ catch {
335
+ return "Response is not valid JSON";
336
+ }
337
+ },
338
+ },
339
+ {
340
+ source_id: "neso-gsp-lookup",
341
+ url: "https://api.neso.energy/dataset/2810092e-d4b2-472f-b955-d8bea01f9ec0/resource/bbe2cc72-a6c6-46e6-8f4e-48b879467368/download/gsp_gnode_directconnect_region_lookup.csv",
342
+ method: "GET",
343
+ timeout_ms: 15_000,
344
+ validate: (status, body) => {
345
+ if (status !== 200)
346
+ return `HTTP ${status}`;
347
+ if (!body.includes("gsp_id"))
348
+ return "Response missing gsp_id column header";
349
+ return null;
350
+ },
351
+ },
352
+ {
353
+ source_id: "neso-tec-register",
354
+ url: "https://api.neso.energy/api/3/action/datastore_search?resource_id=17becbab-e3e8-473f-b303-3806f43a6a10&limit=1",
355
+ method: "GET",
356
+ timeout_ms: 15_000,
357
+ validate: (status, body) => {
358
+ if (status !== 200)
359
+ return `HTTP ${status}`;
360
+ try {
361
+ const json = JSON.parse(body);
362
+ if (!json.success)
363
+ return json.error?.message ?? "NESO API reported failure";
364
+ if (!Array.isArray(json.result?.records))
365
+ return "Response missing records array";
366
+ return null;
367
+ }
368
+ catch {
369
+ return "Response is not valid JSON";
370
+ }
371
+ },
372
+ },
373
+ {
374
+ source_id: "pvgis",
375
+ url: "https://re.jrc.ec.europa.eu/api/v5_3/PVcalc?lat=51.5&lon=-0.1&peakpower=1&loss=14&outputformat=json",
376
+ method: "GET",
377
+ timeout_ms: 15_000,
378
+ validate: (status, body) => {
379
+ if (status !== 200)
380
+ return `HTTP ${status}`;
381
+ try {
382
+ const json = JSON.parse(body);
383
+ if (!json.outputs)
384
+ return "Response missing outputs field";
385
+ return null;
386
+ }
387
+ catch {
388
+ return "Response is not valid JSON";
389
+ }
390
+ },
391
+ },
392
+ ];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Natural England ArcGIS REST API client for querying GB protected area layers.
3
+ *
4
+ * Queries the Natural England Open Data Geoportal (ArcGIS Online hosted feature services)
5
+ * for environmental designations: SSSIs, SACs, SPAs, Ramsar sites, National Parks, and AONBs.
6
+ *
7
+ * All endpoints are free, require no API key, and are published under OGL v3.
8
+ * Attribution: © Natural England copyright. Contains Ordnance Survey data © Crown copyright.
9
+ */
10
+ export interface ConstraintLayerConfig {
11
+ readonly slug: string;
12
+ readonly constraintType: string;
13
+ readonly nameField: string;
14
+ readonly areaField: string | null;
15
+ }
16
+ export declare const GB_PROTECTED_AREA_LAYERS: readonly ConstraintLayerConfig[];
17
+ export interface ConstraintFeature {
18
+ name: string;
19
+ type: string;
20
+ area_ha: number | null;
21
+ source: string;
22
+ }
23
+ /**
24
+ * Query a single Natural England ArcGIS feature layer for protected areas
25
+ * intersecting a bounding box around the given point.
26
+ */
27
+ export declare function queryLayer(layer: ConstraintLayerConfig, lat: number, lon: number, radiusKm: number): Promise<ConstraintFeature[]>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Natural England ArcGIS REST API client for querying GB protected area layers.
3
+ *
4
+ * Queries the Natural England Open Data Geoportal (ArcGIS Online hosted feature services)
5
+ * for environmental designations: SSSIs, SACs, SPAs, Ramsar sites, National Parks, and AONBs.
6
+ *
7
+ * All endpoints are free, require no API key, and are published under OGL v3.
8
+ * Attribution: © Natural England copyright. Contains Ordnance Survey data © Crown copyright.
9
+ */
10
+ import { guardArcGisFields } from "./schema-guard.js";
11
+ const NE_ARCGIS_BASE = "https://services.arcgis.com/JJzESW51TqeY9uat/arcgis/rest/services";
12
+ export const GB_PROTECTED_AREA_LAYERS = [
13
+ {
14
+ slug: "SSSI_England",
15
+ constraintType: "sssi",
16
+ nameField: "NAME",
17
+ areaField: "MEASURE",
18
+ },
19
+ {
20
+ slug: "Special_Areas_of_Conservation_England",
21
+ constraintType: "sac",
22
+ nameField: "SAC_NAME",
23
+ areaField: "SAC_AREA",
24
+ },
25
+ {
26
+ slug: "Special_Protection_Areas_England",
27
+ constraintType: "spa",
28
+ nameField: "SPA_NAME",
29
+ areaField: "SPA_AREA",
30
+ },
31
+ {
32
+ slug: "Ramsar_England",
33
+ constraintType: "ramsar",
34
+ nameField: "NAME",
35
+ areaField: "AREA",
36
+ },
37
+ {
38
+ slug: "National_Parks_England",
39
+ constraintType: "national_park",
40
+ nameField: "NAME",
41
+ areaField: "MEASURE",
42
+ },
43
+ {
44
+ slug: "Areas_of_Outstanding_Natural_Beauty_England",
45
+ constraintType: "aonb",
46
+ nameField: "NAME",
47
+ areaField: "STAT_AREA",
48
+ },
49
+ ];
50
+ /**
51
+ * Build a WGS84 bounding box string from a point and radius.
52
+ * Returns "xmin,ymin,xmax,ymax" suitable for ArcGIS REST envelope queries.
53
+ */
54
+ function buildEnvelopeGeometry(lat, lon, radiusKm) {
55
+ const latDelta = radiusKm / 111.32;
56
+ const lonDelta = radiusKm / (111.32 * Math.cos(lat * (Math.PI / 180)));
57
+ return `${lon - lonDelta},${lat - latDelta},${lon + lonDelta},${lat + latDelta}`;
58
+ }
59
+ /**
60
+ * Query a single Natural England ArcGIS feature layer for protected areas
61
+ * intersecting a bounding box around the given point.
62
+ */
63
+ export async function queryLayer(layer, lat, lon, radiusKm) {
64
+ const base = `${NE_ARCGIS_BASE}/${layer.slug}/FeatureServer/0/query`;
65
+ const url = new URL(base);
66
+ const p = url.searchParams;
67
+ p.set("where", "1=1");
68
+ p.set("geometry", buildEnvelopeGeometry(lat, lon, radiusKm));
69
+ p.set("geometryType", "esriGeometryEnvelope");
70
+ p.set("inSR", "4326");
71
+ p.set("spatialRel", "esriSpatialRelIntersects");
72
+ const fields = [layer.nameField];
73
+ if (layer.areaField)
74
+ fields.push(layer.areaField);
75
+ p.set("outFields", fields.join(","));
76
+ p.set("returnGeometry", "false");
77
+ p.set("resultRecordCount", "20");
78
+ p.set("f", "json");
79
+ const response = await fetch(url.toString());
80
+ if (!response.ok) {
81
+ const body = await response.text();
82
+ throw new Error(`Natural England API returned ${response.status} for ${layer.constraintType}: ${body.slice(0, 300)}`);
83
+ }
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ const json = await response.json();
86
+ if (json.error) {
87
+ throw new Error(`Natural England API error for ${layer.constraintType}: ${json.error.message ?? JSON.stringify(json.error)}`);
88
+ }
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ const features = json.features ?? [];
91
+ const expectedFields = [layer.nameField];
92
+ if (layer.areaField)
93
+ expectedFields.push(layer.areaField);
94
+ guardArcGisFields(features, expectedFields, `Natural England ${layer.constraintType.toUpperCase()}`);
95
+ return features.map((f) => {
96
+ const attrs = f.attributes ?? {};
97
+ const areaRaw = layer.areaField ? attrs[layer.areaField] : null;
98
+ return {
99
+ name: String(attrs[layer.nameField] ?? "Unknown"),
100
+ type: layer.constraintType,
101
+ area_ha: typeof areaRaw === "number" ? Math.round(areaRaw * 100) / 100 : null,
102
+ source: "natural-england",
103
+ };
104
+ });
105
+ }
@@ -0,0 +1,18 @@
1
+ export interface GspRegion {
2
+ gsp_id: string;
3
+ gsp_name: string;
4
+ region_id: string;
5
+ region_name: string;
6
+ }
7
+ export interface GspLookupResult extends GspRegion {
8
+ distance_km: number;
9
+ }
10
+ /**
11
+ * Find the nearest GSP to a given lat/lon using haversine distance.
12
+ * Returns the nearest GSP within `radiusKm` (default 50), or null if none found.
13
+ *
14
+ * This is a nearest-GSP approximation, not polygon containment.
15
+ */
16
+ export declare function lookupGspRegion(lat: number, lon: number, radiusKm?: number): Promise<GspLookupResult | null>;
17
+ /** Reset cache — exposed for tests. */
18
+ export declare function resetGspCacheForTests(): void;
@@ -0,0 +1,113 @@
1
+ import { TtlCache, TTL } from "./cache.js";
2
+ const cache = new TtlCache();
3
+ const NESO_GSP_CSV_URL = "https://api.neso.energy/dataset/2810092e-d4b2-472f-b955-d8bea01f9ec0/resource/bbe2cc72-a6c6-46e6-8f4e-48b879467368/download/gsp_gnode_directconnect_region_lookup.csv";
4
+ const CACHE_KEY = "neso-gsp-lookup:csv";
5
+ const DEFAULT_RADIUS_KM = 50;
6
+ /** Haversine distance in km between two WGS84 points. */
7
+ function haversineKm(lat1, lon1, lat2, lon2) {
8
+ const R = 6371;
9
+ const dLat = (lat2 - lat1) * (Math.PI / 180);
10
+ const dLon = (lon2 - lon1) * (Math.PI / 180);
11
+ const a = Math.sin(dLat / 2) ** 2 +
12
+ Math.cos(lat1 * (Math.PI / 180)) *
13
+ Math.cos(lat2 * (Math.PI / 180)) *
14
+ Math.sin(dLon / 2) ** 2;
15
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
16
+ }
17
+ /**
18
+ * Parse the NESO GSP-Gnode lookup CSV into structured records.
19
+ * Expects columns including: gsp_id, gsp_name, gsp_lat, gsp_lon, region_id, region_name.
20
+ * Column order is detected from the header row.
21
+ */
22
+ function parseCsv(csvText) {
23
+ const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0);
24
+ if (lines.length < 2)
25
+ return [];
26
+ const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
27
+ const colIndex = (name) => {
28
+ const idx = headers.indexOf(name);
29
+ return idx;
30
+ };
31
+ const iGspId = colIndex("gsp_id");
32
+ const iGspName = colIndex("gsp_name");
33
+ const iGspLat = colIndex("gsp_lat");
34
+ const iGspLon = colIndex("gsp_lon");
35
+ const iRegionId = colIndex("region_id");
36
+ const iRegionName = colIndex("region_name");
37
+ if (iGspId === -1 || iGspName === -1 || iGspLat === -1 || iGspLon === -1) {
38
+ throw new Error("NESO GSP CSV missing required columns. Expected: gsp_id, gsp_name, gsp_lat, gsp_lon. " +
39
+ `Found headers: ${headers.join(", ")}`);
40
+ }
41
+ const records = [];
42
+ const seen = new Set();
43
+ for (let i = 1; i < lines.length; i++) {
44
+ const cols = lines[i].split(",").map((c) => c.trim());
45
+ const gspId = cols[iGspId] ?? "";
46
+ const gspName = cols[iGspName] ?? "";
47
+ const lat = parseFloat(cols[iGspLat] ?? "");
48
+ const lon = parseFloat(cols[iGspLon] ?? "");
49
+ if (!gspId || Number.isNaN(lat) || Number.isNaN(lon))
50
+ continue;
51
+ // Deduplicate by gsp_id (multiple gnodes can map to the same GSP)
52
+ if (seen.has(gspId))
53
+ continue;
54
+ seen.add(gspId);
55
+ records.push({
56
+ gsp_id: gspId,
57
+ gsp_name: gspName,
58
+ lat,
59
+ lon,
60
+ region_id: iRegionId !== -1 ? (cols[iRegionId] ?? "") : "",
61
+ region_name: iRegionName !== -1 ? (cols[iRegionName] ?? "") : "",
62
+ });
63
+ }
64
+ return records;
65
+ }
66
+ async function fetchGspRecords() {
67
+ const cached = cache.get(CACHE_KEY);
68
+ if (cached)
69
+ return cached;
70
+ const response = await fetch(NESO_GSP_CSV_URL);
71
+ if (!response.ok) {
72
+ throw new Error(`NESO GSP lookup CSV fetch failed: HTTP ${response.status}`);
73
+ }
74
+ const csvText = await response.text();
75
+ const records = parseCsv(csvText);
76
+ if (records.length === 0) {
77
+ throw new Error("NESO GSP lookup CSV returned no valid records");
78
+ }
79
+ cache.set(CACHE_KEY, records, TTL.STATIC_DATA);
80
+ return records;
81
+ }
82
+ /**
83
+ * Find the nearest GSP to a given lat/lon using haversine distance.
84
+ * Returns the nearest GSP within `radiusKm` (default 50), or null if none found.
85
+ *
86
+ * This is a nearest-GSP approximation, not polygon containment.
87
+ */
88
+ export async function lookupGspRegion(lat, lon, radiusKm = DEFAULT_RADIUS_KM) {
89
+ const records = await fetchGspRecords();
90
+ let nearest = null;
91
+ let nearestDist = Infinity;
92
+ for (const record of records) {
93
+ const dist = haversineKm(lat, lon, record.lat, record.lon);
94
+ if (dist < nearestDist) {
95
+ nearestDist = dist;
96
+ nearest = record;
97
+ }
98
+ }
99
+ if (!nearest || nearestDist > radiusKm) {
100
+ return null;
101
+ }
102
+ return {
103
+ gsp_id: nearest.gsp_id,
104
+ gsp_name: nearest.gsp_name,
105
+ region_id: nearest.region_id,
106
+ region_name: nearest.region_name,
107
+ distance_km: Math.round(nearestDist * 100) / 100,
108
+ };
109
+ }
110
+ /** Reset cache — exposed for tests. */
111
+ export function resetGspCacheForTests() {
112
+ cache.clear();
113
+ }
@@ -0,0 +1,13 @@
1
+ /** Exposed for testing only. */
2
+ export declare function _getOverpassState(): Readonly<{
3
+ windowTimestamps: readonly number[];
4
+ inFlight: number;
5
+ queueLength: number;
6
+ maxConcurrent: number;
7
+ maxPerWindow: number;
8
+ windowMs: number;
9
+ requestTimeoutMs: number;
10
+ }>;
11
+ /** Exposed for testing: reset internal state between test runs. */
12
+ export declare function _resetOverpassState(): void;
13
+ export declare function fetchOverpassJson<T>(query: string): Promise<T>;