Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-fuse-heat-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The EV's hard limit now treats the main fuse as heat, as a fuse or breaker does. A short house spike, such as a heat pump or an oven starting, no longer cuts the charge. A phase above the fuse less 1 A cuts it only once that phase's current, weighted over about the last minute, passes the same limit, and at once above 1.2 times the fuse. The charge rises again only once that weighted current has room too, so repeated peaks cut it once instead of every minute. Where the meter reports phase currents, they replace total import as the fuse check, so a short spike on all three phases no longer cuts either. A tariff import ceiling still applies.
8 changes: 8 additions & 0 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ The reactive fuse saver may bypass normal slew limiting because preventing a
fuse trip is the higher-priority quantified risk. It must still respect
available battery energy and hardware power capability.

The EV hard limit treats the main fuse as heat, not as one reading. It
low-passes each phase's I² from the site meter with a 60 s time constant.
A phase over the fuse less 1 A cuts the EVs once that filtered current
passes the same limit, and at once above 1.2 × the fuse. A rise or start
reads the higher of the reading and that filtered current. Where the meter
reports phase currents, they replace total import as the EVs' fuse check; a
tariff import ceiling still applies.

The implementation and tests in [`go/internal/control`](../go/internal/control)
are authoritative. Planner-side limits in [`go/internal/mpc`](../go/internal/mpc)
reduce infeasible plans but do not
Expand Down
2 changes: 2 additions & 0 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ type State struct {
evFuseDemandsSet bool
// evLimit is the EVs' meter-driven hard cap, kept between site readings.
evLimit evMeterLimit
// fuseHeat is the main fuse's heat per phase, from site readings only.
fuseHeat fuseHeat
// evOffers and batteryCommands are our own recent commands, the only
// device input the EV hard limit and the EV/battery split use.
evOffers commandLog
Expand Down
16 changes: 10 additions & 6 deletions go/internal/control/ev_battery_sequence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,10 @@ func (s *evBatterySite) offer() float64 {
return s.offers[len(s.offers)-1].w
}

// A 2 kW house step lands while the battery and the car both charge. The
// battery dispatch and the EV guard answer the same overage, so the car is
// cut too. EV first: the car must not stop, the battery must yield, and the
// car must get back to its offer within a minute.
// A 2 kW house step lands while the battery and the car both charge. EV
// first: the car must not stop and the battery must yield. The battery
// answers within a tick, inside the fuse's heat budget, so the car keeps its
// offer; a car that is cut must get back to it within a minute.
func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) {
s := newEVBatterySite(t, 3000)
s.run(3*time.Minute + 30*time.Second)
Expand All @@ -291,14 +291,18 @@ func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) {
backAt = s.now
}
}
t.Logf("EV %.0f -> lowest %.0f W, back after %v; battery %.0f -> %.0f W", evBefore, lowest, backAt.Sub(stepAt), batteryBefore, s.batteryW)
back := "never cut"
if !backAt.IsZero() {
back = "back after " + backAt.Sub(stepAt).String()
}
t.Logf("EV %.0f -> lowest %.0f W, %s; battery %.0f -> %.0f W", evBefore, lowest, back, batteryBefore, s.batteryW)
if lowest <= 0 {
t.Fatal("the house step stopped the car")
}
if s.batteryW > batteryBefore-2000+.01 {
t.Fatalf("battery %.0f -> %.0f W did not yield the step", batteryBefore, s.batteryW)
}
if backAt.IsZero() || backAt.Sub(stepAt) > time.Minute {
if lowest < evBefore && (backAt.IsZero() || backAt.Sub(stepAt) > time.Minute) {
t.Fatalf("EV not back at %.0f W within a minute (now %.0f W)", evBefore, s.offer())
}
if s.gridW() > 16560.01 {
Expand Down
67 changes: 31 additions & 36 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package control

import (
"encoding/json"
"log/slog"
"math"
"time"
Expand Down Expand Up @@ -99,12 +98,13 @@ type evPool struct {

// EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint
// controller divides between cars. Callers hold the control lock, and the
// shared freshness gate must already allow dispatch. An overage on the latest
// site reading cuts at once. Inside the limits the cap holds; it rises one
// step only on a fresh reading with room for two, once the meter has shown
// the last change. An idle pool starts only after the meter has kept showing
// room for its minimum plus a step. Each reading acts once, so a silent
// meter can never raise or restart a charge.
// shared freshness gate must already allow dispatch. A fuse overage in total
// import cuts at once; a phase overage cuts once that phase's heat budget is
// spent, or at once above fuseInstantFactor. Otherwise the cap holds; it
// rises one step only on a fresh reading with room for two, once the meter
// has shown the last change. An idle pool starts only after the meter has
// kept showing room for its minimum plus a step. Each reading acts once, so
// a silent meter can never raise or restart a charge.
func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) {
if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 {
return 0, true
Expand All @@ -117,6 +117,7 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State
if !finiteFuseValue(gridW) {
return 0, true
}
state.stepFuseHeat(meter)
lim := &state.evLimit
pool, plugged := evPoolFor(state, points, configs)
if !plugged {
Expand Down Expand Up @@ -148,10 +149,11 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
}
r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW)
step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases
// The fuse cuts on every reading. The hour's budget absorbs a short
// overage of less than a step: it cuts once the overage has lasted
// evSettle, so one reading over the ceiling changes nothing.
cutW := r.fuseW
// The fuse cuts on every reading, by its heat budget where the meter
// reports phase currents. The hour's budget absorbs a short overage of
// less than a step: it cuts once the overage has lasted evSettle, so one
// reading over the ceiling changes nothing.
cutW := r.fuseCutW
if r.limitW >= 0 {
lim.overSince = time.Time{}
} else {
Expand All @@ -169,7 +171,8 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
if lim.capW < prev {
lim.changedAt, lim.raised = now, false
slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA,
"import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW)
"fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW),
"overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW)
}
return
}
Expand Down Expand Up @@ -252,7 +255,8 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first
// evMeterRoom is the measured room in EV watts. The fuse's part holds on
// every reading. The import ceiling's parts are +Inf while it is off.
type evMeterRoom struct {
fuseW float64 // below the fuse less its margin and the worst phase
fuseW float64 // below the fuse less its margin and the worst phase, read or heat; rises and starts
fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat
limitW float64 // below the hour's import limit; negative is an overage
riseW float64 // below the level an increase may reach
startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling
Expand All @@ -262,49 +266,40 @@ type evMeterRoom struct {
}

func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) evMeterRoom {
inf := math.Inf(1)
r := evMeterRoom{fuseW: math.Max(0, fuseMaxW-s.fuseSafetyMarginW()) - gridW, limitW: inf, riseW: inf, startLevelW: inf}
inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW
r := evMeterRoom{fuseW: totalW, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf}
if limitW, riseW, ok := s.importHourLimitsW(); ok {
r.limitW, r.riseW = limitW-gridW, riseW-gridW
// A car that could run only on one hour's slack would stop when
// the next hour starts at the ceiling.
r.startLevelW = math.Min(riseW, s.PeakImportCeilingW)
r.hourly = s.importHourMeasured()
}
worst, present, valid := worstPhaseA(meter, s)
amps, present, valid := phaseCurrentsA(meter, s)
if present && !valid {
return r
}
r.measured = true
if present {
r.fuseW = math.Min(r.fuseW, (s.SiteFuseAmps-evPhaseMarginA-worst)*s.SiteFuseVoltage*phases)
r.worstA = worst
limitA := s.SiteFuseAmps - evPhaseMarginA
for _, a := range amps {
r.worstA = math.Max(r.worstA, a)
}
r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps))*s.SiteFuseVoltage*phases)
// The phases measure the fuse itself; total import stands in only without them.
r.fuseCutW = -s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) * s.SiteFuseVoltage * phases
}
return r
}

