From 3ec61c18514326c09be4164daba2d83830b543ee Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 4 Aug 2026 14:07:15 +0200 Subject: [PATCH] Sessions: record added range per charging session (#32509) --- .../js/components/Sessions/SessionDetailsModal.vue | 6 ++++++ assets/js/components/Sessions/SessionTable.vue | 12 ++++++++++++ assets/js/components/Sessions/types.ts | 1 + core/loadpoint.go | 2 ++ core/loadpoint_session.go | 6 ++++++ core/loadpoint_vehicle.go | 1 + core/session/session.go | 1 + i18n/de.json | 3 +++ i18n/en.json | 5 ++++- tests/session-soc.spec.ts | 6 +++++- tests/sessions.spec.ts | 12 +++++++++--- tests/sessions.sql | 11 ++++++----- 12 files changed, 56 insertions(+), 10 deletions(-) diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue index 59a655922..3c2ac3fba 100644 --- a/assets/js/components/Sessions/SessionDetailsModal.vue +++ b/assets/js/components/Sessions/SessionDetailsModal.vue @@ -121,6 +121,12 @@ {{ socRange }} + + + {{ $t("session.addedRange") }} + + +{{ formatKm(session.addedRange) }} + {{ $t("session.odometer") }} diff --git a/assets/js/components/Sessions/SessionTable.vue b/assets/js/components/Sessions/SessionTable.vue index f9f1a16d3..beb0a4ec8 100644 --- a/assets/js/components/Sessions/SessionTable.vue +++ b/assets/js/components/Sessions/SessionTable.vue @@ -260,6 +260,13 @@ export default defineComponent({ value: (session) => session.chargeDuration, format: (value) => this.fmtDurationNs(value, false, "h"), }, + { + name: "addedRange", + unit: distanceUnit(), + total: this.addedRange, + value: (session) => session.addedRange || null, + format: (value) => `+${this.fmtNumber(distanceValue(value), 0)}`, + }, { name: "odometer", unit: distanceUnit(), @@ -363,6 +370,11 @@ export default defineComponent({ chargeDuration() { return this.filteredSessions.reduce((total, s) => total + s.chargeDuration, 0); }, + addedRange() { + const sessions = this.filteredSessions.filter((s) => s.addedRange != null); + if (!sessions.length) return null; + return sessions.reduce((total, s) => total + (s.addedRange || 0), 0); + }, price() { return this.filteredSessions.reduce((total, s) => total + (s.price || 0), 0); }, diff --git a/assets/js/components/Sessions/types.ts b/assets/js/components/Sessions/types.ts index beb03b156..24550f826 100644 --- a/assets/js/components/Sessions/types.ts +++ b/assets/js/components/Sessions/types.ts @@ -12,6 +12,7 @@ export interface Session { chargeDuration: number; socStart: number | null; socEnd: number | null; + addedRange: number | null; solarPercentage: number; price: number | null; pricePerKWh: number | null; diff --git a/core/loadpoint.go b/core/loadpoint.go index 1859b9d8b..66b1bd625 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -174,6 +174,7 @@ type Loadpoint struct { // charge progress vehicleSoc float64 // Vehicle or charger soc + vehicleRange int64 // Vehicle range in km chargeDuration time.Duration // Charge duration connectedDuration time.Duration // Connection duration energyMetrics EnergyMetrics // Stats for charged energy by session @@ -1979,6 +1980,7 @@ func (lp *Loadpoint) publishSocAndRange() { if vs, ok := api.Cap[api.VehicleRange](lp.GetVehicle()); ok { if rng, err := vs.Range(); err == nil { lp.log.DEBUG.Printf("vehicle range: %dkm", rng) + lp.vehicleRange = rng lp.publish(keys.VehicleRange, rng) } else if !loadpoint.AcceptableError(err) { lp.log.ERROR.Printf("vehicle range: %v", err) diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go index 843bdcde3..d566d9b27 100644 --- a/core/loadpoint_session.go +++ b/core/loadpoint_session.go @@ -74,6 +74,12 @@ func (lp *Loadpoint) applyEnergyMetrics(s *session.Session) { s.SocEnd = &soc } + // added range by linear projection from current range and soc delta + if rng := float64(lp.vehicleRange); rng > 0 && s.SocStart != nil && s.SocEnd != nil && *s.SocEnd > *s.SocStart { + added := rng * (*s.SocEnd - *s.SocStart) / *s.SocEnd + s.AddedRange = &added + } + s.SolarPercentage = new(lp.energyMetrics.SolarPercentage()) s.Price = lp.energyMetrics.Price() s.PricePerKWh = lp.energyMetrics.PricePerKWh() diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index e9947b2b5..c7d55c67d 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -224,6 +224,7 @@ func (lp *Loadpoint) unpublishVehicleIdentity() { // unpublishVehicle resets published vehicle data func (lp *Loadpoint) unpublishVehicle() { lp.vehicleSoc = 0 + lp.vehicleRange = 0 lp.publish(keys.VehicleClimaterActive, nil) lp.publish(keys.VehicleSoc, 0.0) diff --git a/core/session/session.go b/core/session/session.go index f8e874ff9..f1292aca9 100644 --- a/core/session/session.go +++ b/core/session/session.go @@ -21,6 +21,7 @@ type Session struct { ChargeDuration *time.Duration `json:"chargeDuration" csv:"Charge Duration" gorm:"column:charge_duration"` SocStart *float64 `json:"socStart" csv:"SoC Start (%)" gorm:"column:soc_start" format:"int"` SocEnd *float64 `json:"socEnd" csv:"SoC End (%)" gorm:"column:soc_end" format:"int"` + AddedRange *float64 `json:"addedRange" csv:"Added Range (km)" gorm:"column:added_range" format:"int"` SolarPercentage *float64 `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"` Price *float64 `json:"price" csv:"Price" gorm:"column:price"` PricePerKWh *float64 `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"` diff --git a/i18n/de.json b/i18n/de.json index 56e8cad87..2bdca69d2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1582,6 +1582,7 @@ }, "session": { "addOdometer": "Wert eintragen", + "addedRange": "Geladene Reichweite", "cancel": "Abbrechen", "co2": "CO₂", "date": "Zeitraum", @@ -1597,6 +1598,7 @@ "title": "Ladevorgang" }, "sessions": { + "addedRange": "Reichweite", "avgPower": "⌀ Leistung", "avgPrice": "⌀ Preis", "chargeDuration": "Ladedauer", @@ -1622,6 +1624,7 @@ }, "co2": "⌀ CO₂", "csv": { + "addedrange": "Geladene Reichweite (km)", "chargedenergy": "Energie (kWh)", "chargeduration": "Ladedauer", "co2perkwh": "CO₂/kWh", diff --git a/i18n/en.json b/i18n/en.json index 62e2b9088..b608c7a20 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1582,6 +1582,7 @@ }, "session": { "addOdometer": "Add value", + "addedRange": "Added range", "cancel": "Cancel", "co2": "CO₂", "date": "Period", @@ -1592,11 +1593,12 @@ "meterstop": "Meter stop", "odometer": "Mileage", "price": "Cost", - "soc": "Vehicle SoC", + "soc": "Charge", "started": "Started", "title": "Charging Session" }, "sessions": { + "addedRange": "Range", "avgPower": "⌀ Power", "avgPrice": "⌀ Price", "chargeDuration": "Duration", @@ -1622,6 +1624,7 @@ }, "co2": "⌀ CO₂", "csv": { + "addedrange": "Added range (km)", "chargedenergy": "Energy (kWh)", "chargeduration": "Duration", "co2perkwh": "CO₂/kWh", diff --git a/tests/session-soc.spec.ts b/tests/session-soc.spec.ts index 54b7369ff..ae45dd389 100644 --- a/tests/session-soc.spec.ts +++ b/tests/session-soc.spec.ts @@ -56,10 +56,11 @@ test("soc range", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("vehicle-status")).toContainText("Charging"); - // update SoC to 80% and energy to 10 kWh + // update SoC to 80%, range to 400 km and energy to 10 kWh await page.goto(simulatorUrl()); await loadpoint.getByLabel("Energy").fill("10"); await vehicle.getByLabel("SoC").fill("80"); + await vehicle.getByLabel("Range").fill("400"); await simulatorApply(page); await page.goto("/"); @@ -79,4 +80,7 @@ test("soc range", async ({ page }) => { const modal = page.getByTestId("session-details"); await expectModalVisible(modal); await expect(modal.getByTestId("session-details-soc")).toContainText("20 – 80%"); + + // added range: 400 km at 80% soc -> 400 * (80 - 20) / 80 = 300 km + await expect(modal.getByTestId("session-details-added-range")).toContainText("+300 km"); }); diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index 6755ce66d..725c25aba 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -55,12 +55,16 @@ test.describe("basics", async () => { await expect(page.getByTestId("sessions-nodata")).toHaveCount(0); await expect(page.getByRole("table")).toBeVisible(); await expect(page.getByTestId("sessions-head")).toHaveCount(1); - await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(10); + await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(11); await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); await expect(page.getByTestId("sessions-foot-energy")).toBeVisible(); await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0"); + await expect(page.getByTestId("sessions-head-addedRange")).toContainText("Rangekm"); + await expect(page.getByTestId("sessions-foot-addedRange")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-addedRange")).toHaveText("+80"); + await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); await expect(page.getByTestId("sessions-foot-solar")).toBeVisible(); await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3"); @@ -251,9 +255,9 @@ test.describe("columns desktop", async () => { await page.setViewportSize({ width: 800, height: 800 }); await page.goto("/#/sessions?year=2023&month=3"); await expect( - page.getByTestId("sessions-head-avgPrice").locator("option[value=odometer]") + page.getByTestId("sessions-head-price").locator("option[value=odometer]") ).toHaveCount(1); - await page.getByTestId("sessions-head-avgPrice").getByRole("combobox").selectOption("odometer"); + await page.getByTestId("sessions-head-price").getByRole("combobox").selectOption("odometer"); await expect(page.getByTestId("sessions-head-odometer")).toBeVisible(); await expect(page.getByTestId("sessions-entry").nth(0)).toContainText("12,345"); await expect(page.getByTestId("sessions-foot-odometer")).toHaveText(""); @@ -391,6 +395,7 @@ test.describe("session details", async () => { await expect(modal.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)"); await expect(modal.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh"); await expect(modal.getByTestId("session-details-co2")).toHaveCount(0); + await expect(modal.getByTestId("session-details-added-range")).toContainText("+25 km"); await expect(modal.getByTestId("session-details-odometer")).toContainText("Add value"); await expect(modal.getByTestId("session-details-meter")).toHaveCount(0); await expect(modal.getByTestId("session-details-delete")).toContainText("Delete"); @@ -413,6 +418,7 @@ test.describe("session details", async () => { await expect(modal.getByTestId("session-details-price")).toContainText("2.00 € 20.0 ct/kWh"); await expect(modal.getByTestId("session-details-co2")).toContainText("3 kg"); await expect(modal.getByTestId("session-details-co2")).toContainText("300 g/kWh"); + await expect(modal.getByTestId("session-details-added-range")).toContainText("+60 km"); await expect(modal.getByTestId("session-details-odometer")).toContainText("12,345 km"); }); diff --git a/tests/sessions.sql b/tests/sessions.sql index 72c789ec4..3108a49ae 100644 --- a/tests/sessions.sql +++ b/tests/sessions.sql @@ -15,11 +15,12 @@ CREATE TABLE `sessions` ( `price_per_kwh` real, `co2_per_kwh` real, `charge_duration` integer, + `added_range` real, PRIMARY KEY (`id`) ); -INSERT INTO `sessions` VALUES (1,'2023-03-01 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,12345,100,2,0.2,300,3600000000000); -INSERT INTO `sessions` VALUES (2,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,NULL,1800000000000); -INSERT INTO `sessions` VALUES (3,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,2.5,NULL,88.21,0.75,0.3,NULL,NULL); -INSERT INTO `sessions` VALUES (4,'2023-05-03 16:00:00.0+02:00','2023-05-03 20:00:00.0+02:00','Carport',NULL,'weißes Model 3',NULL,NULL,2.5,NULL,50,0.25,0.1,NULL,NULL); -INSERT INTO `sessions` VALUES (5,'2023-05-04 22:00:00.0+02:00','2023-05-05 06:00:00.0+02:00','Garage',NULL,'weißes Model 3',NULL,NULL,5,NULL,0,2.5,0.5,null,3600000000000); +INSERT INTO `sessions` VALUES (1,'2023-03-01 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,12345,100,2,0.2,300,3600000000000,60); +INSERT INTO `sessions` VALUES (2,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,NULL,1800000000000,55); +INSERT INTO `sessions` VALUES (3,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,2.5,NULL,88.21,0.75,0.3,NULL,NULL,NULL); +INSERT INTO `sessions` VALUES (4,'2023-05-03 16:00:00.0+02:00','2023-05-03 20:00:00.0+02:00','Carport',NULL,'weißes Model 3',NULL,NULL,2.5,NULL,50,0.25,0.1,NULL,NULL,NULL); +INSERT INTO `sessions` VALUES (5,'2023-05-04 22:00:00.0+02:00','2023-05-05 06:00:00.0+02:00','Garage',NULL,'weißes Model 3',NULL,NULL,5,NULL,0,2.5,0.5,null,3600000000000,25);