pvperfsolar performance modeling

TypeScript example

This script runs a small house and a 5 MWp tracker field through pvperf's REST API.

The script needs Node.js 22.18 or later and no package. Set the environment variable PVPERF_API_KEY to your key from the workbench's Account page, then run node pvperf_first_run.ts.

// A first run of a small house and of a tracker field through pvperf's REST API.
//
// Needs Node.js 22.18 or later, which runs a TypeScript file as it is.
// Set PVPERF_API_KEY to your key from the workbench's Account page, then run:
//
//     node pvperf_first_run.ts

const BASE_URL = process.env.PVPERF_BASE_URL ?? "https://pvperf.com";
const API_KEY = process.env.PVPERF_API_KEY ?? "";

interface MonthRow {
  month: number;
  e_grid_kwh: number;
}

interface LossRow {
  label: string;
  kind: string;
  value: number;
  unit: string;
}

interface Answer {
  summary: {
    p_nom_dc_kwp: number;
    e_grid_kwh: number;
    specific_yield_kwh_per_kwp: number;
    performance_ratio: number;
    monthly: MonthRow[];
  };
  loss_diagram: LossRow[];
  billing: { units: number; price_usd: number };
  request: { array: Record<string, unknown> } & Record<string, unknown>;
  series?: Record<string, number[]>;
}

/** POST a JSON body to pvperf and return the answer. A refused request throws with pvperf's
 * reason. A refused run is not charged. */
async function post(path: string, body: object): Promise<Answer> {
  const r = await fetch(BASE_URL + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const answer = await r.json();
  if (r.status >= 400) {
    const problem = answer as { code: string; detail: string };
    throw new Error(`${r.status} ${problem.code}: ${problem.detail}`);
  }
  return answer as Answer;
}

const n = (x: number, digits = 0) => x.toLocaleString("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits });

function show(name: string, answer: Answer): void {
  const s = answer.summary;
  console.log(`${name}: ${n(s.p_nom_dc_kwp, 1)} kWp, ${n(s.e_grid_kwh)} kWh to the grid a year`);
  console.log(`  specific yield ${n(s.specific_yield_kwh_per_kwp)} kWh/kWp`);
  console.log(`  performance ratio ${n(s.performance_ratio * 100, 1)}%`);
  const losses = answer.loss_diagram.filter((r) => r.kind === "loss" && r.unit === "kWh/kWp");
  for (const r of losses.sort((a, b) => a.value - b.value).slice(0, 3)) {
    console.log(`  ${r.label}: ${n(r.value, 1)} kWh/kWp`);
  }
  console.log(`  charged ${answer.billing.units} unit(s), USD ${answer.billing.price_usd.toFixed(3)}`);
}

async function main(): Promise<void> {
  if (!API_KEY) throw new Error("Set PVPERF_API_KEY to your pvperf API key.");

  // 1. A house: 16 modules of 400 W flush on a south-facing roof at 25° in Springfield, Illinois.
  //    The answer also carries the hourly power to the grid, p_grid_w, in W.
  const house = await post("/v1/run/simple", {
    latitude: 39.8,
    longitude: -89.65,
    racking: "rooftop",
    modules: 16,
    module_power_w: 400,
    tilt_deg: 25,
    azimuth_deg: 180,
    include_series: ["p_grid_w"],
  });
  show("House", house);
  for (const m of house.summary.monthly) {
    console.log(`  month ${String(m.month).padStart(2)}: ${n(m.e_grid_kwh).padStart(7)} kWh`);
  }
  const hourlyW = house.series?.p_grid_w ?? [];
  console.log(`  ${hourlyW.length} hourly values, highest ${n(Math.max(...hourlyW) / 1000, 2)} kW`);

  // 2. A tracker field: 5 MWp of single-axis trackers near Phoenix, Arizona.
  const field = await post("/v1/run/simple", {
    latitude: 33.45,
    longitude: -112.07,
    racking: "single_axis",
    system_capacity_kw: 5000,
  });
  show("Tracker field, ground cover ratio 0.40", field);

  // 3. The same field with the rows further apart: change the full request the simple run
  //    returned and send it to POST /v1/run.
  const request = field.request;
  request.array.gcr = 0.33;
  const wider = await post("/v1/run", request);
  show("Tracker field, ground cover ratio 0.33", wider);
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : err);
  process.exitCode = 1;
});

What it prints

The script makes three runs. Each answer's billing block gives the units and the price of its run.

The requests

A refused request answers with a problem document. Its code and detail say why, and field names the field to fix. A run that fails is not charged.

A run predicted to take more than 40 s answers 202 with status running. GET /v1/runs/{run_id} returns it when it is done. Each run of this script answers in under 15 s.

The OpenAPI document gives the types of every field and answer. npx openapi-typescript https://pvperf.com/openapi.json -o pvperf.d.ts writes them as TypeScript types. The Python example makes the same runs.