Complete self-consumption optimisation system for 7 kWp PV installation: - Prometheus collector (grid power, SOC, PV, per-phase, compressor) - Pure decision engine with SOC gates, hysteresis, priority ordering - Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback) - Viessmann OAuth2 client for DHW temperature control - PV forecast integration (forecast.solar) - Wallbox mutual exclusion (VX3 4.6 kW AC output constraint) - Car-not-charging detection via Shelly PM - Compressor idle → early SG-Ready release - Per-phase grid power for single-phase wallbox decisions - Manual override detection and web UI with override buttons - Full unit test coverage for decision engine - systemd service, Makefile, complete documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
196 lines
5.0 KiB
Go
196 lines
5.0 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
|
|
}
|
|
|
|
// 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},
|
|
}
|
|
|
|
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,
|
|
)
|
|
|
|
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
|
|
}
|