diff --git a/.goreleaser.yml b/.goreleaser.yml
index a11b64a9e..21aad38b4 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -76,13 +76,16 @@ changelog:
groups:
- title: "Breaking Changes 🚨"
regexp: '\(BC\)'
- order: 1
+ order: 10
- title: "Bug Fixes 🐞"
regexp: '^fix:|\bfix\b'
- order: 3
+ order: 30
- title: "New Features 💫"
regexp: '^feat:|\bfeature\b'
- order: 2
+ order: 20
+ - title: "Experimental Features 🧪"
+ regexp: '^experimental:|\bexperimental\b'
+ order: 25
nfpms:
- id: default
diff --git a/AGENTS.md b/AGENTS.md
index e1f4bae89..8fa24ebe8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -186,7 +186,7 @@ This file provides guidance to AI coding agents when working with code in this r
### Essential Commands
-- Must build before testing: `make ui build`
+- Must build before testing executing playwright `make ui build` since it uses the binary. For manual testing assets are build and reloaded automatically (vite dev).
- Run tests: `npm run playwright` or `npx playwright test`
- Debug: `npx playwright test --debug`
- Specific test: `npx playwright test tests/config-loadpoint.spec.ts`
diff --git a/assets/js/components/Optimize/BatteryConfigurationTable.vue b/assets/js/components/Optimize/BatteryConfigurationTable.vue
new file mode 100644
index 000000000..52a9dab59
--- /dev/null
+++ b/assets/js/components/Optimize/BatteryConfigurationTable.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+ | Battery |
+ State of Charge |
+ SoC Range |
+ Energy Value |
+ Power Range |
+ Max Discharge |
+ Grid Interaction |
+ Demand Profile |
+ SoC Goals |
+
+
+
+
+ | {{ getBatteryTitle(index) }} |
+
+ {{ formatStateOfCharge(battery.s_initial, index) }}
+
+ {{ formatInitialSocPercentage(battery.s_initial, index) }}
+
+ |
+
+ {{ formatEnergyRange(battery.s_min, battery.s_max) }}
+
+ {{ formatSocRangePercentage(battery.s_min, battery.s_max, index) }}
+
+ |
+
+ {{ formatEnergyValue(battery.p_a) }}
+
+ {{ formatTotalEnergyValue(battery.p_a, index) }}
+
+ |
+
+ {{ formatPowerRange(battery.c_min, battery.c_max) }}
+ |
+ {{ formatPower(battery.d_max) }} |
+
+ {{ formatGridInteraction(battery) }}
+ |
+
+
+ {{ battery.p_demand.length }} steps
+
+ None
+ |
+
+
+ {{ battery.s_goal.length }} goals
+
+ None
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue
new file mode 100644
index 000000000..4542a8ff7
--- /dev/null
+++ b/assets/js/components/Optimize/ChargeChart.vue
@@ -0,0 +1,298 @@
+
+
+
+
+
+
+
diff --git a/assets/js/components/Optimize/PriceChart.vue b/assets/js/components/Optimize/PriceChart.vue
new file mode 100644
index 000000000..3ff110eb2
--- /dev/null
+++ b/assets/js/components/Optimize/PriceChart.vue
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
diff --git a/assets/js/components/Optimize/SocChart.vue b/assets/js/components/Optimize/SocChart.vue
new file mode 100644
index 000000000..bcd10747a
--- /dev/null
+++ b/assets/js/components/Optimize/SocChart.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
diff --git a/assets/js/components/Optimize/TimeSeriesDataTable.vue b/assets/js/components/Optimize/TimeSeriesDataTable.vue
new file mode 100644
index 000000000..a69007428
--- /dev/null
+++ b/assets/js/components/Optimize/TimeSeriesDataTable.vue
@@ -0,0 +1,294 @@
+
+
+
+
+
+
+ | Data Series |
+
+ {{ formatHour(index) }}
+ |
+
+
+
+
+
+ | Request Data |
+
+
+ | Solar Forecast (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+
+ |
+ {{ gridFeedinPriceLabel }}
+ |
+
+ {{ fmtPricePerKWh(value * 1000, currency, false, false) }}
+ |
+
+
+ |
+ {{ gridImportPriceLabel }}
+ |
+
+ {{ fmtPricePerKWh(value * 1000, currency, false, false) }}
+ |
+
+
+ | Household Demand (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+ | Time Step Duration (h) |
+
+ {{ formatDuration(value) }}
+ |
+
+
+
+
+ | Response Data |
+
+
+ | Grid Export (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+ | Grid Import (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+ | ⬇ Import / ⬆ Export |
+
+
+ {{ value === 1 ? "⬆" : "⬇" }}
+
+ |
+
+
+
+
+
+ |
+
+
+ {{ getBatteryTitle(batteryIndex) }} Response
+
+ |
+
+
+ | Charging Power (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+ | Discharging Power (kW) |
+
+ {{ formatPower(value) }}
+ |
+
+
+ | State of Charge (kWh) |
+
+ {{ formatEnergy(value) }}
+ |
+
+
+ | State of Charge (%) |
+
+ {{ formatSocPercentage(value, batteryIndex) }}
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue
index 9d032e3b7..6022e00f4 100644
--- a/assets/js/components/Sessions/CostHistoryChart.vue
+++ b/assets/js/components/Sessions/CostHistoryChart.vue
@@ -196,6 +196,7 @@ export default defineComponent({
legends() {
return this.chartData.datasets.map((dataset) => {
let value = null;
+ let type: "area" | "line" = "area";
// line chart handling
if ((dataset as any).type === "line") {
@@ -210,6 +211,7 @@ export default defineComponent({
: this.fmtGrams(value, false);
};
value = `${format(min, false)} – ${format(max, true)}`;
+ type = "line";
} else {
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
value =
@@ -221,6 +223,7 @@ export default defineComponent({
label: dataset.label || "",
color: dataset.backgroundColor,
value,
+ type,
};
});
},
diff --git a/assets/js/components/Sessions/LegendList.vue b/assets/js/components/Sessions/LegendList.vue
index 14cbb41e0..6008f2c8c 100644
--- a/assets/js/components/Sessions/LegendList.vue
+++ b/assets/js/components/Sessions/LegendList.vue
@@ -14,7 +14,11 @@
{{ legend.label }}
@@ -56,8 +63,18 @@ export default defineComponent({
width: 1rem;
height: 1rem;
flex-shrink: 0;
+}
+
+.legend-color--area {
border-radius: 50%;
}
+
+.legend-color--line {
+ height: 2px;
+ border-radius: 1px;
+ align-self: center;
+}
+
.legend-label {
flex-shrink: 0;
flex-grow: 0;
diff --git a/assets/js/components/Sessions/types.ts b/assets/js/components/Sessions/types.ts
index c503157f0..072c9759c 100644
--- a/assets/js/components/Sessions/types.ts
+++ b/assets/js/components/Sessions/types.ts
@@ -20,6 +20,7 @@ export interface Legend {
label: string;
color: any;
value: string | string[];
+ type?: "area" | "line";
}
export interface Column {
diff --git a/assets/js/components/Site/Site.vue b/assets/js/components/Site/Site.vue
index 96c863c84..9e7c5291a 100644
--- a/assets/js/components/Site/Site.vue
+++ b/assets/js/components/Site/Site.vue
@@ -93,6 +93,7 @@ import type {
SMART_COST_TYPE,
Sponsor,
FatalError,
+ EvOpt,
} from "@/types/evcc";
import type { Grid } from "./types";
@@ -156,6 +157,7 @@ export default defineComponent({
fatal: { type: Array as PropType
, default: () => [] },
forecast: Object as PropType,
telemetry: Boolean,
+ evopt: { type: Object as PropType },
},
computed: {
batteryConfigured() {
diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue
index dd63c9442..c528834ac 100644
--- a/assets/js/components/Top/Navigation.vue
+++ b/assets/js/components/Top/Navigation.vue
@@ -73,6 +73,12 @@
{{ $t("log.title") }}
+
+
+
+ Optimize 🧪
+
+
@@ -134,7 +140,7 @@ import baseAPI from "./baseapi";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
-import type { FatalError, Sponsor, AuthProviders } from "@/types/evcc";
+import type { FatalError, Sponsor, AuthProviders, EvOpt } from "@/types/evcc";
import type { Provider as Provider } from "./types";
export default defineComponent({
@@ -145,6 +151,7 @@ export default defineComponent({
sponsor: { type: Object as PropType, default: () => ({}) },
forecast: Object,
battery: Array,
+ evopt: { type: Object as PropType, required: false },
fatal: { type: Array as PropType, default: () => [] },
},
data() {
@@ -188,6 +195,9 @@ export default defineComponent({
const { grid, solar, co2 } = this.forecast || {};
return grid || solar || co2;
},
+ optimizeAvailable() {
+ return !!this.evopt && this.$hiddenFeatures();
+ },
showLogout() {
return isLoggedIn();
},
diff --git a/assets/js/router.ts b/assets/js/router.ts
index e512c2a8d..a8d255df8 100644
--- a/assets/js/router.ts
+++ b/assets/js/router.ts
@@ -77,6 +77,11 @@ export default function setupRouter(i18n: VueI18nInstance) {
component: () => import("./views/Energy.vue"),
props: true,
},
+ {
+ path: "/optimize",
+ component: () => import("./views/Optimize.vue"),
+ props: true,
+ },
{
path: "/log",
component: () => import("./views/Log.vue"),
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index a9767710d..8be868ed0 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -51,6 +51,7 @@ export interface State {
currency?: CURRENCY;
fatal?: FatalError[];
authProviders?: AuthProviders;
+ evopt?: EvOpt;
version?: string;
battery?: Battery[];
tariffGrid?: number;
@@ -337,3 +338,94 @@ export interface SiteConfig {
}
export type ValueOf = T[keyof T];
+
+// EvOpt interfaces matching OpenAPI spec exactly
+export interface EvOpt {
+ req: OptimizationInput;
+ res: OptimizationResult;
+ details: OptimizationDetails;
+}
+
+// Request payload for /optimize/charge-schedule
+export interface OptimizationInput {
+ batteries: BatteryConfig[]; // Battery configurations
+ time_series: TimeSeries; // Time series data
+ eta_c?: number; // Charging efficiency (0-1), default 0.95
+ eta_d?: number; // Discharging efficiency (0-1), default 0.95
+ M?: number; // Big M value for MILP constraints
+}
+
+// Battery configuration
+export interface BatteryConfig {
+ s_min: number; // Min state of charge (Wh)
+ s_max: number; // Max state of charge (Wh)
+ s_initial: number; // Initial state of charge (Wh)
+ c_min: number; // Min charge power (W)
+ c_max: number; // Max charge power (W)
+ d_max: number; // Max discharge power (W)
+ p_a: number; // Energy value per Wh at end
+ charge_from_grid?: boolean; // Can charge from grid
+ discharge_to_grid?: boolean; // Can discharge to grid
+ p_demand?: number[]; // Min charge demand per step (Wh)
+ s_goal?: number[]; // Goal state of charge per step (Wh)
+}
+
+// Time series data
+export interface TimeSeries {
+ dt: number[]; // Duration per time step (seconds)
+ gt: number[]; // Household demand per step (Wh)
+ ft: number[]; // Energy generation forecast per step (Wh)
+ p_N: number[]; // Grid import price per step (currency/Wh)
+ p_E: number[]; // Grid export price per step (currency/Wh)
+}
+
+// Solver status enum
+export enum OptimizationStatus {
+ OPTIMAL = "Optimal",
+ INFEASIBLE = "Infeasible",
+ UNBOUNDED = "Unbounded",
+ UNDEFINED = "Undefined",
+ NOT_SOLVED = "Not Solved",
+}
+
+// Flow direction enum
+export enum FlowDirection {
+ IMPORT = 0, // Import from grid
+ EXPORT = 1, // Export to grid
+}
+
+// Response from /optimize/charge-schedule
+export interface OptimizationResult {
+ status: OptimizationStatus; // Solver status
+ objective_value: number | null; // Economic benefit (null if not optimal)
+ batteries: BatteryResult[]; // Results per battery
+ grid_import: number[]; // Grid import per step (Wh)
+ grid_export: number[]; // Grid export per step (Wh)
+ flow_direction: FlowDirection[]; // Flow direction per step (0=import, 1=export)
+}
+
+// Battery optimization results
+export interface BatteryResult {
+ charging_power: number[]; // Charging energy per step (Wh)
+ discharging_power: number[]; // Discharging energy per step (Wh)
+ state_of_charge: number[]; // State of charge per step (Wh)
+}
+
+// Battery detail information for optimization
+export interface BatteryDetail {
+ type: "vehicle" | "battery"; // Type of battery
+ title: string; // Display title
+ name: string; // Internal name/identifier
+ capacity: number; // Battery capacity (kWh)
+}
+
+// Optimization details with timestamps and battery information
+export interface OptimizationDetails {
+ timestamp: string[]; // Array of ISO timestamp strings
+ batteryDetails: BatteryDetail[]; // Array of battery detail objects
+}
+
+// Error response
+export interface Error {
+ message: string; // Error description
+}
diff --git a/assets/js/views/Optimize.vue b/assets/js/views/Optimize.vue
new file mode 100644
index 000000000..995f81989
--- /dev/null
+++ b/assets/js/views/Optimize.vue
@@ -0,0 +1,173 @@
+
+
+
+
+ This page is for development purposes only. Gives insights into the upcoming
+ optimization algorithm.
+
+
+
+
+
+
+
+ Result: Charging Plan
+
+ {{ evopt.res.status }} ・
+ {{ fmtMoney(evopt.res.objective_value || 0, currency, true, true) }}
+ saved
+
+
+
+
+ Result: SoC Projection
+
+
+
+
+
+ Input: Grid Prices
+
+
+
+ Input: Battery
+
+ {{ fmtPercentage((evopt.req.eta_c || 1) * 100, 1) }} charge
+ efficiency ・
+ {{ fmtPercentage((evopt.req.eta_d || 1) * 100, 1) }} discharge
+ efficiency
+
+
+
+
+
+
+
+
+
+
+ Time Series
+
+
+
+
+
+
+ Show Raw Data
+
+
+
+
Request:
+
{{ JSON.stringify(evopt.req, null, 2) }}
+
Response:
+
{{ JSON.stringify(evopt.res, null, 2) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 215a84f6f..f9003cdc8 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -161,11 +161,11 @@ type Loadpoint struct {
wakeUpTimer *Timer // Vehicle wake-up timeout
// charge progress
- vehicleSoc float64 // Vehicle Soc
+ vehicleSoc float64 // Vehicle or charger soc
chargeDuration time.Duration // Charge duration
energyMetrics EnergyMetrics // Stats for charged energy by session
chargeRemainingDuration time.Duration // Remaining charge duration
- chargeRemainingEnergy float64 // Remaining charge energy in Wh
+ chargeRemainingEnergy float64 // Remaining charge energy in kWh
progress *Progress // Step-wise progress indicator
// session log
@@ -1738,7 +1738,7 @@ func (lp *Loadpoint) publishSocAndRange() {
}
lp.SetRemainingDuration(d)
- lp.SetRemainingEnergy(1e3 * socEstimator.RemainingChargeEnergy(limitSoc))
+ lp.SetRemainingEnergy(socEstimator.RemainingChargeEnergy(limitSoc))
// range
if vs, ok := lp.GetVehicle().(api.VehicleRange); ok {
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 93f539217..f52b6e4b3 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -197,7 +197,7 @@ type API interface {
IsFastChargingActive() bool
// GetRemainingDuration is the estimated remaining charging duration
GetRemainingDuration() time.Duration
- // GetRemainingEnergy is the remaining charge energy in Wh
+ // GetRemainingEnergy is the remaining charge energy in kWh
GetRemainingEnergy() float64
//
@@ -210,4 +210,6 @@ type API interface {
SetVehicle(vehicle api.Vehicle)
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
StartVehicleDetection()
+ // GetSoc returns the last vehicle or charger soc in %
+ GetSoc() float64
}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 63c75498e..ab6cbac04 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -562,6 +562,20 @@ func (mr *MockAPIMockRecorder) GetSmartFeedInPriorityLimit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartFeedInPriorityLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartFeedInPriorityLimit))
}
+// GetSoc mocks base method.
+func (m *MockAPI) GetSoc() float64 {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetSoc")
+ ret0, _ := ret[0].(float64)
+ return ret0
+}
+
+// GetSoc indicates an expected call of GetSoc.
+func (mr *MockAPIMockRecorder) GetSoc() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSoc", reflect.TypeOf((*MockAPI)(nil).GetSoc))
+}
+
// GetSocConfig mocks base method.
func (m *MockAPI) GetSocConfig() SocConfig {
m.ctrl.T.Helper()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index ebd41f32c..bbb08d7be 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -737,25 +737,25 @@ func (lp *Loadpoint) setRemainingDuration(remainingDuration time.Duration) {
}
}
-// GetRemainingEnergy is the remaining charge energy in Wh
+// GetRemainingEnergy is the remaining charge energy in kWh
func (lp *Loadpoint) GetRemainingEnergy() float64 {
lp.RLock()
defer lp.RUnlock()
return lp.chargeRemainingEnergy
}
-// SetRemainingEnergy sets the remaining charge energy in Wh
+// SetRemainingEnergy sets the remaining charge energy in kWh
func (lp *Loadpoint) SetRemainingEnergy(chargeRemainingEnergy float64) {
lp.Lock()
defer lp.Unlock()
lp.setRemainingEnergy(chargeRemainingEnergy)
}
-// setRemainingEnergy sets the remaining charge energy in Wh (no mutex)
+// setRemainingEnergy sets the remaining charge energy in kWh (no mutex)
func (lp *Loadpoint) setRemainingEnergy(chargeRemainingEnergy float64) {
if lp.chargeRemainingEnergy != chargeRemainingEnergy {
lp.chargeRemainingEnergy = chargeRemainingEnergy
- lp.publish(keys.ChargeRemainingEnergy, chargeRemainingEnergy)
+ lp.publish(keys.ChargeRemainingEnergy, chargeRemainingEnergy*1e3)
}
}
@@ -778,6 +778,14 @@ func (lp *Loadpoint) SetVehicle(vehicle api.Vehicle) {
lp.stopVehicleDetection()
}
+// GetSoc returns the estimated vehicle soc in %
+func (lp *Loadpoint) GetSoc() float64 {
+ lp.vmu.RLock()
+ defer lp.vmu.RUnlock()
+
+ return lp.vehicleSoc
+}
+
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
func (lp *Loadpoint) StartVehicleDetection() {
// reset vehicle
diff --git a/core/meterenergy_test.go b/core/meterenergy_test.go
index 9d017c685..3c0ae42e1 100644
--- a/core/meterenergy_test.go
+++ b/core/meterenergy_test.go
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestMeterEnergy(t *testing.T) {
+func TestMeterEnergyMeterTotal(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
@@ -21,7 +21,19 @@ func TestMeterEnergy(t *testing.T) {
assert.Equal(t, 1.0, me.AccumulatedEnergy())
me.AddMeterTotal(11)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
+}
+func TestMeterEnergyAddPower(t *testing.T) {
+ clock := clock.NewMock()
+ clock.Set(now.BeginningOfDay())
+
+ me := &meterEnergy{clock: clock}
+
+ clock.Add(60 * time.Minute)
+ me.AddPower(1e3)
+ assert.Equal(t, 0.0, me.AccumulatedEnergy())
+
+ clock.Add(60 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
diff --git a/core/metrics/db.go b/core/metrics/db.go
index a7ba1178d..d1ddaeb55 100644
--- a/core/metrics/db.go
+++ b/core/metrics/db.go
@@ -28,8 +28,9 @@ func Persist(ts time.Time, value float64) error {
}).Error
}
-// Profile returns a 15min average meter profile in Wh
-func Profile() (*[96]float64, error) {
+// Profile returns a 15min average meter profile in Wh.
+// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
+func Profile(from time.Time) (*[96]float64, error) {
db, err := db.Instance.DB()
if err != nil {
return nil, err
@@ -37,9 +38,10 @@ func Profile() (*[96]float64, error) {
rows, err := db.Query(`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
- WHERE meter = ?
+ WHERE meter = ? AND ts >= ?
GROUP BY strftime("%H:%M", ts)
- ORDER BY ts`, 1)
+ ORDER BY strftime("%H:%M", ts) ASC`, 1, from,
+ )
if err != nil {
return nil, err
}
@@ -48,26 +50,28 @@ func Profile() (*[96]float64, error) {
res := make([]float64, 0, 96)
for rows.Next() {
- var (
- ts time.Time
- val float64
- )
+ var ts SqlTime
+ var val float64
+
if err := rows.Scan(&ts, &val); err != nil {
return nil, err
}
- hour := ts.Hour()
- minute := ts.Minute() / 15
- if len(res) != hour*4+minute {
- return nil, ErrIncomplete
- }
-
res = append(res, val)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
if len(res) != 96 {
return nil, ErrIncomplete
}
return (*[96]float64)(res), nil
}
+
+func SlotNum(ts time.Time) int {
+ ts = ts.Local()
+ return ts.Hour()*4 + ts.Minute()/15
+}
diff --git a/core/metrics/types.go b/core/metrics/types.go
new file mode 100644
index 000000000..1e63f94be
--- /dev/null
+++ b/core/metrics/types.go
@@ -0,0 +1,29 @@
+package metrics
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+)
+
+type SqlTime time.Time
+
+var _ sql.Scanner = (*SqlTime)(nil)
+
+func (st *SqlTime) Scan(value any) error {
+ switch v := value.(type) {
+ case time.Time:
+ *st = SqlTime(v)
+ case int64:
+ *st = SqlTime(time.Unix(v, 0))
+ case string:
+ t, err := time.Parse(time.DateTime+"-07:00", v)
+ if err == nil {
+ *st = SqlTime(t)
+ }
+ return err
+ default:
+ return errors.New("unsupported timestamp type")
+ }
+ return nil
+}
diff --git a/core/site.go b/core/site.go
index 5535029b7..362ee2646 100644
--- a/core/site.go
+++ b/core/site.go
@@ -33,6 +33,7 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/modbus"
+ "github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
"github.com/samber/lo"
"github.com/smallnest/chanx"
@@ -611,9 +612,9 @@ func (site *Site) updatePvMeters() {
}
// updateBatteryMeters updates battery meters
-func (site *Site) updateBatteryMeters() {
+func (site *Site) updateBatteryMeters() []measurement {
if len(site.batteryMeters) == 0 {
- return
+ return nil
}
mm := site.collectMeters("battery", site.batteryMeters)
@@ -681,6 +682,8 @@ func (site *Site) updateBatteryMeters() {
site.publish(keys.BatteryPower, site.batteryPower)
site.publish(keys.BatteryEnergy, totalEnergy)
site.publish(keys.Battery, mm)
+
+ return mm
}
// updateAuxMeters updates aux meters
@@ -767,26 +770,30 @@ func (site *Site) updateGridMeter() error {
func (site *Site) updateMeters() error {
var eg errgroup.Group
+ var battery []measurement
+
eg.Go(func() error { site.updatePvMeters(); return nil })
- eg.Go(func() error { site.updateBatteryMeters(); return nil })
+ eg.Go(func() error { battery = site.updateBatteryMeters(); return nil })
eg.Go(func() error { site.updateAuxMeters(); return nil })
eg.Go(func() error { site.updateExtMeters(); return nil })
eg.Go(site.updateGridMeter)
- return eg.Wait()
-}
-
-func (site *Site) updateHouseholdConsumption(totalChargePower float64) {
- householdPower := site.gridPower + site.pvPower + site.batteryPower - totalChargePower
- if householdPower <= 0 {
- return
+ if err := eg.Wait(); err != nil {
+ return err
}
- site.householdEnergy.AddPower(householdPower)
+ if sponsor.IsAuthorized() {
+ go site.optimizerUpdateAsync(battery)
+ }
+
+ return nil
+}
+
+func (site *Site) updateHomeConsumption(homePower float64) {
+ site.householdEnergy.AddPower(homePower)
now := site.householdEnergy.clock.Now()
-
if site.householdSlotStart.IsZero() {
site.householdSlotStart = now
return
@@ -955,6 +962,10 @@ func (site *Site) update(lp updater) {
homePower = max(homePower, 0)
site.publish(keys.HomePower, homePower)
+ if homePower > 0 {
+ site.updateHomeConsumption(homePower)
+ }
+
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints
// fix for: https://github.com/evcc-io/evcc/issues/11032
nonChargePower := homePower + max(0, -site.batteryPower)
@@ -978,8 +989,6 @@ func (site *Site) update(lp updater) {
site.log.ERROR.Println(err)
}
- site.updateHouseholdConsumption(totalChargePower)
-
site.stats.Update(site)
}
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
new file mode 100644
index 000000000..657b6a862
--- /dev/null
+++ b/core/site_optimizer.go
@@ -0,0 +1,437 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ evopt "github.com/andig/evopt/client"
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/metrics"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/request"
+ "github.com/evcc-io/evcc/util/sponsor"
+ "github.com/jinzhu/now"
+ "github.com/samber/lo"
+)
+
+var (
+ eta = float32(0.9) // efficiency of the battery charging/discharging
+ batteryPower = float32(6000) // power of the battery in W
+ pa = float32(0.3 / 1e3) // Value per Wh at end of time horizon
+
+ updated time.Time
+)
+
+type batteryType string
+
+const (
+ batteryTypeLoadpoint batteryType = "loadpoint"
+ batteryTypeVehicle batteryType = "vehicle"
+ batteryTypeBattery batteryType = "battery"
+)
+
+type batteryDetail struct {
+ Type batteryType `json:"type"`
+ Title string `json:"title,omitempty"`
+ Name string `json:"name,omitempty"`
+ Capacity float64 `json:"capacity,omitempty"`
+}
+
+type responseDetails struct {
+ Timestamps []time.Time `json:"timestamp"`
+ BatteryDetails []batteryDetail `json:"batteryDetails"`
+}
+
+func (site *Site) optimizerUpdateAsync(battery []measurement) {
+ site.log.ERROR.Println("optimizer:", site.optimizerUpdate(battery))
+}
+
+func (site *Site) optimizerUpdate(battery []measurement) error {
+ uri := os.Getenv("EVOPT_URI")
+ if uri == "" {
+ return nil
+ }
+
+ if time.Since(updated) < time.Minute {
+ return nil
+ }
+
+ defer func() {
+ updated = time.Now()
+ }()
+
+ solarTariff := site.GetTariff(api.TariffUsageSolar)
+ solarRates, err := solarTariff.Rates()
+ if err != nil {
+ return err
+ }
+
+ solar := currentSlots(solarTariff)
+ grid := currentSlots(site.GetTariff(api.TariffUsageGrid))
+ feedIn := currentSlots(site.GetTariff(api.TariffUsageFeedIn))
+
+ minLen := lo.Min([]int{len(grid), len(feedIn), len(solar)})
+ if minLen < 8 {
+ return fmt.Errorf("not enough slots for optimization: %d", minLen)
+ }
+
+ dt := timeSteps(minLen)
+ firstSlotDuration := time.Duration(dt[0]) * time.Second
+
+ site.log.DEBUG.Printf("optimizer: optimizing %d slots until %v: grid=%d, feedIn=%d, solar=%d, first slot: %v",
+ minLen,
+ grid[minLen-1].End.Local(),
+ len(grid), len(feedIn), len(solar),
+ firstSlotDuration,
+ )
+
+ gt := site.homeProfile(minLen)
+
+ solarEnergy, err := ratesToEnergy(solarRates, firstSlotDuration)
+ if err != nil {
+ return err
+ }
+
+ req := evopt.OptimizationInput{
+ EtaC: &eta,
+ EtaD: &eta,
+ TimeSeries: evopt.TimeSeries{
+ Dt: dt,
+ Gt: asFloat32(gt),
+ PN: maxValues(grid, 1e3, minLen),
+ PE: maxValues(feedIn, 1e3, minLen),
+ Ft: maxValues(solarEnergy, 1, minLen),
+ },
+ }
+
+ details := responseDetails{
+ Timestamps: asTimestamps(dt),
+ }
+
+ for _, lp := range site.Loadpoints() {
+ bat := evopt.BatteryConfig{
+ ChargeFromGrid: lo.ToPtr(true),
+
+ CMin: float32(lp.EffectiveMinPower()),
+ CMax: float32(lp.EffectiveMaxPower()),
+ DMax: 0,
+ SMin: 0,
+ PA: pa,
+ }
+
+ if profile := loadpointProfile(lp, firstSlotDuration, minLen); profile != nil {
+ bat.PDemand = lo.ToPtr(asFloat32(profile))
+ }
+
+ detail := batteryDetail{Type: batteryTypeLoadpoint}
+
+ if v := lp.GetVehicle(); v != nil {
+ bat.SMax = float32(v.Capacity() * 1e3) // Wh
+ bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
+
+ detail.Type = batteryTypeVehicle
+ detail.Title = v.GetTitle()
+ detail.Capacity = v.Capacity()
+
+ // find vehicle name/id
+ for _, dev := range config.Vehicles().Devices() {
+ if dev.Instance() == v {
+ detail.Name = dev.Config().Name
+ }
+ }
+ }
+
+ req.Batteries = append(req.Batteries, bat)
+
+ details.BatteryDetails = append(details.BatteryDetails, detail)
+ }
+
+ for i, b := range battery {
+ if b.Capacity == nil || b.Soc == nil {
+ continue
+ }
+
+ dev := site.batteryMeters[i]
+
+ bat := evopt.BatteryConfig{
+ CMin: 0,
+ CMax: batteryPower,
+ DMax: batteryPower,
+ SMin: 0,
+ SMax: float32(*b.Capacity * 1e3), // Wh
+ SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
+ PA: pa,
+ }
+
+ // TODO atm we cannot cannot control charge from grid speed
+ if _, ok := (dev.Instance()).(api.BatteryController); ok {
+ bat.ChargeFromGrid = lo.ToPtr(true)
+ }
+
+ req.Batteries = append(req.Batteries, bat)
+
+ details.BatteryDetails = append(details.BatteryDetails, batteryDetail{
+ Type: batteryTypeBattery,
+ Name: dev.Config().Name,
+ Capacity: *b.Capacity,
+ })
+ }
+
+ httpClient := request.NewClient(site.log)
+ httpClient.Timeout = 30 * time.Second
+
+ apiClient, err := evopt.NewClientWithResponses(uri, evopt.WithHTTPClient(httpClient))
+ if err != nil {
+ return err
+ }
+
+ resp, err := apiClient.PostOptimizeChargeScheduleWithResponse(context.TODO(), req, func(_ context.Context, req *http.Request) error {
+ if sponsor.IsAuthorized() {
+ req.Header.Set("Authorization", "Bearer "+sponsor.Token)
+ }
+ // command, _ := http2curl.GetCurlCommand(req)
+ // log.TRACE.Println("\n" + command.String())
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode() == http.StatusInternalServerError && resp.JSON500.Message != nil {
+ return errors.New(*resp.JSON500.Message)
+ }
+
+ if resp.StatusCode() == http.StatusBadRequest && resp.JSON400.Message != nil {
+ return errors.New(*resp.JSON400.Message)
+ }
+
+ if resp.StatusCode() != http.StatusOK {
+ return fmt.Errorf("invalid status: %d", resp.StatusCode())
+ }
+
+ site.publish("evopt", struct {
+ Req evopt.OptimizationInput `json:"req"`
+ Res evopt.OptimizationResult `json:"res"`
+ Details responseDetails `json:"details"`
+ }{
+ Req: req,
+ Res: *resp.JSON200,
+ Details: details,
+ })
+
+ return nil
+}
+
+// TODO consider charging efficiency
+func loadpointProfile(lp loadpoint.API, firstSlotDuration time.Duration, minLen int) []float64 {
+ mode := lp.GetMode()
+ status := lp.GetStatus()
+
+ if status != api.StatusC || (mode != api.ModeMinPV && mode != api.ModeNow) {
+ return nil
+ }
+
+ power := lp.GetChargePower()
+ if minP := lp.EffectiveMinPower(); mode == api.ModeMinPV && minP < power {
+ power = minP
+ }
+
+ energy := lp.GetRemainingEnergy() * 1e3 // Wh
+ energyKnown := energy > 0
+
+ res := make([]float64, 0, minLen)
+ for i := range minLen {
+ d := 1.0 // hours
+ if i == 0 {
+ d = firstSlotDuration.Hours()
+ }
+
+ deltaEnergy := power * d // Wh
+ if energyKnown && deltaEnergy >= energy {
+ deltaEnergy = energy
+ }
+ energy -= deltaEnergy
+
+ res = append(res, deltaEnergy)
+ }
+
+ return res
+}
+
+func (site *Site) homeProfile(minLen int) []float64 {
+ now := time.Now().Truncate(time.Hour)
+
+ profile, err := metrics.Profile(now.AddDate(0, 0, -30))
+ if err != nil {
+ site.log.ERROR.Printf("household metrics profile: %v", err)
+ return lo.RepeatBy(minLen, func(_ int) float64 {
+ return 0
+ })
+ }
+
+ res := slotsToHours(now, profile)
+ for len(res) < minLen {
+ res = append(res, profile[:]...)
+ }
+ if len(res) > minLen {
+ res = res[:minLen]
+ }
+
+ return res
+}
+
+// slotsToHours converts a daily consumption profile consisting of 96 15min slots
+// to an hourly profile by totaling the values per hour and returning the first minLen values.
+// the first value is fractional part of the the current hour, prorated.
+func slotsToHours(now time.Time, profile *[96]float64) []float64 {
+ if profile == nil {
+ return []float64{}
+ }
+
+ // Calculate current 15-minute slot within the day (0-95)
+ currentMinute := now.Hour()*60 + now.Minute()
+ currentSlot := currentMinute / 15
+
+ // Calculate remaining minutes in current hour for prorating
+ minutesIntoHour := now.Minute()
+ remainingMinutesInHour := 60 - minutesIntoHour
+
+ var result []float64
+
+ // Handle the partial current hour first
+ if remainingMinutesInHour > 0 && currentSlot < 96 {
+ var partialHourValue float64
+ slotsInCurrentHour := remainingMinutesInHour / 15
+ if remainingMinutesInHour%15 != 0 {
+ slotsInCurrentHour++
+ }
+
+ // Sum the remaining slots in the current hour
+ for i := 0; i < slotsInCurrentHour && currentSlot+i < 96; i++ {
+ if currentSlot+i >= 0 {
+ partialHourValue += profile[currentSlot+i]
+ }
+ }
+
+ // Prorate based on the fraction of the hour remaining
+ fractionOfHour := float64(remainingMinutesInHour) / 60.0
+ partialHourValue *= fractionOfHour
+
+ result = append(result, float64(partialHourValue))
+ }
+
+ // Process complete hours starting from the next hour
+ nextHourSlot := (currentSlot/4 + 1) * 4
+
+ // Don't wrap around at end of day - only process remaining hours
+ for hourSlot := nextHourSlot; len(result) < 24 && hourSlot < 96; hourSlot += 4 {
+ var hourValue float64
+
+ // Sum 4 slots (4 × 15min = 60min = 1 hour)
+ for i := 0; i < 4 && hourSlot+i < 96; i++ {
+ hourValue += profile[hourSlot+i]
+ }
+
+ result = append(result, float64(hourValue))
+ }
+
+ return result
+}
+
+func ratesToEnergy(rr api.Rates, firstSlot time.Duration) (api.Rates, error) {
+ res := make(api.Rates, 0, len(rr))
+
+ for _, r := range rr {
+ from := r.Start
+
+ if len(res) == 0 {
+ from = endOfHour(r.Start).Add(-firstSlot)
+ }
+
+ if _, err := rr.At(from); err != nil {
+ return nil, fmt.Errorf("missing solar data for: %v", from)
+ }
+
+ energy := solarEnergy(rr, from, r.End)
+ if energy < 0 {
+ return nil, fmt.Errorf("negative solar energy from %v to %v: %.3f", from, r.End, energy)
+ }
+
+ res = append(res, api.Rate{
+ Start: from,
+ End: r.End,
+ Value: energy,
+ })
+ }
+
+ return res, nil
+}
+
+func asFloat32(gt []float64) []float32 {
+ return lo.Map(gt, func(v float64, i int) float32 {
+ return float32(v)
+ })
+}
+
+func endOfHour(ts time.Time) time.Time {
+ return ts.Truncate(time.Hour).Add(time.Hour)
+}
+
+func currentSlots(tariff api.Tariff) []api.Rate {
+ if tariff == nil {
+ return nil
+ }
+
+ rates, err := tariff.Rates()
+ if err != nil {
+ return nil
+ }
+
+ now := now.BeginningOfHour()
+ return lo.Filter(rates, func(slot api.Rate, _ int) bool {
+ return !slot.End.Before(now) // filter past slots
+ })
+}
+
+func timeSteps(minLen int) []int {
+ res := make([]int, 0, minLen)
+
+ eoh := now.BeginningOfHour().Add(time.Hour)
+ if d := time.Until(eoh); d > time.Second {
+ res = append(res, int(d.Seconds()))
+ }
+
+ for i := len(res); i < minLen; i++ {
+ res = append(res, 3600) // 1 hour in seconds
+ }
+
+ return res
+}
+
+func asTimestamps(dt []int) []time.Time {
+ res := make([]time.Time, 0, len(dt))
+ eoh := endOfHour(time.Now())
+ res = append(res, eoh.Add(-time.Duration(dt[0])*time.Second))
+ for i := range len(res) - 1 {
+ res = append(res, eoh.Add(time.Duration(dt[i+1])*time.Second))
+ }
+ return res
+}
+
+func maxValues(rates []api.Rate, div float64, maxLen int) []float32 {
+ res := make([]float32, 0, maxLen)
+
+ for _, slot := range rates {
+ res = append(res, float32(slot.Value/div))
+ if len(res) >= maxLen {
+ break
+ }
+ }
+
+ return res
+}
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
new file mode 100644
index 000000000..f27fc4826
--- /dev/null
+++ b/core/site_optimizer_test.go
@@ -0,0 +1,215 @@
+package core
+
+import (
+ "testing"
+ "time"
+
+ "github.com/benbjohnson/clock"
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/metrics"
+ "github.com/evcc-io/evcc/server/db"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+func TestSqliteTimestamp(t *testing.T) {
+ clock := clock.NewMock()
+ clock.Add(time.Hour)
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, metrics.Init())
+
+ metrics.Persist(clock.Now(), 0)
+
+ db, err := db.Instance.DB()
+ require.NoError(t, err)
+
+ var (
+ ts metrics.SqlTime
+ val float64
+ )
+
+ for _, sql := range []string{
+ `SELECT ts, val FROM meters`,
+ `SELECT min(ts), val FROM meters`,
+ `SELECT unixepoch(ts), val FROM meters`,
+ `SELECT unixepoch(min(ts)), val FROM meters`,
+ `SELECT min(ts) AS ts, avg(val) AS val
+ FROM meters
+ GROUP BY strftime("%H:%M", ts)
+ ORDER BY ts`,
+ } {
+ require.NoError(t, db.QueryRow(sql).Scan(&ts, &val))
+ require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
+ }
+
+ require.NoError(t, db.QueryRow(`SELECT ts, val FROM meters WHERE ts >= ?`, clock.Now()).Scan(&ts, &val))
+ require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
+}
+
+func TestUpdateHouseholdProfile(t *testing.T) {
+ clock := clock.NewMock()
+
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ metrics.Init()
+
+ // 2 days of data
+ // day 1: 0 ... 95
+ // day 2: 96 ... 181
+ for i := range 4 * 2 * 24 {
+ metrics.Persist(clock.Now(), float64(i))
+ clock.Add(15 * time.Minute)
+ }
+
+ {
+ from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
+
+ prof, err := metrics.Profile(from)
+ require.NoError(t, err)
+
+ var expected [96]float64
+ for i := range expected {
+ if i < 48 {
+ expected[i] = float64(48+i+144+i) / 2
+ continue
+ }
+ expected[i] = float64(96 - 48 + i)
+ }
+
+ require.Equal(t, expected, *prof, "partial profile: expected %v, got %v", expected, *prof)
+ }
+
+ {
+ from := clock.Now().Local().AddDate(0, 0, -3).Add(12 * time.Hour) // 12:00 of day -1
+
+ prof, err := metrics.Profile(from)
+ require.NoError(t, err)
+
+ var expected [96]float64
+ for i := range expected {
+ expected[i] = float64(0+96+2*i) / 2
+ }
+
+ require.Equal(t, expected, *prof, "full profile: expected %v, got %v", expected, *prof)
+ }
+}
+
+func TestSlotsToHours(t *testing.T) {
+ // Create a test profile with known values
+ // Slots 0-3: hour 0 (00:00-01:00), values 1,2,3,4 -> sum = 10
+ // Slots 4-7: hour 1 (01:00-02:00), values 5,6,7,8 -> sum = 26
+ // etc.
+ var profile [96]float64
+ for i := range 96 {
+ profile[i] = float64(i + 1)
+ }
+
+ tests := []struct {
+ name string
+ now time.Time
+ expected []float32
+ }{
+ {
+ name: "start of hour",
+ now: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // 00:00
+ expected: []float32{
+ 10, // hour 0: slots 0-3 (1+2+3+4)
+ 26, // hour 1: slots 4-7 (5+6+7+8)
+ 42, // hour 2: slots 8-11 (9+10+11+12)
+ },
+ },
+ {
+ name: "middle of hour",
+ now: time.Date(2024, 1, 1, 0, 30, 0, 0, time.UTC), // 00:30
+ expected: []float32{
+ 3.5, // remaining 30min of hour 0: slots 2-3 (3+4) * 0.5 = 7 * 0.5 = 3.5
+ 26, // hour 1: slots 4-7 (5+6+7+8)
+ 42, // hour 2: slots 8-11 (9+10+11+12)
+ },
+ },
+ {
+ name: "quarter past hour",
+ now: time.Date(2024, 1, 1, 0, 15, 0, 0, time.UTC), // 00:15
+ expected: []float32{
+ 6.75, // remaining 45min of hour 0: slots 1-3 (2+3+4) * 0.75 = 9 * 0.75 = 6.75
+ 26, // hour 1: slots 4-7 (5+6+7+8)
+ 42, // hour 2: slots 8-11 (9+10+11+12)
+ },
+ },
+ {
+ name: "middle of slot early",
+ now: time.Date(2024, 1, 1, 0, 10, 0, 0, time.UTC), // 00:10 (middle of slot 0)
+ expected: []float32{
+ 8.33, // remaining 50min of hour 0: slots 0-3 (1+2+3+4) * (50/60) = 10 * 0.833 = 8.33
+ 26, // hour 1: slots 4-7 (5+6+7+8)
+ 42, // hour 2: slots 8-11 (9+10+11+12)
+ },
+ },
+ {
+ name: "middle of slot late",
+ now: time.Date(2024, 1, 1, 0, 50, 0, 0, time.UTC), // 00:50 (middle of slot 3)
+ expected: []float32{
+ 0.67, // remaining 10min of hour 0: slot 3 (4) * (10/60) = 4 * 0.167 = 0.67
+ 26, // hour 1: slots 4-7 (5+6+7+8)
+ 42, // hour 2: slots 8-11 (9+10+11+12)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := slotsToHours(tt.now, &profile)
+ require.GreaterOrEqual(t, len(result), len(tt.expected), "insufficient result length")
+
+ for i, expected := range tt.expected {
+ require.InDelta(t, expected, result[i], 0.01, "mismatch at index %d", i)
+ }
+ })
+ }
+}
+
+func TestSlotsToHoursEdgeCases(t *testing.T) {
+ var profile [96]float64
+ for i := range 96 {
+ profile[i] = float64(i + 1)
+ }
+
+ t.Run("nil profile", func(t *testing.T) {
+ result := slotsToHours(time.Now(), nil)
+ require.Empty(t, result)
+ })
+
+ t.Run("end of day", func(t *testing.T) {
+ now := time.Date(2024, 1, 1, 23, 45, 0, 0, time.UTC) // 23:45
+ result := slotsToHours(now, &profile)
+
+ // Should only return the remaining 15min of the current hour
+ expected := []float32{24} // slot 95 (value 96) * 0.25 (15min/60min)
+ require.Equal(t, len(expected), len(result))
+ require.InDelta(t, expected[0], result[0], 0.01)
+ })
+
+ t.Run("exact hour boundary", func(t *testing.T) {
+ now := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC) // 01:00
+ result := slotsToHours(now, &profile)
+
+ // Should start from hour 1 (slots 4-7)
+ require.GreaterOrEqual(t, len(result), 1)
+ require.InDelta(t, float32(26), result[0], 0.01) // 5+6+7+8 = 26
+ })
+}
+
+func TestLoadpointProfile(t *testing.T) {
+ ctrl := gomock.NewController(t)
+
+ lp := loadpoint.NewMockAPI(ctrl)
+ lp.EXPECT().GetMode().Return(api.ModeMinPV).AnyTimes()
+ lp.EXPECT().GetStatus().Return(api.StatusC).AnyTimes()
+ lp.EXPECT().GetChargePower().Return(10000.0).AnyTimes() // 1 0kW
+ lp.EXPECT().EffectiveMinPower().Return(1000.0).AnyTimes() // 1 kW
+ lp.EXPECT().GetRemainingEnergy().Return(2.0).AnyTimes() // 2 kWh
+
+ // expected slots: 0.25/ 1.0 / 0.75 kWh
+ require.Equal(t, []float64{250, 1000, 750}, loadpointProfile(lp, 15*time.Minute, 3))
+}
diff --git a/core/site_test.go b/core/site_test.go
index 83c325f1c..1a14e6f95 100644
--- a/core/site_test.go
+++ b/core/site_test.go
@@ -162,7 +162,7 @@ func TestRequiredBatteryMode(t *testing.T) {
}
}
-func TestUpdateHouseholdConsumption(t *testing.T) {
+func TestUpdateHomeConsumption(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
@@ -174,19 +174,21 @@ func TestUpdateHouseholdConsumption(t *testing.T) {
householdEnergy: &meterEnergy{clock: clock},
}
+ require.True(t, s.householdEnergy.updated.IsZero())
+
clock.Add(5 * time.Minute)
- s.updateHouseholdConsumption(1e3)
+ s.updateHomeConsumption(1e3)
require.False(t, s.householdEnergy.updated.IsZero())
clock.Add(5 * time.Minute)
- s.updateHouseholdConsumption(1e3)
- require.Equal(t, 0.25, s.householdEnergy.AccumulatedEnergy())
+ s.updateHomeConsumption(1e3)
+ require.Equal(t, 1e3*5/60/1e3, s.householdEnergy.AccumulatedEnergy()) // kWh
clock.Add(5 * time.Minute)
- s.updateHouseholdConsumption(1e3)
+ s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
clock.Add(15 * time.Minute)
- s.updateHouseholdConsumption(1e3)
+ s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
}
diff --git a/core/solar.go b/core/solar.go
index 0ff488f9a..38f959c60 100644
--- a/core/solar.go
+++ b/core/solar.go
@@ -38,6 +38,10 @@ func interpolate(rr api.Rates, i int, ts time.Time) float64 {
func solarEnergy(rr api.Rates, from, to time.Time) float64 {
var energy float64
+ if from.After(to) {
+ panic("from cannot be after to")
+ }
+
idx, ok := search(rr, from)
if !ok {
switch {
diff --git a/go.mod b/go.mod
index 53452af89..11a5352b5 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/WulfgarW/sensonet v0.0.4
+ github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140
github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e
diff --git a/go.sum b/go.sum
index fc1eb6b1a..6593568ea 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9 h1:tWGkO2RipG162BGfxuLj3TbxA2BiI/oHH7ftQGUJ/VI=
+github.com/andig/evopt v0.0.0-20250806061439-fc50d99582e9/go.mod h1:TE4sjD3Fxu4Jn49ecE6H6EdHliJA5KnkRfY6WGdHBu0=
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140 h1:C93T8vg7CN4Q4BBDbBMOvBoTYBA+CaBEvlY2SZF8aa0=
github.com/andig/go-powerwall v0.2.1-0.20230808194509-dd70cdb6e140/go.mod h1:Xk09mD+7RTCuuHMX5RxlqgEUeh9oZdIX0rL0qiY6l/4=
github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b h1:81UMfM949I7StrRay7YDUZazY8M1u/JHkzwcFEjiilQ=
diff --git a/hems/semp/semp.go b/hems/semp/semp.go
index 94d57488a..91459bfc6 100644
--- a/hems/semp/semp.go
+++ b/hems/semp/semp.go
@@ -447,7 +447,7 @@ func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
}
// remaining max energy demand in Wh
- chargeRemainingEnergy := lp.GetRemainingEnergy()
+ chargeRemainingEnergy := lp.GetRemainingEnergy() * 1e3
maxEnergy := int(chargeRemainingEnergy)
// add 1kWh in case we're charging but battery claims full
diff --git a/tests/config-fatals.spec.ts b/tests/config-fatals.spec.ts
index b8cf70a12..1897313fb 100644
--- a/tests/config-fatals.spec.ts
+++ b/tests/config-fatals.spec.ts
@@ -77,7 +77,7 @@ test.describe("fatal config handling", async () => {
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal);
-
+ await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toContainText("Test Carport");
diff --git a/tests/utils.ts b/tests/utils.ts
index 1b10ca022..c270f07dd 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -50,15 +50,15 @@ export async function expectModalHidden(modal: Locator): Promise {
export async function editorClear(editor: Locator, iterations = 10): Promise {
for (let i = 0; i < iterations; i++) {
await editor.locator(".view-line").nth(0).click();
- await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 10 });
- await editor.page().keyboard.press("Backspace", { delay: 10 });
+ await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 50 });
+ await editor.page().keyboard.press("Backspace", { delay: 50 });
}
}
export async function editorPaste(editor: Locator, page: Page, text: string): Promise {
await editor.locator(".view-line").nth(0).click();
await page.evaluate((text) => navigator.clipboard.writeText(text), text);
- await page.keyboard.press("ControlOrMeta+KeyV", { delay: 50 });
+ await page.keyboard.press("ControlOrMeta+KeyV", { delay: 100 });
}
export enum LoadpointType {