diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f796895..3491763 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,7 @@ export interface EnergyBar { sell: number produce: number consume: number + charge: number } export interface PowerPoint { @@ -35,6 +36,7 @@ export interface PowerPoint { pv: number house: number barn: number + charger: number } export interface BatteryPoint { diff --git a/frontend/src/views/EnergyView.vue b/frontend/src/views/EnergyView.vue index f4395da..c35f010 100644 --- a/frontend/src/views/EnergyView.vue +++ b/frontend/src/views/EnergyView.vue @@ -39,6 +39,7 @@ const COLORS = { pv: { bg: 'rgba(255,213,79,0.15)', border: '#ffd54f' }, house: { bg: 'rgba(66,165,245,0.15)', border: '#42a5f5' }, barn: { bg: 'rgba(239,83,80,0.15)', border: '#ef5350' }, + charger: { bg: 'rgba(102,187,106,0.15)', border: '#66bb6a' }, battery: { bg: 'rgba(66,165,245,0.2)', border: '#42a5f5' }, } @@ -69,9 +70,10 @@ function buildPowerChart(canvas: HTMLCanvasElement, pts: PowerPoint[], range: st data: { labels, datasets: [ - { label: 'PV Production', data: pts.map(p => p.pv), borderColor: COLORS.pv.border, backgroundColor: COLORS.pv.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, - { label: 'House', data: pts.map(p => p.house), borderColor: COLORS.house.border, backgroundColor: COLORS.house.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, - { label: 'Barn', data: pts.map(p => p.barn), borderColor: COLORS.barn.border, backgroundColor: COLORS.barn.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, + { label: 'PV Production', data: pts.map(p => p.pv), borderColor: COLORS.pv.border, backgroundColor: COLORS.pv.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, + { label: 'House', data: pts.map(p => p.house), borderColor: COLORS.house.border, backgroundColor: COLORS.house.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, + { label: 'Barn', data: pts.map(p => p.barn), borderColor: COLORS.barn.border, backgroundColor: COLORS.barn.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, + { label: 'Charger', data: pts.map(p => p.charger), borderColor: COLORS.charger.border, backgroundColor: COLORS.charger.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, ], }, options: { diff --git a/frontend/src/views/StatisticsView.vue b/frontend/src/views/StatisticsView.vue index f908da1..52b68d8 100644 --- a/frontend/src/views/StatisticsView.vue +++ b/frontend/src/views/StatisticsView.vue @@ -61,6 +61,7 @@ const COLORS = { sell: { bg: 'rgba(66,165,245,0.8)', border: '#42a5f5' }, produce: { bg: 'rgba(102,187,106,0.8)', border: '#66bb6a' }, consume: { bg: 'rgba(255,167,38,0.8)', border: '#ffa726' }, + charge: { bg: 'rgba(171,71,188,0.8)', border: '#ab47bc' }, } function labelFor(t: string): string { @@ -78,6 +79,7 @@ function datasets(data: EnergyBar[]): ChartDataset<'bar'>[] { { label: 'Sell', data: data.map(b => b.sell), backgroundColor: COLORS.sell.bg, borderColor: COLORS.sell.border, borderWidth: 1 }, { label: 'Produce', data: data.map(b => b.produce), backgroundColor: COLORS.produce.bg, borderColor: COLORS.produce.border, borderWidth: 1 }, { label: 'Consume', data: data.map(b => b.consume), backgroundColor: COLORS.consume.bg, borderColor: COLORS.consume.border, borderWidth: 1 }, + { label: 'Charge', data: data.map(b => b.charge), backgroundColor: COLORS.charge.bg, borderColor: COLORS.charge.border, borderWidth: 1 }, ] } diff --git a/internal/api/energy.go b/internal/api/energy.go index 99c754a..9f1ce6e 100644 --- a/internal/api/energy.go +++ b/internal/api/energy.go @@ -15,13 +15,15 @@ type EnergyBar struct { Sell float64 `json:"sell"` Produce float64 `json:"produce"` Consume float64 `json:"consume"` + Charge float64 `json:"charge"` } type PowerPoint struct { - Time time.Time `json:"time"` - PV float64 `json:"pv"` - Barn float64 `json:"barn"` - House float64 `json:"house"` + Time time.Time `json:"time"` + PV float64 `json:"pv"` + Barn float64 `json:"barn"` + House float64 `json:"house"` + Charger float64 `json:"charger"` } type BatteryPoint struct { @@ -36,16 +38,16 @@ var periodDefaults = map[string]int{ } // viewForRange picks the right continuous aggregate views for the given time range. -func viewForRange(timeRange string) (invView, meterView, interval string) { +func viewForRange(timeRange string) (invView, meterView, chargerView, interval string) { switch timeRange { case "1h": - return "inverter_10m", "power_meter_10m", "1 hour" + return "inverter_10m", "power_meter_10m", "charger_10m", "1 hour" case "6h": - return "inverter_10m", "power_meter_10m", "6 hours" + return "inverter_10m", "power_meter_10m", "charger_10m", "6 hours" case "7d": - return "inverter_1h", "power_meter_1h", "7 days" + return "inverter_1h", "power_meter_1h", "charger_1h", "7 days" default: // "24h" and anything else - return "inverter_10m", "power_meter_10m", "24 hours" + return "inverter_10m", "power_meter_10m", "charger_10m", "24 hours" } } @@ -65,7 +67,7 @@ func sortedUnion(maps ...map[time.Time]float64) []time.Time { } func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]PowerPoint, error) { - invView, meterView, interval := viewForRange(timeRange) + invView, meterView, chargerView, interval := viewForRange(timeRange) type colRow struct { bucket time.Time @@ -97,22 +99,28 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe barnSQL := fmt.Sprintf( `SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`, meterView, interval) + chargerSQL := fmt.Sprintf( + `SELECT bucket, COALESCE(power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, + chargerView, interval) type result struct { m map[time.Time]float64 err error } - pvCh := make(chan result, 1) - houseCh := make(chan result, 1) - barnCh := make(chan result, 1) + pvCh := make(chan result, 1) + houseCh := make(chan result, 1) + barnCh := make(chan result, 1) + chargerCh := make(chan result, 1) go func() { m, e := queryCol(pvSQL); pvCh <- result{m, e} }() go func() { m, e := queryCol(houseSQL); houseCh <- result{m, e} }() go func() { m, e := queryCol(barnSQL); barnCh <- result{m, e} }() + go func() { m, e := queryCol(chargerSQL); chargerCh <- result{m, e} }() - pvRes := <-pvCh - houseRes := <-houseCh - barnRes := <-barnCh + pvRes := <-pvCh + houseRes := <-houseCh + barnRes := <-barnCh + chargerRes := <-chargerCh if pvRes.err != nil { return nil, pvRes.err @@ -123,22 +131,26 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe if barnRes.err != nil { return nil, barnRes.err } + if chargerRes.err != nil { + return nil, chargerRes.err + } - times := sortedUnion(pvRes.m, houseRes.m, barnRes.m) + times := sortedUnion(pvRes.m, houseRes.m, barnRes.m, chargerRes.m) pts := make([]PowerPoint, 0, len(times)) for _, t := range times { pts = append(pts, PowerPoint{ - Time: t, - PV: pvRes.m[t], - House: houseRes.m[t], - Barn: barnRes.m[t], + Time: t, + PV: pvRes.m[t], + House: houseRes.m[t], + Barn: barnRes.m[t], + Charger: chargerRes.m[t], }) } return pts, nil } func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) { - invView, _, interval := viewForRange(timeRange) + invView, _, _, interval := viewForRange(timeRange) sql := fmt.Sprintf( `SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, @@ -206,6 +218,14 @@ barn_last AS ( WHERE device = 'barn' AND bucket >= NOW() - '%[2]s'::interval ORDER BY date_trunc('%[1]s', bucket), bucket DESC ), +charger_last AS ( + SELECT DISTINCT ON (date_trunc('%[1]s', bucket)) + date_trunc('%[1]s', bucket) AS period, + eto_wh + FROM charger_daily + WHERE bucket >= NOW() - '%[2]s'::interval + ORDER BY date_trunc('%[1]s', bucket), bucket DESC +), joined AS ( SELECT i.period, @@ -218,17 +238,21 @@ joined AS ( h.import_kwh AS house_kwh, LAG(h.import_kwh) OVER (ORDER BY i.period) AS prev_house, b.import_kwh AS barn_kwh, - LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn + LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn, + c.eto_wh AS charger_wh, + LAG(c.eto_wh) OVER (ORDER BY i.period) AS prev_charger_wh FROM inv_last i - LEFT JOIN house_last h ON h.period = i.period - LEFT JOIN barn_last b ON b.period = i.period + LEFT JOIN house_last h ON h.period = i.period + LEFT JOIN barn_last b ON b.period = i.period + LEFT JOIN charger_last c ON c.period = i.period ) SELECT period, GREATEST(0, grid_import_kwh - prev_import) AS buy, GREATEST(0, grid_export_kwh - prev_export) AS sell, GREATEST(0, pv_energy_kwh - prev_pv) AS produce, - GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume + GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume, + GREATEST(0, COALESCE((charger_wh - prev_charger_wh) / 1000.0, 0)) AS charge FROM joined WHERE prev_import IS NOT NULL ORDER BY period @@ -243,7 +267,7 @@ ORDER BY period var bars []EnergyBar for rows.Next() { var b EnergyBar - if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume); err != nil { + if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume, &b.Charge); err != nil { return nil, err } bars = append(bars, b)