Files
EMS/internal/collector/collector.go
Lutz Finsterle 971e22c4be Implement proactive forecast-driven car charging strategy
- Replace reactive export-threshold wallbox activation with proactive
  logic: WallboxA/B activate when forecast ≥ mid AND PV ≥ threshold
  AND SOC ≥ 35%, without requiring grid export surplus
- Add WallboxB no-car detection via grid-delta probe (no PM available):
  after probe window, if grid shift < GridDeltaThreshW → no car, retry
  after configured timeout
- Add EOD soft stop: after 16:00, stop proactive car charging if
  remaining PV estimate can't cover battery deficit to 90% by sunset
- WW boost no longer requires export threshold; dynamic setpoint uses
  tank top temp + hysteresis + boost delta, capped at 60°C
- Proactive wallboxes bypass import-hysteresis shutdown; SOC emergency
  brake uses SOCFloor (5%) instead of standard AllConsumers gate
- Add WWTopTempC to SystemState (ww_top_temp metric from DHW cylinder)
- Add BatteryConfig (capacity_kwh), CarChargingConfig to config
- Add WWMaxSetpointC, WWHysteresisC to StrategicConfig
- Update Decide() signature: forecastKWh + sunsetTime parameters
- Update all tests; add proactive charging test cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:23:04 +02:00

199 lines
5.1 KiB
Go

package collector
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"github.com/tb/ems/internal/config"
)
// SystemState represents the current state of the energy system,
// collected from Prometheus metrics.
type SystemState struct {
Timestamp time.Time
GridPowerW float64 // positive = import, negative = export
BatterySOC float64 // 0-100
PVProductionW float64 // current PV production in watts
BatteryPowerW float64 // battery charge/discharge power
CompressorPowerW float64 // heat pump compressor power
AmbientTempC float64 // outdoor temperature
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
PhaseL2PowerW float64 // per-phase grid power L2
PhaseL3PowerW float64 // per-phase grid power L3
WWTopTempC float64 // DHW cylinder top temperature (°C)
}
// IsExporting returns true if the system is exporting to grid.
func (s SystemState) IsExporting() bool {
return s.GridPowerW < 0
}
// ExportW returns the export power as a positive number, or 0 if importing.
func (s SystemState) ExportW() float64 {
if s.GridPowerW < 0 {
return -s.GridPowerW
}
return 0
}
// ImportW returns the import power as a positive number, or 0 if exporting.
func (s SystemState) ImportW() float64 {
if s.GridPowerW > 0 {
return s.GridPowerW
}
return 0
}
// Collector reads the current system state from a Prometheus instance.
type Collector struct {
client *http.Client
baseURL string
metrics map[string]string
logger *slog.Logger
}
// NewCollector creates a new Prometheus collector.
func NewCollector(cfg *config.Config, logger *slog.Logger) *Collector {
return &Collector{
client: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: cfg.Prometheus.URL,
metrics: cfg.Prometheus.Metrics,
logger: logger,
}
}
// Collect queries Prometheus for the current system state.
func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
state := SystemState{
Timestamp: time.Now(),
}
type metricTarget struct {
name string
dest *float64
scale float64 // unit conversion multiplier (1.0 = no conversion)
}
targets := []metricTarget{
{"grid_power_exchange", &state.GridPowerW, 1},
{"battery_soc", &state.BatterySOC, 1},
{"pv_production", &state.PVProductionW, 1000}, // kW → W
{"battery_power", &state.BatteryPowerW, 1},
{"compressor_power", &state.CompressorPowerW, 1},
{"ambient_temp", &state.AmbientTempC, 1},
{"phase_l1_power", &state.PhaseL1PowerW, 1},
{"phase_l2_power", &state.PhaseL2PowerW, 1},
{"phase_l3_power", &state.PhaseL3PowerW, 1},
{"ww_top_temp", &state.WWTopTempC, 1},
}
for _, t := range targets {
metricName, ok := c.metrics[t.name]
if !ok {
c.logger.Warn("metric not configured", "key", t.name)
continue
}
val, err := c.queryInstant(ctx, metricName)
if err != nil {
c.logger.Error("failed to query metric",
"key", t.name,
"metric", metricName,
"error", err,
)
continue
}
*t.dest = val * t.scale
}
c.logger.Info("collected system state",
"grid_w", state.GridPowerW,
"soc", state.BatterySOC,
"pv_w", state.PVProductionW,
"compressor_w", state.CompressorPowerW,
"l1_w", state.PhaseL1PowerW,
"l2_w", state.PhaseL2PowerW,
"l3_w", state.PhaseL3PowerW,
"ww_top_c", state.WWTopTempC,
)
return state, nil
}
// promResponse represents the Prometheus API response for instant queries.
type promResponse struct {
Status string `json:"status"`
Data promData `json:"data"`
}
type promData struct {
ResultType string `json:"resultType"`
Result []promResult `json:"result"`
}
type promResult struct {
Metric map[string]string `json:"metric"`
Value [2]interface{} `json:"value"` // [timestamp, "value"]
}
// queryInstant performs a Prometheus instant query and returns the scalar value.
func (c *Collector) queryInstant(ctx context.Context, query string) (float64, error) {
u, err := url.Parse(c.baseURL + "/api/v1/query")
if err != nil {
return 0, fmt.Errorf("parsing URL: %w", err)
}
q := u.Query()
q.Set("query", query)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return 0, fmt.Errorf("creating request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return 0, fmt.Errorf("querying prometheus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("prometheus returned status %d", resp.StatusCode)
}
var promResp promResponse
if err := json.NewDecoder(resp.Body).Decode(&promResp); err != nil {
return 0, fmt.Errorf("decoding response: %w", err)
}
if promResp.Status != "success" {
return 0, fmt.Errorf("prometheus query failed: %s", promResp.Status)
}
if len(promResp.Data.Result) == 0 {
return 0, fmt.Errorf("no data for query %q", query)
}
// Value is [timestamp, "string_value"]
valStr, ok := promResp.Data.Result[0].Value[1].(string)
if !ok {
return 0, fmt.Errorf("unexpected value type for %q", query)
}
val, err := strconv.ParseFloat(valStr, 64)
if err != nil {
return 0, fmt.Errorf("parsing value %q: %w", valStr, err)
}
return val, nil
}