diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go new file mode 100644 index 000000000..15dc828e6 --- /dev/null +++ b/go/internal/control/sitesim_test.go @@ -0,0 +1,637 @@ +package control + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "slices" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/sitesim" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// simCoreEvery is control_interval_s's default. The plant steps every second. +const simCoreEvery = 2 * time.Second + +// simSite runs Core's control tick against a sitesim.Plant in main.go's +// order: site import, charger tick, then battery dispatch. Core's settings +// are the config defaults. Every plant second it checks the invariants and +// keeps the first one broken. +type simSite struct { + p *sitesim.Plant + lp loadpoint.Config + surplus bool // the car charges from surplus; otherwise Charge now while plugged + setup func(*simSite) // after each boot + + db memConfigStore // state.db: survives a restart + store *telemetry.Store + state *State + mgr *loadpoint.Manager + ctrl *loadpoint.Controller + + downUntil, nextTick time.Time + meter sitesim.MeterReading + meterAt, evAt time.Time // last reading + ev sitesim.EVReading + seen map[string]time.Time // each driver's last reading + offerW, batteryCmdW float64 // our last commands + raisedAt time.Time // our last command that raised import + defaulted bool + commands []simCommand + + heat [3]float64 // the phases' I², filtered as Core's heat model does + heatSet bool + overSince, needSince time.Time + limitSince time.Time // a phase above the fuse less its margin since + ownRaise bool // that run followed a raise we sent + peakA float64 + overLimit time.Duration // time with a phase over the fuse less its margin + idleWithRoom time.Duration // time the car was idle with room for it + failure string +} + +type simCommand struct { + at time.Time + device string + w float64 +} + +func (c simCommand) String() string { + return fmt.Sprintf("%s %.0f W at %s", c.device, c.w, c.at.Format("Jan 2 15:04:05")) +} + +// boot starts Core with no memory. The devices keep their last command. +func (s *simSite) boot() { + s.store, s.seen, s.evAt, s.defaulted = telemetry.NewStore(), map[string]time.Time{}, time.Time{}, false + s.state = NewState(0, 42, "meter") + s.state.clock = func() time.Time { return s.p.Now } + s.state.SlewRateW, s.state.MinDispatchIntervalS = 3000, 2 + s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = s.p.Fuse.RatingA, s.p.Voltage, 3, .5 + if b := s.p.Battery; b != nil { + s.state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: b.MaxChargeW, MaxDischargeW: b.MaxDischargeW}} + } + s.mgr = loadpoint.NewManager() + s.mgr.SetNowFn(s.state.clock) + s.mgr.Load([]loadpoint.Config{s.lp}) + if s.db == nil { + s.db = memConfigStore{} + } + s.mgr.SetSessionStore(s.db) + s.ctrl = loadpoint.NewController(s.mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, s.evSample, s.send) + s.ctrl.SetDriverOnline(func(d string) bool { h := s.store.DriverHealth(d); return h != nil && h.IsOnline() }) + s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3}) + s.ctrl.SetFuseEVMax(func() (float64, bool) { + return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), s.fuseMaxW()) + }) + s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { + if !s.meterFresh() { + return 0, 0, 0, false + } + return s.meter.PhaseA[0], s.meter.PhaseA[1], s.meter.PhaseA[2], true + }) + s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { + if !s.meterFresh() { + return 0, false + } + var batW float64 + for _, r := range s.store.ReadingsByType(telemetry.DerBattery) { + if h := s.store.DriverHealth(r.Driver); h != nil && h.Status != telemetry.StatusOffline { + batW += r.SmoothedW + } + } + meterW := s.store.Get("meter", telemetry.DerMeter).SmoothedW + return loadpoint.SurplusAvailableForEVW(meterW, batW, s.store.SumOnlineEVW(), s.ctrl.AnyLoadpointSurplusActive()), true + }) + s.ctrl.SetBatSoCProvider(func() (float64, bool) { + r := s.store.Get("battery", telemetry.DerBattery) + if h := s.store.DriverHealth("battery"); r == nil || r.SoC == nil || h == nil || !h.IsOnline() { + return 0, false + } + return *r.SoC, true + }) + if s.setup != nil { + s.setup(s) + } +} + +func (s *simSite) fuseMaxW() float64 { return s.p.Fuse.RatingA * s.p.Voltage * 3 } + +// meterFresh is Core's view: a site reading inside the 60 s watchdog. +func (s *simSite) meterFresh() bool { + m := s.store.Get("meter", telemetry.DerMeter) + return m != nil && s.p.Now.Sub(m.UpdatedAt) <= time.Minute +} + +func (s *simSite) evSample(string) (loadpoint.EVSample, bool) { + if s.evAt.IsZero() || s.p.Now.Sub(s.evAt) > time.Minute { + return loadpoint.EVSample{}, false + } + return loadpoint.EVSample{Connected: s.ev.Plugged, RequestActive: true, PowerW: s.ev.W, PowerAt: s.evAt, + DeviceID: "easee:SIM1", SessionWh: s.ev.SessionWh, EnergyAt: s.evAt}, true +} + +func (s *simSite) send(_ context.Context, driver string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + PhaseMode string `json:"phase_mode"` + SplitW float64 `json:"phase_split_w"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if driver != s.lp.DriverName || s.p.Offline("evse") { + return errors.New("sitesim: no such charger online") + } + if cmd.Action != "ev_set_current" { + return nil + } + // (c) A site meter older than the watchdog raises nothing. + if age := s.p.Now.Sub(s.meterAt); age > time.Minute && cmd.PowerW > s.offerW { + s.fail("raised the car %.0f -> %.0f W on a meter %v old", s.offerW, cmd.PowerW, age) + } + if cmd.PowerW > s.offerW { + s.raisedAt = s.p.Now + } + s.offerW = cmd.PowerW + s.commands = append(s.commands, simCommand{s.p.Now, "evse", cmd.PowerW}) + s.p.EVSE.Command(s.p.Now, cmd.PowerW, cmd.PhaseMode, cmd.SplitW, s.p.Voltage) + return nil +} + +func (s *simSite) emit(driver string, kind telemetry.DerType, w float64, soc *float64, extra map[string]any) { + d := map[string]any{"control_power_available": true, "control_power_observed_at": s.p.Now.Format(time.RFC3339Nano)} + maps.Copy(d, extra) + data, _ := json.Marshal(d) + s.store.Update(driver, kind, w, soc, data) + r := s.store.Get(driver, kind) + r.UpdatedAt = s.p.Now // Receipt on the plant's clock, as are SoC times: + if soc != nil { // Update stamps both with the wall clock. + r.SoCUpdatedAt = s.p.Now + } + s.store.RecordDriverSuccess(driver) + s.seen[driver] = s.p.Now +} + +func (s *simSite) step() { + tick := s.p.Step() + now := s.p.Now + if tick.Meter != nil { + s.meter, s.meterAt = *tick.Meter, now + } + if tick.Restart > 0 { + s.downUntil = now.Add(tick.Restart) + } + if !s.downUntil.IsZero() && !now.Before(s.downUntil) { + s.downUntil = time.Time{} + s.boot() + } + if s.downUntil.IsZero() { + if m := tick.Meter; m != nil { + s.emit("meter", telemetry.DerMeter, m.W, nil, map[string]any{"l1_a": m.PhaseA[0], "l2_a": m.PhaseA[1], "l3_a": m.PhaseA[2]}) + } + if b := tick.Battery; b != nil { + s.emit("battery", telemetry.DerBattery, b.W, &b.SoC, nil) + } + if e := tick.EV; e != nil { + s.ev, s.evAt = *e, now + s.emit(s.lp.DriverName, telemetry.DerEV, e.W, nil, nil) + } + if !now.Before(s.nextTick) { + s.nextTick = now.Add(simCoreEvery) + s.control() + } + } + s.check() +} + +func (s *simSite) run(d time.Duration) { + for end := s.p.Now.Add(d); s.p.Now.Before(end) && s.failure == ""; { + s.step() + } +} + +// control is one tick of main.go's control loop. +func (s *simSite) control() { + now := s.p.Now + for d, at := range s.seen { + if h := s.store.DriverHealthMut(d); now.Sub(at) > time.Minute && h.IsOnline() { + h.SetOffline() // The watchdog. + } + } + if _, held := s.ctrl.GetManualHold(s.lp.ID, now); !s.surplus && !held && s.ev.Plugged { + s.ctrl.SetManualHold(s.lp.ID, loadpoint.ManualHold{PowerW: s.lp.MaxChargeW, PhaseMode: s.lp.PhaseMode, Persistent: true, StartedAt: now, UpdatedAt: now}) + } + allowed := s.meterFresh() + s.state.ObserveSiteImport(s.store) + s.ctrl.TickWithDispatch(context.Background(), now, allowed) + if !allowed { + if !s.defaulted { + s.batteryCommand(0) // The driver's default mode, sent once. + s.defaulted = true + } + return + } + s.defaulted = false + points := s.mgr.States() + for i := range points { + _, points[i].ManualActive = s.ctrl.GetManualHold(points[i].ID, now) + } + s.state.EVSurplusOnlyReserveW = loadpoint.SurplusReserveW(points, nil) + s.state.EVSurplusOnlyChargingW = loadpoint.SurplusChargingW(points) + s.state.EVCurtailHeadroomW = loadpoint.SurplusPotentialW(points) + s.state.SetEVCommanded(points) + s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) + var capacityWh float64 + if b := s.p.Battery; b != nil { + capacityWh = b.CapacityWh + } + for _, target := range ComputeDispatch(s.store, s.state, map[string]float64{"battery": capacityWh}, s.fuseMaxW()) { + if target.Driver == "battery" { + s.batteryCommand(target.TargetW) + } + } +} + +// batteryCommand sends w. More than an amp of extra import on one phase +// counts as our raise; smaller moves are the PI's trim. +func (s *simSite) batteryCommand(w float64) { + b := s.p.Battery + if b == nil || s.p.Offline("battery") { + return + } + if w-s.batteryCmdW > s.p.Voltage { + s.raisedAt = s.p.Now + } + s.batteryCmdW = w + s.commands = append(s.commands, simCommand{s.p.Now, "battery", w}) + b.Command(s.p.Now, w) +} + +// responseTime is how long a command takes to show on the meter: a control +// tick, the device's delay and a meter reading. slowest picks the slower of +// the charger and the battery. +func (s *simSite) responseTime(slowest bool) time.Duration { + d := s.p.EVSE.Delay + if b := s.p.Battery; b != nil { + d = min(d, b.Latency) + if slowest { + d = max(s.p.EVSE.Delay, b.Latency) + } + } + return d + max(s.p.Meter.Every, sitesim.Step) + simCoreEvery +} + +// check holds the invariants for this second. +func (s *simSite) check() { + p, now := s.p, s.p.Now + if p.Fuse.AnyBlown() { + s.fail("the main fuse blew: damage %.2f", p.Fuse.Damage) + } + // (a) A phase above the fuse less its margin after a raise we sent within + // the slowest response time is our own doing and may last 3 s. After an + // outside change, Core's own limit, read from the physical currents as + // fuseHeat reads the meter, may break for the fastest response time plus + // 3 s: a phase may sit above the fuse less its margin only while its + // I²-filtered current is under it, and never above the instant limit. + fuseA := p.Fuse.RatingA + limitA, k := fuseA-evPhaseMarginA, -math.Expm1(-sitesim.Step.Seconds()/fuseHeatTau.Seconds()) + over, overLimit := false, false + for i, a := range p.A { + if s.heatSet { + s.heat[i] += (a*a - s.heat[i]) * k + } else { + s.heat[i] = a * a + } + over = over || math.Abs(a) > fuseInstantFactor*fuseA || (math.Abs(a) > limitA && s.heat[i] > limitA*limitA) + overLimit = overLimit || math.Abs(a) > limitA + s.peakA = math.Max(s.peakA, math.Abs(a)) + } + s.heatSet = true + if overLimit { + s.overLimit += sitesim.Step + if s.limitSince.IsZero() { + s.limitSince, s.ownRaise = now, !s.raisedAt.IsZero() && now.Sub(s.raisedAt) <= s.responseTime(true) + } + } else { + s.limitSince = time.Time{} + } + if s.ownRaise && !s.limitSince.IsZero() && now.Sub(s.limitSince) > 3*time.Second { + s.fail("a raise we sent kept a phase over %.0f A for more than 3 s", limitA) + } + if !over { + s.overSince = time.Time{} + } else if s.overSince.IsZero() { + s.overSince = now + } + if allow := s.responseTime(false) + 3*time.Second; !s.overSince.IsZero() && now.Sub(s.overSince) > allow { + s.fail("over Core's limit for more than %v (heat %.1f A, limit %.0f A)", allow, math.Sqrt(max(s.heat[0], s.heat[1], s.heat[2])), limitA) + } + // (b) With the meter on schedule, room for the car's minimum and a car + // that asks, the car draws within a minute plus the charger's delay. + if !s.wantsCharge() { + s.needSince = time.Time{} + } else if s.needSince.IsZero() { + s.needSince = now + } + if !s.needSince.IsZero() && p.EVSE.DrawW <= 0 { + s.idleWithRoom += sitesim.Step + if wait := time.Minute + p.EVSE.Delay; now.Sub(s.needSince) > wait { + s.fail("the car is idle after %v with room for it", wait) + } + } +} + +// wantsCharge reports whether the car should charge now: it is plugged +// and asks, Core and the meter are running, and the uncontrolled load +// leaves room for the car's minimum on its phases (and, for surplus +// charging, exports at least that much). +func (s *simSite) wantsCharge() bool { + p, e := s.p, s.p.EVSE + if p.Car == nil || !p.Car.Plugged || p.Offline("evse") || !s.downUntil.IsZero() || p.Now.Sub(s.meterAt) > max(p.Meter.Every, sitesim.Step)+sitesim.Step { + return false + } + phases := min(e.Phases, p.Car.Phases) + var baseW float64 + for i, houseW := range p.HouseW { + w := houseW + p.PVW/3 + baseW += w + if i < phases && math.Abs(w/p.Voltage+e.MinA) > p.Fuse.RatingA-evPhaseMarginA { + return false + } + } + return !s.surplus || -baseW >= e.MinA*p.Voltage*float64(phases) +} + +func (s *simSite) fail(format string, args ...any) { + if s.failure != "" { + return + } + st, _ := s.mgr.State(s.lp.ID) + s.failure = fmt.Sprintf("%v: "+format+"\n phases %.1f A, offer %.0f W (%s), car draws %.0f W, battery %.0f W", + append(append([]any{s.p.Now.Format("15:04:05")}, args...), s.p.A, s.offerW, st.CommandedReason, s.p.EVSE.DrawW, s.batteryW())...) +} + +func (s *simSite) batteryW() float64 { + if s.p.Battery == nil { + return 0 + } + return s.p.Battery.W +} + +// simScenario is one house. known names the finding that makes it fail on +// master: the row must keep failing, and fails once the fix lands until the +// fix deletes that line. expect checks the plant at times along the run. +type simScenario struct { + name string + known string + start time.Time // zero: simStart + run time.Duration + surplus bool + plant func(*sitesim.Plant) + lp func(*loadpoint.Config) + setup func(*simSite) + expect []simExpect +} + +type simExpect struct { + at time.Duration + check func(*sitesim.Plant) error +} + +var simStart = time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) + +// simPlant is a 25 A site with an Easee Cloud-like charger, which answers +// in 5 s and reports the draw 10 s late, and a 3p car that takes 16 A. +func simPlant(start time.Time) *sitesim.Plant { + p := sitesim.New(start, 25) + p.EVSE = &sitesim.EVSE{Phases: 3, MinA: 6, MaxA: 16, Delay: 5 * time.Second, ReadbackLag: 10 * time.Second} + p.Car = &sitesim.Car{Phases: 3, MaxW: 11040, Plugged: true} + return p +} + +func simLoadpoint() loadpoint.Config { + cfg := loadpoint.Config{ID: "garage", DriverName: "evse", MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "3p"} + for a := 0.0; a <= 16; a++ { + if a == 0 || a >= 6 { + cfg.AllowedStepsW = append(cfg.AllowedStepsW, a*690) + } + } + return cfg +} + +func houseA(l1, l2, l3 float64) [3]float64 { return [3]float64{l1 * 230, l2 * 230, l3 * 230} } + +// planBatteryCharge has the plan charge the battery at w for the hour. +func planBatteryCharge(w float64) func(*simSite) { + return func(s *simSite) { + s.state.Mode, s.state.UseEnergyDispatch = ModePlannerArbitrage, true + slot := SlotDirective{SlotStart: s.p.Now, SlotEnd: s.p.Now.Add(time.Hour), BatteryEnergyWh: w, Strategy: "arbitrage"} + s.state.SlotDirective = func(time.Time) (SlotDirective, bool) { return slot, true } + } +} + +// simDay is a weekday from midnight in Self mode: the car is plugged +// overnight with Charge now, a 9 kW PV peak at 13:00 fills the 20 kWh +// battery by 12:45 and a heavy evening puts L1 at 13.7 A before the car. +// The battery lasts the heavy evening; "battery reaches its floor" is the +// case where it does not. Core restarts at 18:31, the battery driver drops +// out at 20:01 and the meter goes silent at 22:31. +func simDay(p *sitesim.Plant) { + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 20000, SoC: .5, Latency: 2 * time.Second} + for q := range 96 { + h, houseW, pvW := float64(q)/4, 250.0, 0.0 + switch { + case h >= 6.5 && h < 8.5: + houseW += 600 + case h >= 17 && h < 20: + houseW += 2500 + case h >= 20 && h < 22: + houseW += 1000 + } + if h > 7 && h < 19 { + pvW = -9000 * math.Sin(math.Pi*(h-7)/12) + } + plugged := h < 7 || h >= 17 + p.Events = append(p.Events, sitesim.Event{At: time.Duration(q) * 15 * time.Minute, Do: func(p *sitesim.Plant) { + p.HouseW, p.PVW, p.Car.Plugged = [3]float64{houseW + 400, houseW, houseW}, pvW, plugged + }}) + } + p.Events = append(p.Events, + sitesim.Event{At: 18*time.Hour + 31*time.Minute, Kind: sitesim.CoreRestart, For: 20 * time.Second}, + sitesim.Event{At: 20*time.Hour + time.Minute, Kind: sitesim.DriverOffline, Device: "battery", For: 2 * time.Minute}, + sitesim.Event{At: 22*time.Hour + 31*time.Minute, Kind: sitesim.MeterSilent, For: 90 * time.Second}) + slices.SortStableFunc(p.Events, func(a, b sitesim.Event) int { return cmp.Compare(a.At, b.At) }) +} + +func simScenarios() []simScenario { + slowCharger := func(delay time.Duration) func(*sitesim.Plant) { + return func(p *sitesim.Plant) { p.HouseW, p.EVSE.Delay = houseA(15, 15, 15), delay } + } + // 9 kW of export to a charger that reads back at once. + surplus := func(p *sitesim.Plant) { p.HouseW, p.PVW, p.EVSE.ReadbackLag = houseA(2, 2, 2), -9000-3*460, 0 } + surplusPhase := func(mode string) func(*loadpoint.Config) { + return func(c *loadpoint.Config) { + c.PhaseMode, c.SurplusOnly, c.MinChargeW, c.PhaseSplitW = mode, true, 1380, 3680 + steps := []float64{0} + for a := 6.0; a <= 16; a++ { + steps = append(steps, a*230) + } + c.AllowedStepsW = append(steps, c.AllowedStepsW[1:]...) + } + } + return []simScenario{ + {name: "export start", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute, + plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, + {name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute, + plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }}, + {name: "charger's own load balancer", known: "a cut comes off an offer the car does not draw, ev_fuse_budget.go:172", run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), 25 + p.Events = []sitesim.Event{ + {At: 5 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(18, 12, 12) }}, + {At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(4, 4, 4) }}, + {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, + } + }}, + {name: "slow cloud charger 60 s", known: "rises stack on a charger slower than evSettle, ev_fuse_budget.go:204", run: 15 * time.Minute, + plant: slowCharger(time.Minute)}, + {name: "slow cloud charger 120 s", known: "rises stack on a charger slower than evSettle, ev_fuse_budget.go:204", run: 15 * time.Minute, + plant: slowCharger(2 * time.Minute)}, + {name: "#1761 replay", run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.Car.MaxW = houseA(1, 1, 1), 4200 + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} + p.Events = []sitesim.Event{{At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}} + }, + lp: func(c *loadpoint.Config) { + c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] + }, + setup: planBatteryCharge(9400), + expect: []simExpect{{9*time.Minute + 50*time.Second, func(p *sitesim.Plant) error { + offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) + if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { + return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) + } + return nil + }}}}, + {name: "meter every 30 s after a cut", known: "a meter slower than evSettle makes every reading a gap, ev_fuse_budget.go:141", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(3, 3, 3) + p.Events = []sitesim.Event{ + {At: 3 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(23, 23, 23) }}, + {At: 3*time.Minute + 3*time.Second, Do: func(p *sitesim.Plant) { p.HouseW, p.Meter.Every = houseA(3, 3, 3), 30*time.Second }}, + } + }}, + {name: "phase mode 3p, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("3p")}, + {name: "phase mode empty, 9 kW surplus", known: "empty phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true, + plant: surplus, lp: surplusPhase("")}, + {name: "phase mode auto, 9 kW surplus", known: "auto phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true, + plant: surplus, lp: surplusPhase("auto")}, + {name: "9 kW surplus, readback 10 s late", known: "surplus adds back the charger's late readback, surplus_reserve.go:128", run: 5 * time.Minute, surplus: true, + plant: func(p *sitesim.Plant) { surplus(p); p.EVSE.ReadbackLag = 10 * time.Second }, lp: surplusPhase("3p")}, + {name: "surplus_unlock_bat_soc saved, Charge now", known: "a saved unlock that is not armed sets the fuse phase mode, controller_fuse_budget.go:184", run: 5 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(3, 3, 3) + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .5} + }, + lp: func(c *loadpoint.Config) { c.PhaseMode = "" }, + setup: func(s *simSite) { + planBatteryCharge(0)(s) + s.mgr.SetSchedule(s.lp.ID, loadpoint.Schedule{SurplusUnlockBatSoC: .9}) + }}, + {name: "battery reaches its floor under Charge now", known: "the battery stops at its 5 % floor with no room for that, dispatch.go:3540", run: 5 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(13.7, 12, 12) + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .07, Latency: 2 * time.Second} + }}, + {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, + } +} + +// runSimScenario runs sc from start and returns the site, with the first +// broken invariant or expectation in failure. +func runSimScenario(sc simScenario, start time.Time) *simSite { + p := simPlant(start) + if sc.plant != nil { + sc.plant(p) + } + lp := simLoadpoint() + if sc.lp != nil { + sc.lp(&lp) + } + s := &simSite{p: p, lp: lp, surplus: sc.surplus, setup: sc.setup} + s.boot() + for _, e := range sc.expect { + s.run(start.Add(e.at).Sub(p.Now)) + if err := e.check(p); err != nil { + s.fail("%v", err) + } + } + s.run(start.Add(sc.run).Sub(p.Now)) + return s +} + +func TestSiteSimScenarios(t *testing.T) { + for _, sc := range simScenarios() { + t.Run(sc.name, func(t *testing.T) { + start := sc.start + if start.IsZero() { + start = simStart + } + s := runSimScenario(sc, start) + t.Logf("peak phase %.1f A, %v over %.0f A; car idle with room %v", s.peakA, s.overLimit, s.p.Fuse.RatingA-evPhaseMarginA, s.idleWithRoom) + switch { + case sc.known == "" && s.failure != "": + t.Fatal(s.failure) + case sc.known != "" && s.failure == "": + t.Fatalf("passes now: delete its known line (%s)", sc.known) + case sc.known != "": + t.Logf("fails as known (%s): %s", sc.known, s.failure) + } + }) + } +} + +// Core reads only the plant's clock: a run that crosses the wall clock does +// what the same run a day earlier or later does. Battery-full latching once +// read SoC times from the wall clock. +func TestSiteSimIgnoresWallClock(t *testing.T) { + sc := simScenario{run: 15 * time.Hour, plant: simDay} + // The battery is full from 12:45, so it is full when the plant passes now. + base := time.Now().Add(-13*time.Hour - 30*time.Minute).Truncate(time.Second) + want := runSimScenario(sc, base) + if want.failure != "" { + t.Fatal(want.failure) + } + for _, shift := range []time.Duration{-24 * time.Hour, 24 * time.Hour} { + got := runSimScenario(sc, base.Add(shift)) + if i := firstCommandDiff(want, got, shift); i >= 0 { + t.Fatalf("shifted %v: command %d is %v, want %v", shift, i, commandAt(got.commands, i), commandAt(want.commands, i)) + } + } +} + +func firstCommandDiff(a, b *simSite, shift time.Duration) int { + for i := range max(len(a.commands), len(b.commands)) { + x, y := commandAt(a.commands, i), commandAt(b.commands, i) + if x.device != y.device || x.w != y.w || !x.at.Add(shift).Equal(y.at) { + return i + } + } + return -1 +} + +func commandAt(log []simCommand, i int) simCommand { + if i < len(log) { + return log[i] + } + return simCommand{} +} diff --git a/go/internal/sitesim/fuse.go b/go/internal/sitesim/fuse.go new file mode 100644 index 000000000..6d436eea8 --- /dev/null +++ b/go/internal/sitesim/fuse.go @@ -0,0 +1,59 @@ +package sitesim + +import "math" + +// Fuse is a gG main fuse (IEC 60269) on each phase. Every second above its +// rating spends 1/t of its life, with t the curve's time to blow at that +// multiple of the rating; below the rating it recovers over fuseCooldownS. +// A phase blows when its damage reaches 1. Ported from the iec_gg profile in +// srcfl/device-simulator cmd/simulator/fuse.go. The curve blows slowly from +// just above the rating, so it is stricter than a real gG fuse below 1.25 × +// its rating. +type Fuse struct { + RatingA float64 + Damage [3]float64 + Blown [3]bool +} + +// ggCurve is the time to blow, in seconds, at a multiple of the rating. +var ggCurve = [...]struct{ multiple, seconds float64 }{ + {1.25, 3600}, {1.6, 1800}, {2, 600}, {3, 120}, {5, 20}, {10, 3}, {20, .5}, +} + +const ( + fuseCooldownS = 300 + fuseMinTripS = .05 +) + +// AnyBlown reports whether any phase has blown. +func (f *Fuse) AnyBlown() bool { return f.Blown[0] || f.Blown[1] || f.Blown[2] } + +func (f *Fuse) step(amps [3]float64, dt float64) { + for i, a := range amps { + if f.Blown[i] { + continue + } + ratio := math.Abs(a) / f.RatingA + if ratio <= 1 { + f.Damage[i] = math.Max(0, f.Damage[i]-dt/fuseCooldownS) + continue + } + f.Damage[i] = math.Min(1, f.Damage[i]+dt/math.Max(fuseMinTripS, ggTripSeconds(ratio))) + f.Blown[i] = f.Damage[i] >= 1 + } +} + +// ggTripSeconds interpolates the curve on log-log axes and extends its last +// segment above 20 ×. +func ggTripSeconds(multiple float64) float64 { + if multiple <= ggCurve[0].multiple { + return ggCurve[0].seconds + } + i := 1 + for i < len(ggCurve)-1 && multiple > ggCurve[i].multiple { + i++ + } + a, b := ggCurve[i-1], ggCurve[i] + t := (math.Log10(multiple) - math.Log10(a.multiple)) / (math.Log10(b.multiple) - math.Log10(a.multiple)) + return math.Pow(10, math.Log10(a.seconds)+(math.Log10(b.seconds)-math.Log10(a.seconds))*t) +} diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go new file mode 100644 index 000000000..647db565b --- /dev/null +++ b/go/internal/sitesim/sitesim.go @@ -0,0 +1,284 @@ +// Package sitesim is a simulated house behind one main fuse, for testing Core +// in a closed loop. It holds the physics and the devices' own behaviour; Core +// reads what the devices report and sends them commands. A test advances it +// one second at a time on a pinned clock. Nothing here sleeps or starts a +// goroutine. +// +// Signs follow docs/site-convention.md: positive W flows into the site. +// Phase currents are signed the same way unless a meter drops the sign. +package sitesim + +import ( + "math" + "time" +) + +// Step is the plant's time step. +const Step = time.Second + +// Plant is one site. Set its fields before the first Step and change them +// from events. +type Plant struct { + Now time.Time + Voltage float64 + HouseW [3]float64 // house load per phase + PVW float64 // three-phase PV, zero or negative + Battery *Battery // nil: none + EVSE *EVSE // nil: none + Car *Car // the car on the EVSE; nil or unplugged: none + Meter Meter + Fuse Fuse + Events []Event // in time order; each fires once + + A [3]float64 // physical phase currents after the last step + start time.Time + offline map[string]time.Time +} + +// New returns a plant starting at start with a fuse of fuseA per phase. +func New(start time.Time, fuseA float64) *Plant { + return &Plant{Now: start, start: start, Voltage: 230, Fuse: Fuse{RatingA: fuseA}, offline: map[string]time.Time{}} +} + +// EventKind says what an event does. +type EventKind int + +const ( + Change EventKind = iota // run Do on the plant + CoreRestart // Core stops for For and starts with no memory + MeterSilent // the site meter sends nothing for For + DriverOffline // Device sends nothing for For; Offline says so, and callers send it nothing +) + +// Event is one change on the timeline, At after the plant started. +type Event struct { + At time.Duration + Kind EventKind + For time.Duration + Device string // "battery" or "evse" + Do func(*Plant) +} + +// Tick is what one second produced. A nil reading means the device sent +// nothing this second. +type Tick struct { + Meter *MeterReading + Battery *BatteryReading + EV *EVReading + Restart time.Duration // > 0: Core goes down now for this long +} + +// MeterReading is one site meter reading. +type MeterReading struct { + W float64 + PhaseA [3]float64 +} + +// BatteryReading is what the battery reports. +type BatteryReading struct{ W, SoC float64 } + +// EVReading is what the charger reports. W is ReadbackLag late. +type EVReading struct { + W, SessionWh float64 + Plugged bool +} + +// Offline reports whether device is unreachable now. +func (p *Plant) Offline(device string) bool { return p.Now.Before(p.offline[device]) } + +// Step advances one second: events, device responses, physics, fuse and +// readings, in that order. +func (p *Plant) Step() Tick { + p.Now = p.Now.Add(Step) + var tick Tick + for len(p.Events) > 0 && p.Now.Sub(p.start) >= p.Events[0].At { + e := p.Events[0] + p.Events = p.Events[1:] + switch e.Kind { + case Change: + e.Do(p) + case CoreRestart: + tick.Restart = e.For + case MeterSilent: + p.Meter.silentUntil = p.Now.Add(e.For) + case DriverOffline: + p.offline[e.Device] = p.Now.Add(e.For) + } + } + var batW [3]float64 + if b := p.Battery; b != nil { + b.step(p.Now) + batW = spread(b.W, b.Phases) + } + for i := range p.A { + p.A[i] = (p.HouseW[i] + p.PVW/3 + batW[i]) / p.Voltage + } + if e := p.EVSE; e != nil { + evA := spread(e.step(p.Now, p.Car, p.A, p.Voltage), e.DrawPhases) + for i := range p.A { + p.A[i] += evA[i] / p.Voltage + } + } + p.Fuse.step(p.A, Step.Seconds()) + if p.Meter.due(p.Now) { + r := &MeterReading{PhaseA: p.A} + for i, a := range p.A { + r.W += a * p.Voltage + if p.Meter.Unsigned { + r.PhaseA[i] = math.Abs(a) + } + } + tick.Meter = r + } + if b := p.Battery; b != nil && !p.Offline("battery") { + tick.Battery = &BatteryReading{W: b.W, SoC: b.SoC} + } + if e := p.EVSE; e != nil && !p.Offline("evse") { + tick.EV = &EVReading{W: e.readback(p.Now), SessionWh: e.SessionWh, Plugged: p.Car != nil && p.Car.Plugged} + } + return tick +} + +// spread puts w on L1 alone or evenly on three phases. +func spread(w float64, phases int) [3]float64 { + if phases == 1 { + return [3]float64{w, 0, 0} + } + return [3]float64{w / 3, w / 3, w / 3} +} + +type timedW struct { + at time.Time + w float64 +} + +// Meter is the site meter. It reads the boundary every Every, or every +// second when Every is zero. +type Meter struct { + Every time.Duration + Unsigned bool // phase currents as magnitudes + last time.Time + silentUntil time.Time +} + +func (m *Meter) due(now time.Time) bool { + if now.Before(m.silentUntil) || (!m.last.IsZero() && now.Sub(m.last) < m.Every) { + return false + } + m.last = now + return true +} + +// Battery is a battery inverter that follows a power command after Latency. +type Battery struct { + Phases int // 1 (on L1) or 3 + MaxChargeW, MaxDischargeW float64 + CapacityWh, SoC float64 + Latency time.Duration + W float64 // power now, positive charging + queue []timedW +} + +// Command asks for w from now + Latency. +func (b *Battery) Command(now time.Time, w float64) { + b.queue = append(b.queue, timedW{now.Add(b.Latency), w}) +} + +func (b *Battery) step(now time.Time) { + for len(b.queue) > 0 && !b.queue[0].at.After(now) { + b.W, b.queue = b.queue[0].w, b.queue[1:] + } + b.W = math.Max(-b.MaxDischargeW, math.Min(b.MaxChargeW, b.W)) + if (b.W > 0 && b.SoC >= 1) || (b.W < 0 && b.SoC <= 0) { + b.W = 0 + } + if b.CapacityWh > 0 { + b.SoC = math.Max(0, math.Min(1, b.SoC+b.W*Step.Hours()/b.CapacityWh)) + } +} + +// EVSE is a charger with its driver: it turns a power command into a +// current limit, as the Easee driver does, and applies it after Delay. +type EVSE struct { + Phases int // wired phases, 1 or 3 + MinA, MaxA float64 + Delay time.Duration // from command to current at the car + ReadbackLag time.Duration // the charger reports the draw this late + LoadBalancerA float64 // > 0: its own balancer keeps each phase under this + OfferA float64 // current limit in force + OfferPhases int + DrawW float64 // the car's draw now + DrawPhases int + SessionWh float64 // since the car plugged in + queue []evCommand + draws []timedW +} + +type evCommand struct { + at time.Time + a float64 + phases int +} + +// Command sends powerW in phaseMode ("1p", "3p", "auto" or empty). Auto +// picks one phase below splitW, as the Easee driver does without its +// hysteresis. Below MinA the charger pauses. +func (e *EVSE) Command(now time.Time, powerW float64, phaseMode string, splitW, voltage float64) { + phases := e.Phases + if phaseMode == "1p" || (phaseMode == "auto" && powerW < math.Max(splitW, 3*voltage*e.MinA)) { + phases = 1 + } + a := math.Min(math.Floor(powerW/voltage/float64(phases)+.5), e.MaxA) + if a < e.MinA { + a = 0 + } + e.queue = append(e.queue, evCommand{now.Add(e.Delay), a, phases}) +} + +// step applies due commands and returns the car's draw. The car takes the +// offer, its own maximum and the balancer's room, whichever is least. +func (e *EVSE) step(now time.Time, car *Car, houseA [3]float64, voltage float64) float64 { + for len(e.queue) > 0 && !e.queue[0].at.After(now) { + e.OfferA, e.OfferPhases, e.queue = e.queue[0].a, e.queue[0].phases, e.queue[1:] + } + w := 0.0 + if car != nil && car.Plugged && e.OfferA > 0 { + e.DrawPhases = min(max(e.OfferPhases, 1), e.Phases, car.Phases) + a := math.Min(e.OfferA, car.MaxW/voltage/float64(e.DrawPhases)) + if e.LoadBalancerA > 0 { + for i := range e.DrawPhases { + a = math.Min(a, e.LoadBalancerA-houseA[i]) + } + } + if a >= e.MinA { + w = a * voltage * float64(e.DrawPhases) + } + } + e.DrawW, e.SessionWh = w, e.SessionWh+w*Step.Hours() + if car == nil || !car.Plugged { + e.SessionWh = 0 + } + if len(e.draws) == 0 || e.draws[len(e.draws)-1].w != w { + e.draws = append(e.draws, timedW{now, w}) + } + return w +} + +// readback is the draw as it was ReadbackLag ago. +func (e *EVSE) readback(now time.Time) float64 { + seen := now.Add(-e.ReadbackLag) + for len(e.draws) > 1 && !e.draws[1].at.After(seen) { + e.draws = e.draws[1:] + } + if len(e.draws) == 0 || e.draws[0].at.After(seen) { + return 0 + } + return e.draws[0].w +} + +// Car is the vehicle on the EVSE. +type Car struct { + Phases int // phases its onboard charger uses + MaxW float64 // the most it takes, whatever it is offered + Plugged bool +} diff --git a/go/internal/sitesim/sitesim_test.go b/go/internal/sitesim/sitesim_test.go new file mode 100644 index 000000000..18fc6bbab --- /dev/null +++ b/go/internal/sitesim/sitesim_test.go @@ -0,0 +1,67 @@ +package sitesim + +import ( + "math" + "testing" + "time" +) + +var t0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) + +func TestFuseBlowsOnTheGGCurve(t *testing.T) { + for _, tc := range []struct{ multiple, wantS float64 }{{1, math.Inf(1)}, {2, 600}, {5, 20}, {10, 3}} { + f := Fuse{RatingA: 25} + blewAt := math.Inf(1) + for s := 1; s <= 4000 && math.IsInf(blewAt, 1); s++ { + f.step([3]float64{-tc.multiple * 25, 0, 0}, 1) // Export heats it too. + if f.Blown[0] { + blewAt = float64(s) + } + } + if blewAt != tc.wantS || f.Blown[1] { + t.Errorf("%.0f× rating: blew after %v s, want %v s", tc.multiple, blewAt, tc.wantS) + } + } + if got := ggTripSeconds(1.4); got <= 1800 || got >= 3600 { + t.Errorf("1.4×: %.0f s, want between the 1.25× and 1.6× points", got) + } +} + +// PV export shows as negative phase currents, or as magnitudes on an +// unsigned meter, and a slow charger and a slow battery answer late. +func TestPlantSignsAndDelays(t *testing.T) { + p := New(t0, 25) + p.HouseW = [3]float64{460, 460, 460} + p.PVW = -6900 + p.Meter.Unsigned = true + p.Battery = &Battery{Phases: 1, MaxChargeW: 3000, CapacityWh: 10000, SoC: .5, Latency: 2 * time.Second} + p.EVSE = &EVSE{Phases: 3, MinA: 6, MaxA: 16, Delay: 5 * time.Second, ReadbackLag: 10 * time.Second} + p.Car = &Car{Phases: 3, MaxW: 11040, Plugged: true} + p.Events = []Event{{At: 3 * time.Second, Kind: MeterSilent, For: 2 * time.Second}} + if tick := p.Step(); tick.Meter.W != -5520 || tick.Meter.PhaseA != [3]float64{8, 8, 8} || p.A[0] != -8 { + t.Fatalf("export: meter %+v, physical %v", tick.Meter, p.A) + } + p.Battery.Command(p.Now, 2300) + p.EVSE.Command(p.Now, 6900, "", 0, 230) + var heard []float64 + for s := 2; s <= 17; s++ { + tick := p.Step() + if (tick.Meter == nil) != (s == 3 || s == 4) { + t.Fatalf("%d s: meter reading %v", s, tick.Meter) + } + if want := s >= 3; (p.Battery.W == 2300) != want { + t.Fatalf("%d s: battery %.0f W", s, p.Battery.W) + } + wantL2 := -8.0 + if s >= 6 { + wantL2 = 2 + } + if p.A[1] != wantL2 { + t.Fatalf("%d s: L2 %.1f A, want %.0f A", s, p.A[1], wantL2) + } + heard = append(heard, tick.EV.W) + } + if heard[13] != 0 || heard[14] != 6900 { + t.Fatalf("readback %v, want the draw from 6 s at 16 s", heard) + } +}