History UI: group data (#29303)
This commit is contained in:
parent
004cb37396
commit
087f9bb10b
6 changed files with 92 additions and 52 deletions
|
|
@ -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: "",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}));
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue