← 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;
}