@uk-energy/neso 0.0.0-stage → 0.1.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.
@@ -0,0 +1,365 @@
1
+ import { RateLimit, HttpClient, HttpClientOptions, Query, DateInput } from '@uk-energy/core';
2
+
3
+ /**
4
+ * Shapes of the NESO Data Portal (CKAN 2.8) API and of the datasets this package wraps.
5
+ * Column names are the portal's own, spaces and all, so rows match what the SQL endpoint and
6
+ * the CSV downloads show. There is no OpenAPI document for the portal; these are hand-typed
7
+ * from recorded responses (fixtures/api.neso.energy) and checked by the tests.
8
+ *
9
+ * Supported by National Energy SO Open Data.
10
+ */
11
+ interface CkanSuccess<T> {
12
+ help: string;
13
+ success: true;
14
+ result: T;
15
+ }
16
+ interface CkanFailure {
17
+ help: string;
18
+ success: false;
19
+ error: {
20
+ message?: string;
21
+ __type?: string;
22
+ [field: string]: unknown;
23
+ };
24
+ }
25
+ type CkanResponse<T> = CkanSuccess<T> | CkanFailure;
26
+ interface CkanResource {
27
+ id: string;
28
+ package_id: string;
29
+ name: string;
30
+ description: string;
31
+ format: string;
32
+ url: string;
33
+ /** True when the resource is loaded into the datastore (CSV/XLSX pushed to Postgres). */
34
+ datastore_active: boolean;
35
+ last_modified: string | null;
36
+ created: string;
37
+ size: number | null;
38
+ position: number;
39
+ state: string;
40
+ mimetype: string | null;
41
+ }
42
+ interface CkanPackage {
43
+ id: string;
44
+ name: string;
45
+ title: string;
46
+ notes: string;
47
+ license_id: string;
48
+ license_title: string;
49
+ license_url?: string;
50
+ metadata_created: string;
51
+ metadata_modified: string;
52
+ num_resources: number;
53
+ organization: {
54
+ name: string;
55
+ title: string;
56
+ } | null;
57
+ resources: CkanResource[];
58
+ tags: {
59
+ name: string;
60
+ display_name: string;
61
+ }[];
62
+ extras: {
63
+ key: string;
64
+ value: string;
65
+ }[];
66
+ }
67
+ interface CkanPackageSearch {
68
+ count: number;
69
+ results: CkanPackage[];
70
+ }
71
+ /** Datastore column: `int4`, `numeric`, `text`, `date`, `timestamp`, `tsvector`… */
72
+ interface DatastoreField {
73
+ id: string;
74
+ type: string;
75
+ }
76
+ interface DatastoreSearchResult<Row = DatastoreRow> {
77
+ resource_id: string;
78
+ fields: DatastoreField[];
79
+ records: Row[];
80
+ total: number;
81
+ limit?: number;
82
+ offset?: number;
83
+ _links?: {
84
+ start?: string;
85
+ next?: string;
86
+ prev?: string;
87
+ };
88
+ }
89
+ interface DatastoreSqlResult<Row = DatastoreRow> {
90
+ sql: string;
91
+ fields: DatastoreField[];
92
+ records: Row[];
93
+ }
94
+ type DatastoreRow = Record<string, string | number | boolean | null>;
95
+ interface DatastoreSearchOptions {
96
+ /** Rows per request; the portal returns everything up to 32,000 in one page. */
97
+ limit?: number;
98
+ offset?: number;
99
+ /** e.g. `"_id"` or `"Settlement Date desc"`. */
100
+ sort?: string;
101
+ /** Equality filters, `{ "Constraint Group": "ESTEX" }`. */
102
+ filters?: Record<string, string | number | boolean | (string | number)[]>;
103
+ /** Columns to return. */
104
+ fields?: string[];
105
+ /** Full-text query. */
106
+ q?: string;
107
+ }
108
+ /** `thermal-constraint-costs`, one row per boundary per day. */
109
+ interface ThermalConstraintCostRow {
110
+ _id: number;
111
+ "Settlement Date": string;
112
+ "Constraint Group": string;
113
+ "Daily Cost (GBP)": number;
114
+ }
115
+ /** `constraint-breakdown`: daily cost (£) and volume (MWh) by constraint type. */
116
+ interface ConstraintBreakdownRow {
117
+ _id: number;
118
+ Date: string;
119
+ "Reducing largest loss cost": number;
120
+ "Increasing system inertia cost": number;
121
+ "Voltage constraints cost": number;
122
+ "Thermal constraints cost": number;
123
+ "Reducing largest loss volume": number;
124
+ "Increasing system inertia volume": number;
125
+ "Voltage constraints volume": number;
126
+ "Thermal constraints volume": number;
127
+ }
128
+ /** `24-months-ahead-constraint-cost-forecast`: £m per month. */
129
+ interface ConstraintForecastRow {
130
+ _id: number;
131
+ /** "Nov-26" */
132
+ Month: string;
133
+ "Constraint Cost": number;
134
+ }
135
+ /** `24-months-ahead-constraint-limits`: MW limit per boundary per week. Boundary columns vary. */
136
+ interface ConstraintLimitRow {
137
+ _id: number;
138
+ YEAR: number;
139
+ "Week No": number;
140
+ [boundary: string]: number | undefined;
141
+ }
142
+ /** `day-ahead-constraint-flows-and-limits`: half-hourly forecast flow vs limit per boundary. */
143
+ interface DayAheadFlowRow {
144
+ _id: number;
145
+ "Constraint Group": string;
146
+ /** Local time (GMT/BST), no offset: "2026-10-15T00:00:00". */
147
+ "Date_ Time GMT_BST": string;
148
+ Limit_MW: number;
149
+ Flow_MW: number;
150
+ }
151
+ /** `daily-balancing-costs-balancing-services-use-of-system`: £ per settlement period by category. */
152
+ interface DailyBalancingCostRow {
153
+ _id: number;
154
+ SETT_DATE: string;
155
+ SETT_PERIOD: number;
156
+ "Energy Imbalance": number;
157
+ "Frequency Control": number;
158
+ "Positive Reserve": number;
159
+ Constraints: number;
160
+ "Negative Reserve": number;
161
+ Other: number;
162
+ }
163
+ /** Demand Flexibility Service: a requirement published ahead of an event window. */
164
+ interface DfsRequirementRow {
165
+ _id: number;
166
+ "Event ID": number;
167
+ "DFS Submission Time_Local": string;
168
+ "Event Type": string;
169
+ "Event Tag": string;
170
+ "Delivery Date": string;
171
+ From_Local: string;
172
+ To_Local: string;
173
+ From_UTC: string;
174
+ To_UTC: string;
175
+ "Service Requirement MW": number;
176
+ "Service Requirement Type": string;
177
+ "Guaranteed Acceptance Price GBP per MWh": number;
178
+ "Dispatch Type": string;
179
+ "Participant Bids Eligible": string;
180
+ "Zonal Cap": string;
181
+ }
182
+ /** DFS utilisation: one row per participant unit per event window. */
183
+ interface DfsUtilisationRow {
184
+ _id: number;
185
+ "Event ID": number;
186
+ "Event Type": string;
187
+ "Event Tag": string;
188
+ "Delivery Date": string;
189
+ From_Local: string;
190
+ To_Local: string;
191
+ From_UTC: string;
192
+ To_UTC: string;
193
+ "Registered DFS Participant": string;
194
+ "DFS Unit ID": string;
195
+ Zone: number;
196
+ "DFS Procured MW": number;
197
+ "Service Requirement Type": string;
198
+ "Utilisation Price GBP per MWh": number;
199
+ Status: string;
200
+ }
201
+ /** DFS utilisation summary: one row per event window. */
202
+ interface DfsSummaryRow {
203
+ _id: number;
204
+ "Event ID": number;
205
+ "Event Type": string;
206
+ "Event Tag": string;
207
+ "Delivery Date": string;
208
+ From_Local: string;
209
+ To_Local: string;
210
+ From_UTC: string;
211
+ To_UTC: string;
212
+ "Service Requirement Type": string;
213
+ "Service Requirement MW": number;
214
+ "DFS Procured MW": number;
215
+ "DFS Provider Bids Accepted Total Cost GBP": number;
216
+ "Settled Volume MW": number | null;
217
+ "Settled Cost GBP": number | null;
218
+ }
219
+
220
+ /**
221
+ * Client for the NESO Data Portal (https://www.neso.energy/data-portal), a CKAN 2.8 instance at
222
+ * https://api.neso.energy/api/3/action/. No API key. Datasets ("packages") hold "resources";
223
+ * tabular resources are loaded into a Postgres datastore that `datastore_search` and
224
+ * `datastore_search_sql` query. Many datasets split one resource per GB financial year
225
+ * (April–March), so the helpers resolve the right resource(s) for a date range.
226
+ *
227
+ * const neso = new NesoClient();
228
+ * const costs = await neso.constraints.thermalCosts("2025-04-01", "2025-04-07"); // £/day per boundary
229
+ * const dfs = await neso.dfs.summary("2026-10-20", "2026-10-22");
230
+ * const rows = await neso.ckan.sql(`SELECT * FROM "407f3e9d-…" WHERE "Settlement Date" = '2025-04-01'`);
231
+ *
232
+ * Licence: National Energy SO Open Data Licence v1.0 (OGL v3 based): copy, adapt, use
233
+ * commercially, with the attribution "Supported by National Energy SO Open Data".
234
+ */
235
+
236
+ declare const BASE_URL = "https://api.neso.energy/api/3/action";
237
+ /**
238
+ * No limit is published; the licence only reserves the right to throttle "excessive use".
239
+ * 5 rps / burst 10 is comfortably polite for a shared public CKAN.
240
+ */
241
+ declare const DEFAULT_RATE_LIMIT: RateLimit;
242
+ /** Resources update daily or weekly; package metadata is memoised for this long. */
243
+ declare const DEFAULT_CACHE_TTL_MS: number;
244
+ /** The portal returns up to this many rows per `datastore_search` page. */
245
+ declare const MAX_PAGE_SIZE = 32000;
246
+ /** Dataset (package) names on the portal that the helpers use. */
247
+ declare const PACKAGES: {
248
+ readonly thermalConstraintCosts: "thermal-constraint-costs";
249
+ readonly constraintBreakdown: "constraint-breakdown";
250
+ readonly constraintCostForecast24m: "24-months-ahead-constraint-cost-forecast";
251
+ readonly constraintLimits24m: "24-months-ahead-constraint-limits";
252
+ readonly dayAheadConstraintFlows: "day-ahead-constraint-flows-and-limits";
253
+ readonly dailyBalancingCosts: "daily-balancing-costs-balancing-services-use-of-system";
254
+ readonly demandFlexibility: "demand-flexibility";
255
+ };
256
+ interface NesoClientOptions extends Omit<HttpClientOptions, "baseUrl"> {
257
+ baseUrl?: string;
258
+ }
259
+ /**
260
+ * The SQL endpoint returns `numeric` columns as strings ("252"); `datastore_search` returns
261
+ * numbers. Make rows consistent: numeric-typed columns become numbers, `_full_text` goes.
262
+ */
263
+ declare function normaliseRows<Row extends object>(records: readonly DatastoreRow[], fields: readonly DatastoreField[], extraNumeric?: readonly string[]): Row[];
264
+ /** Double-quote an identifier for the SQL endpoint. */
265
+ declare function ident(name: string): string;
266
+ /** Single-quote a literal for the SQL endpoint. */
267
+ declare function literal(value: string | number): string;
268
+ /**
269
+ * `SELECT * FROM "resource" WHERE "col" >= 'from' AND "col" <= 'to' ORDER BY …` — the shape
270
+ * every date-range helper uses. `toExclusive` switches the upper bound to `<` for timestamps.
271
+ */
272
+ declare function rangeSql(resourceId: string, column: string, from: string, to: string, orderBy: readonly string[], opts?: {
273
+ toExclusive?: boolean;
274
+ where?: string | undefined;
275
+ }): string;
276
+ declare class NesoClient {
277
+ readonly http: HttpClient;
278
+ private readonly packageCache;
279
+ private readonly packageTtlMs;
280
+ constructor(opts?: NesoClientOptions);
281
+ /** Any CKAN action; unwraps `{ success, result }` and throws `UkEnergyError` on `success: false`. */
282
+ action<T>(name: string, query?: Query): Promise<T>;
283
+ /** Raw CKAN API. */
284
+ readonly ckan: {
285
+ /** Dataset metadata and its resources (memoised for `cacheTtlMs`, default 10 minutes). */
286
+ packageShow: (id: string) => Promise<CkanPackage>;
287
+ /** Search datasets by free text. */
288
+ packageSearch: (q: string, rows?: number) => Promise<CkanPackageSearch>;
289
+ resourceShow: (id: string) => Promise<CkanResource>;
290
+ /** One page of a datastore resource (up to 32,000 rows). Rows are normalised (see `normaliseRows`). */
291
+ datastoreSearch: <Row extends object = DatastoreRow>(resourceId: string, opts?: DatastoreSearchOptions) => Promise<DatastoreSearchResult<Row>>;
292
+ /** Every row of a datastore resource, paging by `limit` (default 32,000). */
293
+ datastoreAll: <Row extends object = DatastoreRow>(resourceId: string, opts?: Omit<DatastoreSearchOptions, "offset">) => Promise<Row[]>;
294
+ /**
295
+ * `datastore_search_sql`: read-only PostgreSQL over the datastore. Resource ids are the
296
+ * table names (quote them). Numeric columns come back as strings from this endpoint; they
297
+ * are converted using the `fields` types, and `_full_text` is dropped.
298
+ */
299
+ sql: <Row extends object = DatastoreRow>(sql: string, extraNumeric?: readonly string[]) => Promise<Row[]>;
300
+ };
301
+ /** Resolving resources inside datasets. */
302
+ readonly resources: {
303
+ /** All datastore-backed resources of a dataset. */
304
+ datastore: (packageId: string) => Promise<CkanResource[]>;
305
+ /** The resource for a GB financial year (`2025` = April 2025 – March 2026), by name. */
306
+ forFinancialYear: (packageId: string, startYear: number) => Promise<CkanResource>;
307
+ /** The datastore resource whose name matches exactly (or the regex), e.g. "DFS Utilisation Report". */
308
+ byName: (packageId: string, name: string | RegExp) => Promise<CkanResource>;
309
+ };
310
+ /** Rows of a per-financial-year dataset for an inclusive date range, across year boundaries. */
311
+ private byFinancialYear;
312
+ /** Transmission constraint costs and forecasts. */
313
+ readonly constraints: {
314
+ /**
315
+ * Thermal constraint cost per boundary per day (£), `thermal-constraint-costs`. One row per
316
+ * constraint group per settlement date, e.g. SCOTEX, SSHARN, SEIMP (Scottish export
317
+ * boundaries), ESTEX, SWALEX.
318
+ */
319
+ thermalCosts: (from: DateInput, to: DateInput, constraintGroup?: string) => Promise<ThermalConstraintCostRow[]>;
320
+ /**
321
+ * Daily constraint cost (£) and volume (MWh) by type: thermal, voltage, inertia, largest
322
+ * loss (`constraint-breakdown`). Thermal volume is negative (energy turned down).
323
+ */
324
+ breakdown: (from: DateInput, to: DateInput) => Promise<ConstraintBreakdownRow[]>;
325
+ /** NESO's 24-month-ahead constraint cost forecast, £m per month (24 rows). */
326
+ forecast24m: () => Promise<ConstraintForecastRow[]>;
327
+ /** 24-month-ahead boundary limits (MW) per week; boundary columns vary by publication. */
328
+ limits24m: () => Promise<ConstraintLimitRow[]>;
329
+ /**
330
+ * Day-ahead forecast flow vs limit per boundary, half-hourly, for a range of local dates
331
+ * (`day-ahead-constraint-flows-and-limits`, ~750k rows, so always filtered by SQL).
332
+ */
333
+ dayAheadFlows: (from: DateInput, to: DateInput, constraintGroup?: string) => Promise<DayAheadFlowRow[]>;
334
+ /** Daily balancing (BSUoS) costs per settlement period by category, incl. `Constraints` (£). */
335
+ dailyBalancingCosts: (from: DateInput, to: DateInput) => Promise<DailyBalancingCostRow[]>;
336
+ };
337
+ /** Demand Flexibility Service (current season; archives are separate resources). */
338
+ readonly dfs: {
339
+ /** Published requirements per event window, by delivery date. */
340
+ requirements: (from: DateInput, to: DateInput) => Promise<DfsRequirementRow[]>;
341
+ /** Accepted/rejected bids per participant unit per event window. */
342
+ utilisation: (from: DateInput, to: DateInput) => Promise<DfsUtilisationRow[]>;
343
+ /** Procured MW, accepted cost and settled volume/cost per event window. */
344
+ summary: (from: DateInput, to: DateInput) => Promise<DfsSummaryRow[]>;
345
+ };
346
+ private dfsRange;
347
+ }
348
+
349
+ /**
350
+ * Financial-year arithmetic and resource-name matching for datasets split one resource per
351
+ * GB financial year (1 April – 31 March): "Thermal Constraint Costs Data 25-26",
352
+ * "Constraint Breakdown 2025-2026", "Daily Balancing Costs 2025-2026".
353
+ */
354
+
355
+ /** Financial year a date ("YYYY-MM-DD") falls in, as its starting calendar year. */
356
+ declare function financialYearOf(date: string): number;
357
+ /** Starting years of every financial year touched by an inclusive date range. */
358
+ declare function financialYearsBetween(from: string, to: string): number[];
359
+ /** The financial year a resource name refers to ("25-26" / "2025-2026" → 2025), or undefined. */
360
+ declare function financialYearInName(name: string): number | undefined;
361
+ /** The datastore resource of a dataset for one financial year, if any. */
362
+ declare function resourceForFinancialYear(resources: readonly CkanResource[], startYear: number): CkanResource | undefined;
363
+
364
+ export { BASE_URL, DEFAULT_CACHE_TTL_MS, DEFAULT_RATE_LIMIT, MAX_PAGE_SIZE, NesoClient, PACKAGES, financialYearInName, financialYearOf, financialYearsBetween, ident, literal, normaliseRows, rangeSql, resourceForFinancialYear };
365
+ export type { CkanFailure, CkanPackage, CkanPackageSearch, CkanResource, CkanResponse, CkanSuccess, ConstraintBreakdownRow, ConstraintForecastRow, ConstraintLimitRow, DailyBalancingCostRow, DatastoreField, DatastoreRow, DatastoreSearchOptions, DatastoreSearchResult, DatastoreSqlResult, DayAheadFlowRow, DfsRequirementRow, DfsSummaryRow, DfsUtilisationRow, NesoClientOptions, ThermalConstraintCostRow };
package/dist/index.js ADDED
@@ -0,0 +1,310 @@
1
+ import { HttpClient, UkEnergyError, pages, offsetCursor, londonDate } from '@uk-energy/core';
2
+
3
+ // src/client.ts
4
+
5
+ // src/resources.ts
6
+ function financialYearOf(date) {
7
+ const year = Number(date.slice(0, 4));
8
+ const month = Number(date.slice(5, 7));
9
+ return month >= 4 ? year : year - 1;
10
+ }
11
+ function financialYearsBetween(from, to) {
12
+ const first = financialYearOf(from);
13
+ const last = financialYearOf(to);
14
+ const out = [];
15
+ for (let y = first; y <= last; y++) out.push(y);
16
+ return out;
17
+ }
18
+ var YEAR_SPAN = /(?<!\d)(\d{4}|\d{2})\s*[-/–]\s*(\d{4}|\d{2})(?!\d)/g;
19
+ function financialYearInName(name) {
20
+ for (const m of name.matchAll(YEAR_SPAN)) {
21
+ if (!m[1] || !m[2]) continue;
22
+ const start = m[1].length === 2 ? 2e3 + Number(m[1]) : Number(m[1]);
23
+ const end = m[2].length === 2 ? 2e3 + Number(m[2]) : Number(m[2]);
24
+ if (end === start + 1) return start;
25
+ }
26
+ return void 0;
27
+ }
28
+ function resourceForFinancialYear(resources, startYear) {
29
+ return resources.find((r) => r.datastore_active && financialYearInName(r.name) === startYear);
30
+ }
31
+
32
+ // src/client.ts
33
+ var BASE_URL = "https://api.neso.energy/api/3/action";
34
+ var DEFAULT_RATE_LIMIT = { requestsPerSecond: 5, burst: 10 };
35
+ var DEFAULT_CACHE_TTL_MS = 10 * 60 * 1e3;
36
+ var MAX_PAGE_SIZE = 32e3;
37
+ var PACKAGES = {
38
+ thermalConstraintCosts: "thermal-constraint-costs",
39
+ constraintBreakdown: "constraint-breakdown",
40
+ constraintCostForecast24m: "24-months-ahead-constraint-cost-forecast",
41
+ constraintLimits24m: "24-months-ahead-constraint-limits",
42
+ dayAheadConstraintFlows: "day-ahead-constraint-flows-and-limits",
43
+ dailyBalancingCosts: "daily-balancing-costs-balancing-services-use-of-system",
44
+ demandFlexibility: "demand-flexibility"
45
+ };
46
+ var DECIMAL = /^-?\d+(\.\d+)?([eE][-+]?\d+)?$/;
47
+ var NUMERIC_TYPES = /* @__PURE__ */ new Set([
48
+ "numeric",
49
+ "int4",
50
+ "int8",
51
+ "float8",
52
+ "float4",
53
+ "int",
54
+ "double precision"
55
+ ]);
56
+ function normaliseRows(records, fields, extraNumeric = []) {
57
+ const numeric = new Set(extraNumeric);
58
+ for (const f of fields) if (NUMERIC_TYPES.has(f.type)) numeric.add(f.id);
59
+ return records.map((r) => {
60
+ const out = {};
61
+ for (const [k, v] of Object.entries(r)) {
62
+ if (k === "_full_text") continue;
63
+ if (numeric.has(k) && typeof v === "string" && DECIMAL.test(v.trim())) {
64
+ const n = Number(v);
65
+ out[k] = Number.isFinite(n) ? n : v;
66
+ } else {
67
+ out[k] = v;
68
+ }
69
+ }
70
+ return out;
71
+ });
72
+ }
73
+ function ident(name) {
74
+ return `"${name.replace(/"/g, '""')}"`;
75
+ }
76
+ function literal(value) {
77
+ return typeof value === "number" ? String(value) : `'${value.replace(/'/g, "''")}'`;
78
+ }
79
+ function rangeSql(resourceId, column, from, to, orderBy, opts = {}) {
80
+ const upper = opts.toExclusive ? "<" : "<=";
81
+ const extra = opts.where ? ` AND ${opts.where}` : "";
82
+ const order = orderBy.length ? ` ORDER BY ${orderBy.map(ident).join(", ")}` : "";
83
+ return `SELECT * FROM ${ident(resourceId)} WHERE ${ident(column)} >= ${literal(from)} AND ${ident(column)} ${upper} ${literal(to)}${extra}${order}`;
84
+ }
85
+ function localRange(from, to) {
86
+ const f = londonDate(from);
87
+ const t = londonDate(to);
88
+ if (f > t) throw new UkEnergyError(`from (${f}) is after to (${t})`);
89
+ return [f, t];
90
+ }
91
+ function nextDay(date) {
92
+ const d = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
93
+ d.setUTCDate(d.getUTCDate() + 1);
94
+ return d.toISOString().slice(0, 10);
95
+ }
96
+ var NesoClient = class {
97
+ http;
98
+ packageCache = /* @__PURE__ */ new Map();
99
+ packageTtlMs;
100
+ constructor(opts = {}) {
101
+ this.http = new HttpClient({
102
+ baseUrl: BASE_URL,
103
+ rateLimit: DEFAULT_RATE_LIMIT,
104
+ cacheTtlMs: opts.cache ? DEFAULT_CACHE_TTL_MS : 0,
105
+ ...opts
106
+ });
107
+ this.packageTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
108
+ }
109
+ /** Any CKAN action; unwraps `{ success, result }` and throws `UkEnergyError` on `success: false`. */
110
+ async action(name, query) {
111
+ const res = await this.http.get(name, query);
112
+ if (!res || typeof res !== "object" || !("success" in res)) {
113
+ throw new UkEnergyError(`Unexpected response shape for ${name}`);
114
+ }
115
+ if (!res.success) {
116
+ throw new UkEnergyError(`${name}: ${res.error?.message ?? JSON.stringify(res.error)}`);
117
+ }
118
+ return res.result;
119
+ }
120
+ /** Raw CKAN API. */
121
+ ckan = {
122
+ /** Dataset metadata and its resources (memoised for `cacheTtlMs`, default 10 minutes). */
123
+ packageShow: async (id) => {
124
+ const hit = this.packageCache.get(id);
125
+ if (hit && Date.now() - hit.at < this.packageTtlMs) return hit.value;
126
+ const value = this.action("package_show", { id }).then((pkg) => {
127
+ if (!pkg || !Array.isArray(pkg.resources)) {
128
+ throw new UkEnergyError(`package_show: unexpected shape for ${id}`);
129
+ }
130
+ return pkg;
131
+ });
132
+ this.packageCache.set(id, { at: Date.now(), value });
133
+ value.catch(() => this.packageCache.delete(id));
134
+ return value;
135
+ },
136
+ /** Search datasets by free text. */
137
+ packageSearch: async (q, rows = 20) => this.action("package_search", { q, rows }),
138
+ resourceShow: async (id) => this.action("resource_show", { id }),
139
+ /** One page of a datastore resource (up to 32,000 rows). Rows are normalised (see `normaliseRows`). */
140
+ datastoreSearch: async (resourceId, opts = {}) => {
141
+ const query = {
142
+ resource_id: resourceId,
143
+ limit: opts.limit ?? MAX_PAGE_SIZE,
144
+ sort: opts.sort ?? "_id"
145
+ };
146
+ if (opts.offset !== void 0) query.offset = opts.offset;
147
+ if (opts.filters) query.filters = JSON.stringify(opts.filters);
148
+ if (opts.fields) query.fields = opts.fields.join(",");
149
+ if (opts.q) query.q = opts.q;
150
+ const res = await this.action("datastore_search", query);
151
+ if (!Array.isArray(res?.records)) {
152
+ throw new UkEnergyError("Unexpected response shape for datastore_search");
153
+ }
154
+ return { ...res, records: normaliseRows(res.records, res.fields ?? []) };
155
+ },
156
+ /** Every row of a datastore resource, paging by `limit` (default 32,000). */
157
+ datastoreAll: async (resourceId, opts = {}) => {
158
+ const pageSize = Math.min(opts.limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE);
159
+ const out = [];
160
+ for await (const page of pages({
161
+ fetchPage: (offset) => this.ckan.datastoreSearch(resourceId, {
162
+ ...opts,
163
+ limit: pageSize,
164
+ offset: offset ?? 0
165
+ }),
166
+ items: (p) => p.records,
167
+ nextCursor: offsetCursor(pageSize, (p) => p.records.length)
168
+ })) {
169
+ out.push(...page.records);
170
+ }
171
+ return out;
172
+ },
173
+ /**
174
+ * `datastore_search_sql`: read-only PostgreSQL over the datastore. Resource ids are the
175
+ * table names (quote them). Numeric columns come back as strings from this endpoint; they
176
+ * are converted using the `fields` types, and `_full_text` is dropped.
177
+ */
178
+ sql: async (sql, extraNumeric = []) => {
179
+ const res = await this.action("datastore_search_sql", {
180
+ sql
181
+ });
182
+ if (!Array.isArray(res?.records)) {
183
+ throw new UkEnergyError("Unexpected response shape for datastore_search_sql");
184
+ }
185
+ return normaliseRows(res.records, res.fields ?? [], extraNumeric);
186
+ }
187
+ };
188
+ /** Resolving resources inside datasets. */
189
+ resources = {
190
+ /** All datastore-backed resources of a dataset. */
191
+ datastore: async (packageId) => (await this.ckan.packageShow(packageId)).resources.filter((r) => r.datastore_active),
192
+ /** The resource for a GB financial year (`2025` = April 2025 – March 2026), by name. */
193
+ forFinancialYear: async (packageId, startYear) => {
194
+ const pkg = await this.ckan.packageShow(packageId);
195
+ const r = resourceForFinancialYear(pkg.resources, startYear);
196
+ if (!r) {
197
+ throw new UkEnergyError(
198
+ `${packageId}: no datastore resource for financial year ${startYear}/${(startYear + 1) % 100}`
199
+ );
200
+ }
201
+ return r;
202
+ },
203
+ /** The datastore resource whose name matches exactly (or the regex), e.g. "DFS Utilisation Report". */
204
+ byName: async (packageId, name) => {
205
+ const list = await this.resources.datastore(packageId);
206
+ const r = list.find(
207
+ (x) => typeof name === "string" ? x.name.trim() === name.trim() : name.test(x.name)
208
+ );
209
+ if (!r) throw new UkEnergyError(`${packageId}: no datastore resource named ${String(name)}`);
210
+ return r;
211
+ }
212
+ };
213
+ /** Rows of a per-financial-year dataset for an inclusive date range, across year boundaries. */
214
+ async byFinancialYear(packageId, column, range, orderBy, opts = {}) {
215
+ const [from, to] = localRange(range.from, range.to);
216
+ const out = [];
217
+ for (const fy of financialYearsBetween(from, to)) {
218
+ const resource = await this.resources.forFinancialYear(packageId, fy);
219
+ const sql = rangeSql(resource.id, column, from, to, orderBy, { where: opts.where });
220
+ out.push(...await this.ckan.sql(sql, opts.extraNumeric));
221
+ }
222
+ return out;
223
+ }
224
+ /** Transmission constraint costs and forecasts. */
225
+ constraints = {
226
+ /**
227
+ * Thermal constraint cost per boundary per day (£), `thermal-constraint-costs`. One row per
228
+ * constraint group per settlement date, e.g. SCOTEX, SSHARN, SEIMP (Scottish export
229
+ * boundaries), ESTEX, SWALEX.
230
+ */
231
+ thermalCosts: async (from, to, constraintGroup) => this.byFinancialYear(
232
+ PACKAGES.thermalConstraintCosts,
233
+ "Settlement Date",
234
+ { from, to },
235
+ ["Settlement Date", "Constraint Group"],
236
+ constraintGroup ? { where: `${ident("Constraint Group")} = ${literal(constraintGroup)}` } : {}
237
+ ),
238
+ /**
239
+ * Daily constraint cost (£) and volume (MWh) by type: thermal, voltage, inertia, largest
240
+ * loss (`constraint-breakdown`). Thermal volume is negative (energy turned down).
241
+ */
242
+ breakdown: async (from, to) => this.byFinancialYear(PACKAGES.constraintBreakdown, "Date", { from, to }, ["Date"], {
243
+ // "Thermal constraints cost" is typed text in some years
244
+ extraNumeric: ["Thermal constraints cost"]
245
+ }),
246
+ /** NESO's 24-month-ahead constraint cost forecast, £m per month (24 rows). */
247
+ forecast24m: async () => {
248
+ const r = await this.resources.byName(PACKAGES.constraintCostForecast24m, /Forecast/);
249
+ return this.ckan.datastoreAll(r.id);
250
+ },
251
+ /** 24-month-ahead boundary limits (MW) per week; boundary columns vary by publication. */
252
+ limits24m: async () => {
253
+ const r = await this.resources.byName(PACKAGES.constraintLimits24m, /limits/i);
254
+ return this.ckan.datastoreAll(r.id);
255
+ },
256
+ /**
257
+ * Day-ahead forecast flow vs limit per boundary, half-hourly, for a range of local dates
258
+ * (`day-ahead-constraint-flows-and-limits`, ~750k rows, so always filtered by SQL).
259
+ */
260
+ dayAheadFlows: async (from, to, constraintGroup) => {
261
+ const [f, t] = localRange(from, to);
262
+ const r = await this.resources.byName(PACKAGES.dayAheadConstraintFlows, /Flows and Limits/);
263
+ const sql = rangeSql(
264
+ r.id,
265
+ "Date_ Time GMT_BST",
266
+ `${f}T00:00:00`,
267
+ `${nextDay(t)}T00:00:00`,
268
+ ["Constraint Group", "Date_ Time GMT_BST"],
269
+ {
270
+ toExclusive: true,
271
+ where: constraintGroup ? `${ident("Constraint Group")} = ${literal(constraintGroup)}` : void 0
272
+ }
273
+ );
274
+ return this.ckan.sql(sql);
275
+ },
276
+ /** Daily balancing (BSUoS) costs per settlement period by category, incl. `Constraints` (£). */
277
+ dailyBalancingCosts: async (from, to) => this.byFinancialYear(PACKAGES.dailyBalancingCosts, "SETT_DATE", { from, to }, [
278
+ "SETT_DATE",
279
+ "SETT_PERIOD"
280
+ ])
281
+ };
282
+ /** Demand Flexibility Service (current season; archives are separate resources). */
283
+ dfs = {
284
+ /** Published requirements per event window, by delivery date. */
285
+ requirements: async (from, to) => this.dfsRange("DFS Service Requirement", from, to, [
286
+ "Event ID",
287
+ "From_UTC"
288
+ ]),
289
+ /** Accepted/rejected bids per participant unit per event window. */
290
+ utilisation: async (from, to) => this.dfsRange("DFS Utilisation Report", from, to, [
291
+ "Event ID",
292
+ "From_UTC",
293
+ "DFS Unit ID"
294
+ ]),
295
+ /** Procured MW, accepted cost and settled volume/cost per event window. */
296
+ summary: async (from, to) => this.dfsRange("DFS Utilisation Report Summary", from, to, [
297
+ "Event ID",
298
+ "From_UTC"
299
+ ])
300
+ };
301
+ async dfsRange(name, from, to, orderBy) {
302
+ const [f, t] = localRange(from, to);
303
+ const r = await this.resources.byName(PACKAGES.demandFlexibility, name);
304
+ return this.ckan.sql(rangeSql(r.id, "Delivery Date", f, t, orderBy));
305
+ }
306
+ };
307
+
308
+ export { BASE_URL, DEFAULT_CACHE_TTL_MS, DEFAULT_RATE_LIMIT, MAX_PAGE_SIZE, NesoClient, PACKAGES, financialYearInName, financialYearOf, financialYearsBetween, ident, literal, normaliseRows, rangeSql, resourceForFinancialYear };
309
+ //# sourceMappingURL=index.js.map
310
+ //# sourceMappingURL=index.js.map