// worstPhaseA reads the site's configured phases. present is false when the
// meter reports no phase currents; valid is false when they cannot be used.
func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) {
// DER data uses snake case; separate fields retain absent-vs-zero.
var data struct {
L1 *float64 `json:"l1_a"`
L2 *float64 `json:"l2_a"`
L3 *float64 `json:"l3_a"`
}
if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) {
return 0, false, false
}
if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 {
return 0, true, false
}
for _, amps := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] {
if amps == nil || !finiteFuseValue(*amps) {
return 0, true, false
}
worstA = math.Max(worstA, math.Abs(*amps))
amps, present, valid := phaseCurrentsA(meter, state)
for _, a := range amps {
worstA = math.Max(worstA, a)
}
return worstA, true, true
return worstA, present, valid
}

// pendingBatteryCommandsW is battery increase we ordered that the meter may
Expand Down
3 changes: 2 additions & 1 deletion go/internal/control/ev_fuse_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ func evMeterBudgetFixture(t *testing.T, gridW float64, phaseA []float64, offerW
}

// A 3p car offered 15 A beside 11 A of house load on L1: 26 A on a 24 A
// working limit. Two amps on three phases come off the offer at once.
// working limit. With no earlier reading the fuse heat starts at 26 A, so
// two amps on three phases come off the offer at once.
func TestEVHardLimitTrimsPhaseOverageFromOffer(t *testing.T) {
store, state, points, configs := evMeterBudgetFixture(t, 8280, []float64{26, 20, 20}, 10350, "3p")
got, known := EVFuseBudget(store, state, points, configs, 17250)
Expand Down
123 changes: 123 additions & 0 deletions go/internal/control/fuse_heat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package control

import (
"encoding/json"
"math"
"time"

"github.com/srcfl/ftw/go/internal/telemetry"
)

// fuseHeatTau is the time constant of the main fuse's heat model.
const fuseHeatTau = 60 * time.Second

// fuseInstantFactor is the phase current, as a multiple of the fuse rating,
// above which the EV is cut at once with no heat budget. It stays below the
// 1.25 × In that a gG fuse of 16–63 A carries for an hour.
const fuseInstantFactor = 1.2

// fuseHeat models each phase of the main fuse as a first-order low-pass on
// I², fed only by the site meter's phase currents. A breaker and a fuse both
// trip on heat, not on one reading. FTW does not know which one a site has,
// so the EV hard limit holds the filtered current under the working limit,
// below both types' one-hour ratings (a miniature circuit breaker does not
// trip at 1.13 × In, a gG fuse does not blow at 1.25 × In), and gives no
// budget above fuseInstantFactor.
//
// A reading stands for the time until the next one. A meter gap counts at
// the last current seen: the reading after it neither fills the gap with its
// own current nor erases an overload from before it. A Core restart starts
// the model at the first reading's current, with no memory of the minutes
// before.
type fuseHeat struct {
set bool
at time.Time // the last reading
amps [3]float64 // its phase currents, held until the next
sq [3]float64 // filtered I² per phase up to at, A²
}

// step folds the time since the last reading into the model at that
// reading's currents, then holds the new ones.
func (h *fuseHeat) step(at time.Time, amps []float64) {
if !h.set {
for i, a := range amps {
h.sq[i] = a * a
}
} else {
dt := at.Sub(h.at)
if dt <= 0 {
return
}
k := -math.Expm1(-dt.Seconds() / fuseHeatTau.Seconds())
for i, a := range h.amps {
h.sq[i] += (a*a - h.sq[i]) * k
}
}
h.set, h.at, h.amps = true, at, [3]float64{}
copy(h.amps[:], amps)
}

// overA is how far the phases the budget no longer covers sit above limitA
// on this reading: a phase whose filtered current is over limitA, or one
// above instantA. Zero while the budget covers every phase.
func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 {
over := 0.0
for i, a := range amps {
if h.sq[i] > limitA*limitA || a > instantA {
over = math.Max(over, a-limitA)
}
}
return over
}

// levelA is the highest phase current, taking each phase at the higher of
// the reading and its heat. Rises and starts read it, so a rise never spends
// heat the last minute has already used.
func (h *fuseHeat) levelA(amps []float64) float64 {
level := 0.0
for i, a := range amps {
level = math.Max(level, math.Max(a, math.Sqrt(h.sq[i])))
}
return level
}

// worstA is the highest filtered phase current.
func (h *fuseHeat) worstA() float64 {
return math.Sqrt(max(h.sq[0], h.sq[1], h.sq[2]))
}

// stepFuseHeat folds each new site reading into the model, whether or not a
// car is plugged in.
func (s *State) stepFuseHeat(meter *telemetry.DerReading) {
if s.fuseHeat.set && !meter.UpdatedAt.After(s.fuseHeat.at) {
return
}
if amps, _, valid := phaseCurrentsA(meter, s); valid {
s.fuseHeat.step(meter.UpdatedAt, amps)
}
}

// phaseCurrentsA reads the site's configured phases as magnitudes. present
// is false when the meter reports no phase currents; valid is false when
// they cannot be used.
func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) {
// DER data uses snake case; separate fields retain absent-vs-zero.
var data struct {
L1 *float64 `json:"l1_a"`
L2 *float64 `json:"l2_a"`
L3 *float64 `json:"l3_a"`
}
if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) {
return nil, false, false
}
if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 {
return nil, true, false
}
for _, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] {
if a == nil || !finiteFuseValue(*a) {
return nil, true, false
}
amps = append(amps, math.Abs(*a))
}
return amps, true, true
}
Loading
Loading