History UI: group data (#29303)

This commit is contained in:
Michael Geers 2026-04-21 16:04:51 +02:00 • committed by GitHub
parent 004cb37396
commit 087f9bb10b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 92 additions and 52 deletions

View file

@ -25,7 +25,7 @@ import { commonOptions } from "../Sessions/chartConfig";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import formatter from "@/mixins/formatter";
import type { SeriesData } from "./Chart.vue";
import type { SeriesData } from "./PowerChart.vue";
ChartJS.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip);
@ -79,7 +79,7 @@ export default defineComponent({
const exportData = dayKeys.map((key) => -(byDay[key]?.export ?? 0));
const hasExport = exportData.some((v) => v !== 0);
const importLabel = hasExport ? `${s.name} (import)` : s.name;
const importLabel = hasExport ? `${s.group} (import)` : s.group;
datasets.push({
label: importLabel,
@ -90,7 +90,7 @@ export default defineComponent({
if (hasExport) {
datasets.push({
label: `${s.name} (export)`,
label: `${s.group} (export)`,
data: exportData,
backgroundColor: lighterColor(color),
stack: `s${i}`,
@ -153,11 +153,11 @@ export default defineComponent({
const color = colors.palette[i % colors.palette.length];
const hasExport = s.data.some((slot) => slot.export > 0);
const importLabel = hasExport ? `${s.name} (import)` : s.name;
const importLabel = hasExport ? `${s.group} (import)` : s.group;
result.push({ label: importLabel, color, value: "" });
if (hasExport) {
result.push({
label: `${s.name} (export)`,
label: `${s.group} (export)`,
color: lighterColor(color),
value: "",
});

View file

@ -48,7 +48,8 @@ interface Slot {
}
export interface SeriesData {
name: string;
name?: string;
group: string;
data: Slot[];
}
@ -58,7 +59,7 @@ function slotPower(slot: Slot, field: "import" | "export"): number {
}
export default defineComponent({
name: "HistoryChart",
name: "HistoryPowerChart",
components: { Line, LegendList },
mixins: [formatter],
props: {
@ -82,7 +83,7 @@ export default defineComponent({
});
return {
label: s.name,
label: s.group,
data: points,
borderColor: color,
backgroundColor: fill,
@ -171,7 +172,7 @@ export default defineComponent({
},
legends(): Legend[] {
return this.series.map((s, i) => ({
label: s.name,
label: s.group,
color: colors.palette[i % colors.palette.length],
value: "",
}));

View file

@ -32,7 +32,7 @@
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import PowerChart from "../components/History/Chart.vue";
import PowerChart from "../components/History/PowerChart.vue";
import EnergyChart from "../components/History/EnergyChart.vue";
import api from "../api";
@ -83,6 +83,7 @@ export default defineComponent({
params: {
from: this.powerFrom.toISOString(),
to: this.powerTo.toISOString(),
grouped: true,
},
}),
api.get("history/energy", {
@ -90,6 +91,7 @@ export default defineComponent({
from: this.energyFrom.toISOString(),
to: this.powerTo.toISOString(),
aggregate: "day",
grouped: true,
},
}),
]);

View file

@ -35,8 +35,9 @@ type Slot struct {
// Series represents a named series of energy slots
type Series struct {
Name string `json:"name"`
Data []Slot `json:"data"`
Name string `json:"name,omitempty"`
Group string `json:"group"`
Data []Slot `json:"data"`
}
var aggregateFormats = map[string]string{
@ -60,8 +61,8 @@ var aggregateDurations = map[string]func(time.Time) time.Time{
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
}
// QueryImportEnergy returns aggregated import energy data from the meters table
func QueryImportEnergy(from, to time.Time, aggregate string) ([]Series, error) {
// QueryImportEnergy returns aggregated energy data, per entity or per group.
func QueryImportEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) {
format, ok := aggregateFormats[aggregate]
if !ok {
return nil, errors.New("invalid aggregate value")
@ -74,12 +75,25 @@ func QueryImportEnergy(from, to time.Time, aggregate string) ([]Series, error) {
to = to.Local()
tz := from.Format("-07:00")
groupCols := `e.name, e."group", bucket`
if grouped {
groupCols = `e."group", bucket`
}
type row struct {
Name string
Group string
Bucket string
Import float64
Export float64
}
tx := db.Instance.Table("meters m").
Select(`e.name AS label, strftime('` + format + `', m.ts, '` + tz + `') AS bucket,
COALESCE(SUM(m."import"), 0) AS import, COALESCE(SUM(m.export), 0) AS export`).
Select(`e.name, e."group", strftime(?, m.ts, ?) AS bucket,
COALESCE(SUM(m."import"), 0) AS import, COALESCE(SUM(m.export), 0) AS export`, format, tz).
Joins("JOIN entities e ON m.meter = e.id").
Group("label, bucket").
Order("label, bucket")
Group(groupCols).
Order(groupCols)
if !from.IsZero() {
tx = tx.Where("m.ts >= ?", from)
@ -88,49 +102,33 @@ func QueryImportEnergy(from, to time.Time, aggregate string) ([]Series, error) {
tx = tx.Where("m.ts < ?", to)
}
rows, err := tx.Rows()
if err != nil {
var rows []row
if err := tx.Scan(&rows).Error; err != nil {
return nil, err
}
defer rows.Close()
seriesMap := make(map[string][]Slot)
var order []string
for rows.Next() {
var label, bucket string
var imp, exp float64
if err := rows.Scan(&label, &bucket, &imp, &exp); err != nil {
return nil, err
}
start, err := time.ParseInLocation(aggregateGoFormats[aggregate], bucket, time.Now().Location())
var res []Series
for _, r := range rows {
start, err := time.ParseInLocation(aggregateGoFormats[aggregate], r.Bucket, time.Now().Location())
if err != nil {
return nil, err
}
if _, exists := seriesMap[label]; !exists {
order = append(order, label)
name := r.Name
if grouped {
name = ""
}
seriesMap[label] = append(seriesMap[label], Slot{
if n := len(res); n == 0 || res[n-1].Name != name || res[n-1].Group != r.Group {
res = append(res, Series{Name: name, Group: r.Group})
}
s := &res[len(res)-1]
s.Data = append(s.Data, Slot{
Start: start,
End: addDuration(start),
Import: imp,
Export: exp,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
res := make([]Series, 0, len(order))
for _, name := range order {
res = append(res, Series{
Name: name,
Data: seriesMap[name],
Import: r.Import,
Export: r.Export,
})
}

View file

@ -68,7 +68,7 @@ func TestQueryImportEnergyUTCFilter(t *testing.T) {
from := base.Add(-time.Hour).UTC()
to := base.Add(time.Hour).UTC()
res, err := QueryImportEnergy(from, to, "15m")
res, err := QueryImportEnergy(from, to, "15m", false)
require.NoError(t, err)
require.Len(t, res, 1)
require.Len(t, res[0].Data, 4, "expected all 4 slots, got %d", len(res[0].Data))
@ -80,6 +80,43 @@ func TestQueryImportEnergyUTCFilter(t *testing.T) {
require.InDelta(t, 1+2+3+4, totalExport, 0.001)
}
func TestQueryImportEnergyGrouped(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
// two entities sharing the same group, different names
e1 := entity{Id: 2, Name: "db:12", Group: "grid"}
require.NoError(t, db.Instance.Create(&e1).Error)
e2 := entity{Id: 3, Name: "db:13", Group: "grid"}
require.NoError(t, db.Instance.Create(&e2).Error)
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
require.NoError(t, persist(e1, base, 1, 0))
require.NoError(t, persist(e2, base, 2, 0))
require.NoError(t, persist(e1, base.Add(15*time.Minute), 3, 0))
require.NoError(t, persist(e2, base.Add(15*time.Minute), 4, 0))
from := base.Add(-time.Hour).UTC()
to := base.Add(time.Hour).UTC()
// ungrouped: 2 series
res, err := QueryImportEnergy(from, to, "15m", false)
require.NoError(t, err)
require.Len(t, res, 2)
// grouped: 1 series, values summed per bucket
res, err = QueryImportEnergy(from, to, "15m", true)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, "grid", res[0].Group)
require.Empty(t, res[0].Name)
require.Len(t, res[0].Data, 2)
require.InDelta(t, 1+2, res[0].Data[0].Import, 0.001)
require.InDelta(t, 3+4, res[0].Data[1].Import, 0.001)
}
func TestUpdateProfile(t *testing.T) {
clock := clock.NewMock()

View file

@ -41,7 +41,9 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
aggregate = "15m"
}
res, err := metrics.QueryImportEnergy(from, to, aggregate)
grouped := q.Get("grouped") == "true"
res, err := metrics.QueryImportEnergy(from, to, aggregate, grouped)
if err != nil {
jsonError(w, http.StatusInternalServerError, err)
return