← back to Charge And Explore
backend/src/providers/nrel-station-provider.ts
94 lines
// NREL Alternative Fuel Stations adapter — the free, launch-region (US/Canada)
// charger source. Real HTTP; no browser, no secret beyond a free API key
// (falls back to NREL's public DEMO_KEY for smoke tests only).
//
// Every station carries provenance: NREL under its API terms, redistributable
// WITH attribution. We normalize but never erase the source record id/timestamp.
import type { GeoPoint } from "../core/types.ts";
import type {
ChargingStationProvider,
ProviderStation,
RouteCorridor,
} from "./provider.ts";
// developer.nrel.gov DNS was retired 2026-08-01; same API now lives at developer.nlr.gov.
const NREL_BASE = "https://developer.nlr.gov/api/alt-fuel-stations/v1";
export class NrelStationProvider implements ChargingStationProvider {
// NOTE: explicit fields + body assignment, NOT constructor parameter
// properties — Node's native type-stripping can't transform the latter.
private readonly apiKey: string;
private readonly fetchImpl: typeof fetch;
constructor(
apiKey: string = process.env.NREL_API_KEY ?? "DEMO_KEY",
fetchImpl: typeof fetch = fetch,
) {
this.apiKey = apiKey;
this.fetchImpl = fetchImpl;
}
async stationsNear(point: GeoPoint, radiusMeters: number): Promise<ProviderStation[]> {
const radiusMiles = Math.max(0.1, Math.min(500, radiusMeters / 1609.34));
const url =
`${NREL_BASE}/nearest.json?api_key=${encodeURIComponent(this.apiKey)}` +
`&latitude=${point.latitude}&longitude=${point.longitude}` +
`&radius=${radiusMiles.toFixed(2)}&fuel_type=ELEC&limit=25`;
const res = await this.fetchImpl(url);
if (!res.ok) {
throw new Error(`NREL nearest failed: ${res.status} ${res.statusText}`);
}
const body = (await res.json()) as { fuel_stations?: NrelStation[] };
const retrievedAt = new Date().toISOString();
return (body.fuel_stations ?? []).map((s) => this.normalize(s, retrievedAt));
}
// Corridor search is a v0.2 concern (needs the routing layer first).
async stationsAlong(_corridor: RouteCorridor): Promise<ProviderStation[]> {
throw new Error("NrelStationProvider.stationsAlong not implemented in v0.1");
}
private normalize(s: NrelStation, retrievedAt: string): ProviderStation {
const maxKw = maxDcPower(s.ev_dc_fast_num, s.ev_connector_types);
return {
id: `nrel:${s.id}`,
name: s.station_name ?? "Unknown station",
location: { latitude: s.latitude, longitude: s.longitude },
maxKw,
stallCount: s.ev_dc_fast_num ?? undefined,
network: s.ev_network ?? undefined,
provenance: {
providerId: "nrel",
providerRecordId: String(s.id),
sourceRetrievedAt: retrievedAt,
sourceEffectiveAt: s.updated_at ?? undefined,
confidence: 0.85,
licenseCode: "NREL-API-TERMS",
attributionText: "Charging data from the U.S. DOE/NREL Alternative Fuel Stations",
redistributionAllowed: true,
},
};
}
}
/** NREL doesn't return kW directly; infer a coarse ceiling from DC presence. */
function maxDcPower(dcCount: number | null, connectors: string[] | null): number | undefined {
if (!dcCount || dcCount <= 0) return undefined;
if (connectors?.includes("TESLA") || connectors?.includes("J3400")) return 250;
if (connectors?.includes("J1772COMBO")) return 150;
return 50; // conservative default for unspecified DC fast
}
interface NrelStation {
id: number;
station_name: string | null;
latitude: number;
longitude: number;
ev_network: string | null;
ev_dc_fast_num: number | null;
ev_connector_types: string[] | null;
updated_at: string | null;
}