Functional Weave
Code in Rust

energy.tariff-time-of-use@1.0.1

impl/typescript.ts

3,621 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type RoundingMode, roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type HalfHourUsage, type TariffRate, type TimeOfUseCost } from "./energy_tariff_time_of_use_types.ts";

const SLOT = /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):(00|30)$/;
const CLOCK = /^([01]\d|2[0-3]):(00|30)$/;
// watt-hours x thousandths of a minor unit per kWh is millionths of a minor unit.
const DIVISOR = 1_000_000;

function covers(rate: TariffRate, start: string): boolean {
  if (rate.start.length === 5) {
    const time = start.slice(11);
    if (rate.start === rate.end) return true;
    if (rate.start < rate.end) return rate.start <= time && time < rate.end;
    return time >= rate.start || time < rate.end;
  }
  return rate.start <= start && start < rate.end;
}

/**
 * Cost half-hourly usage against time-of-use rates. Each half hour must fall
 * in exactly one rate; each rate name becomes a line costed exactly and
 * rounded once.
 */
export function timeOfUseCost(
  usage: readonly HalfHourUsage[],
  rates: readonly TariffRate[],
  currency: string,
  mode: RoundingMode
): TimeOfUseCost {
  const names: string[] = [];
  for (const r of rates) {
    const daily = CLOCK.test(r.start) && CLOCK.test(r.end);
    const dated = SLOT.test(r.start) && SLOT.test(r.end);
    if (!daily && !dated) {
      throw new RangeError(
        `rate ${r.name}: start and end must both be HH:MM or both YYYY-MM-DDTHH:MM on the hour or half hour, received ${r.start} and ${r.end}`
      );
    }
    if (dated && r.start >= r.end) {
      throw new RangeError(`rate ${r.name}: start must be before end, received ${r.start} and ${r.end}`);
    }
    if (!Number.isInteger(r.rate)) {
      throw new RangeError(`rate ${r.name}: rate must be a whole number of thousandths of a minor unit, received ${r.rate}`);
    }
    if (!names.includes(r.name)) names.push(r.name);
  }
  const exact = new Map<string, number>(names.map((n) => [n, 0]));
  const energy = new Map<string, number>(names.map((n) => [n, 0]));
  const seen = new Set<string>();
  let wattHours = 0;
  for (const u of usage) {
    if (!SLOT.test(u.start)) {
      throw new RangeError(`usage start must be YYYY-MM-DDTHH:MM on the hour or half hour, received ${u.start}`);
    }
    if (!Number.isInteger(u.wattHours) || u.wattHours < 0) {
      throw new RangeError(`usage wattHours must be a whole number of 0 or more, received ${u.wattHours} at ${u.start}`);
    }
    if (seen.has(u.start)) {
      throw new RangeError(`usage for the half hour starting ${u.start} appears twice`);
    }
    seen.add(u.start);
    const matches = rates.filter((r) => covers(r, u.start));
    if (matches.length === 0) {
      throw new RangeError(`no rate covers the half hour starting ${u.start}`);
    }
    if (matches.length > 1) {
      throw new RangeError(`rates ${matches[0].name} and ${matches[1].name} both cover the half hour starting ${u.start}`);
    }
    const r = matches[0];
    const next = exact.get(r.name)! + u.wattHours * r.rate;
    if (Math.abs(next) > Number.MAX_SAFE_INTEGER || Math.abs(u.wattHours * r.rate) > Number.MAX_SAFE_INTEGER) {
      throw new RangeError("usage cost too large to calculate exactly");
    }
    exact.set(r.name, next);
    energy.set(r.name, energy.get(r.name)! + u.wattHours);
    wattHours += u.wattHours;
  }
  let total = 0;
  const lines = names.map((name) => {
    const minor = roundDiv(exact.get(name)!, DIVISOR, mode);
    total += minor;
    return { name, wattHours: energy.get(name)!, cost: money(minor, currency) };
  });
  return { lines, wattHours, total: money(total, currency) };
}