view src/FdElectricity.ts @ 21:a90cb6927c7d default tip

fix countdown queries. Display "now" instead of "In -0.4 hours"
author drewp@bigasterisk.com
date Sat, 07 Sep 2024 17:47:36 -0700
parents e8c90d893919
children
line wrap: on
line source

import { LitElement, PropertyValues, TemplateResult, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { shared } from "./shared";

const HR = 3600;
const off_peak_wh_cost = 0.27 / 1000;
const on_peak_wh_cost = 0.312 / 1000;

@customElement("fd-electricity")
export class FdElectricity extends LitElement {
  static styles = [
    shared,
    css`
      :host {
        font-size: 14px;
      }
      .unit {
        color: #6c7d6c;
        font-family: monospace; /* for dollar signs */
      }
      :host > table {
        margin-top: 10px;
      }
      th,
      td {
        padding: 0 0;
        white-space: nowrap;
      }
      th {
        text-align: left;
      }
      td {
        text-align: right;
      }
      tr.total td,
      tr.total th {
        border-top: 1px solid #6c7d6c;
      }
      .bar {
        background: yellow;
        display: inline-block;
        height: 10px;
        margin-left: 5px;
      }
      .bar > div {
        transition: width 3s ease-out;
      }
      .total .bar {
        background: pink;
      }
      table {
        border-collapse: collapse;
      }
      .summary tr:first-of-type {
        opacity: .7;
      }
      .summary td {
        padding: 2px 6px;
      }
      .summary td,
      .summary th {
        border: 1px solid #2f2f2f;
      }
      .summary td.odometer {
        width: 3.4em;
        text-align: left;
      }
    `,
  ];
  @state() seriesData: Map<string, number> = new Map();
  @state() isPeak = false;
  @state() yesterday_charge = 0; // $$
  @state() today_charge = 0; // $$
  @state() today_peak_penalty = 0; // $$
  @state() yesterday_peak_penalty = 0; // $$
  @state() lastLoad = 0; // unix secs

  constructor() {
    super();
    this.load();
    setInterval(this.load.bind(this), 8 * 1000);
    setInterval(this.computeLiveState.bind(this), 500);
  }

  async load() {
    const seriesData = new Map();
    const base = "https://bigasterisk.com/m/vmselect/select/0/prometheus/api/v1/query";
    const { t, day0, day1, hoursSinceMidnight, hours } = this.timeMath();

    const seriesUrls = [
      { url: base + `?query=powermeter_w&time=${t}`, label: "powermeter" },
      { url: base + `?query=sum (powermeter_w{sensor=~".*_fridge"})&time=${t}`, label: "fridges" },
      { url: base + `?query=sum (powermeter_w{sensor=~"ws_.*"})&time=${t}`, label: "ws" },
      { url: base + `?query=(sum%20by%20%20(s)%20(house_power_w))%20-%20(sum%20by(s)%20(powermeter_w))&time=${t}`, label: "unmetered" },
      { url: base + `?query=house_power_w&time=${t}`, label: "total" },

      { url: base + `?query=avg_over_time(house_power_w[17h]) @ ${day0 + 17 * HR}`, label: "day0_off_0" },
      { url: base + `?query=avg_over_time(house_power_w[3h]) @ ${day0 + 20 * HR}`, label: "day0_on_0" },
      { url: base + `?query=avg_over_time(house_power_w[4h]) @ ${day1}`, label: "day0_off_1" },
      { url: base + `?query=avg_over_time(house_power_w[24h]) @ ${day1}`, label: "day0_avg_w" },

      { url: base + `?query=avg_over_time(house_power_w[17h]) @ ${day1 + 17 * HR}`, label: "day1_off_0" },
      { url: base + `?query=avg_over_time(house_power_w[3h]) @ ${day1 + 20 * HR}`, label: "day1_on_0" },
      { url: base + `?query=avg_over_time(house_power_w[4h]) @ ${day1 + 24 * HR}`, label: "day1_off_1" },
      { url: base + `?query=avg_over_time(house_power_w[${hoursSinceMidnight}h]) @ ${t}`, label: "day1_avg_w" },
    ];

    for (const series of seriesUrls) {
      const response = await fetch(series.url);
      const data = await response.json();
      for (let row of data.data.result) {
        const key = series.label + (row.metric.sensor ? "-" + row.metric.sensor : "");
        const value = parseFloat(row.value[1]);
        seriesData.set(key, value);
      }
    }

    this.lastLoad = t;
    this.isPeak = hours >= 17 && hours < 20; // todo: no peak on weekends
    this.seriesData = seriesData;
    this.update(new Map() as PropertyValues);
  }

  private timeMath() {
    const now = new Date();

    const t = now.getTime() / 1000;

    const hours = now.getHours();
    const minutes = now.getMinutes();
    const seconds = now.getSeconds();
    const hoursSinceMidnight = (hours * 60 * 60 + minutes * 60 + seconds) / HR;

    const lastMidnight = new Date(now);
    lastMidnight.setHours(0, 0, 0, 0);
    const day1 = lastMidnight.getTime() / 1000;
    const day0 = day1 - 86400;

    return { t, day0, day1, hoursSinceMidnight, hours };
  }

  private computeLiveState() {
    const data = (label: string): number => this.seriesData.get(label) || 0;

    const day0_off_peak_wh = data("day0_off_0") * 17 + data("day0_off_1") * 4;
    const day0_on_peak_wh = data("day0_on_0") * 3;
    this.yesterday_charge = off_peak_wh_cost * day0_off_peak_wh + on_peak_wh_cost * day0_on_peak_wh;

    const day1_off_peak_wh = data("day1_off_0") * 17 + data("day1_off_1") * 4;
    const day1_on_peak_wh = data("day1_on_0") * 3;
    this.today_charge = off_peak_wh_cost * day1_off_peak_wh + on_peak_wh_cost * day1_on_peak_wh + this.costSinceLastLoad();

    this.yesterday_peak_penalty = this.yesterday_charge - off_peak_wh_cost * (day0_off_peak_wh + day0_on_peak_wh);
    this.today_peak_penalty = this.today_charge - off_peak_wh_cost * (day1_off_peak_wh + day1_on_peak_wh);
  }

  private costSinceLastLoad() {
    const t = Date.now() / 1000;
    const hr_since_last_load = (t - this.lastLoad) / HR;
    const wh_since_last_load = (this.seriesData.get("total") || 0) * hr_since_last_load;
    const cost_since_load = (this.isPeak ? on_peak_wh_cost : off_peak_wh_cost) * wh_since_last_load;
    return cost_since_load;
  }

  render() {
    const disp0 = (n: number | undefined) => (n ? `${n.toFixed(0)}` : "0");
    const disp1 = (n: number | undefined) => (n ? `${n.toFixed(1)}` : "0.0");
    const dispDollar = (n: number | undefined, places = 2) => (n ? html`<span class="unit">$</span>${n.toFixed(places)}` : "");
    const pxPerWatt = 150 / 2500;
    const bar = (n: number | undefined) => (n ? html`<div style="width: ${Math.ceil(n * pxPerWatt).toFixed(1)}px"></div>` : "");

    const seriesRow = (label: string, value: number | undefined, unitColumn: TemplateResult | string) => html`
      <tr>
        <th>${label}</th>
        <td>${disp1(value)}</td>
        <td>${unitColumn}</td>
        <td class="bar">${bar(value)}</td>
      </tr>
    `;

    return html`<h1 data-text="Electricity">Electricity (now is ${this.isPeak ? "Peak" : "Off-Peak"})</h1>
      <table>
        ${seriesRow("washer", this.seriesData.get("powermeter-ga_washer"), html`<span class="unit">W</span>`)}
        ${seriesRow("theater console", this.seriesData.get("powermeter-tt_console"), "")} ${seriesRow("workshop", this.seriesData.get("ws"), "")}
        ${seriesRow("dash", this.seriesData.get("powermeter-do_r"), "")} ${seriesRow("fridges", this.seriesData.get("fridges"), "")}
        ${seriesRow("server closet", this.seriesData.get("powermeter-st_wall"), "")} ${seriesRow("(unmetered)", this.seriesData.get("unmetered"), "")}
        <tr class="total">
          <th>Total</th>
          <td>${(this.seriesData.get("total") || 0).toFixed(1)}</td>
          <td></td>
          <td class="bar">${bar(this.seriesData.get("total"))}</td>
        </tr>
      </table>
      <table class="summary">
        <tr>
          <th>yesterday</th>
          <td>${disp0(this.seriesData.get("day0_avg_w"))} <span class="unit">W avg</span></td>
          <td class="odometer">${dispDollar(this.yesterday_charge)}</td>
          <td>${dispDollar(this.yesterday_peak_penalty)} peak penalty</td>
        </tr>
        <tr>
          <th>today</th>
          <td>${disp0(this.seriesData.get("day1_avg_w"))} <span class="unit">W avg</span></td>
          <td class="odometer">${dispDollar(this.today_charge, 4)}</td>
          <td>${dispDollar(this.today_peak_penalty)} peak penalty</td>
        </tr>
      </table>`;
  }
}