Skip to main content
Version: Latest

Control Modes

Flux implements multiple control strategies that can be scheduled and prioritized based on market conditions and site requirements.

NIV Chase Mode

Overview

Net Imbalance Volume (NIV) Chase mode helps balance the electricity grid by responding to system imbalances. When the grid has excess generation (negative NIV), the battery charges; when there's excess demand (positive NIV), it discharges.

How It Works

Configuration

components:
- type: niv_chase
enabled: true
priority: 1
schedule:
- days: ["weekday"]
start: "16:00"
end: "19:00"
config:
modo_api_key: "${MODO_API_KEY}"
target_factor: 0.0003 # Response per MW of NIV
max_power: 50.0 # kW limit
deadband: 20.0 # MW - ignore small NIV
update_interval: 60s

Implementation

func (n *NivChaseComponent) CalculateTarget(state SystemState) (float64, error) {
// Get latest NIV estimate
niv, err := n.modoClient.GetLatestNIV()
if err != nil {
return 0, fmt.Errorf("failed to get NIV: %w", err)
}

// Apply deadband
if math.Abs(niv) < n.config.Deadband {
return 0, nil
}

// Calculate proportional response
// Negative NIV = grid has excess = battery should charge (positive power)
// Positive NIV = grid needs power = battery should discharge (negative power)
target := -niv * n.config.TargetFactor * 1000 // MW to kW

// Apply power limits
target = clamp(target, -n.config.MaxPower, n.config.MaxPower)

// Check SOE constraints
if state.SOE < 20 && target > 0 {
target = 0 // Don't discharge when low
}
if state.SOE > 80 && target < 0 {
target = 0 // Don't charge when full
}

return target, nil
}

Market Opportunity

  • Revenue: £30-80/MWh for balancing services
  • Best Times: Peak periods (16:00-19:00)
  • Risk: Low - following market signals

Dynamic Peak Avoidance

Overview

Reduces site peak demand to minimize network charges and capacity costs. The controller monitors site consumption and discharges the battery when approaching threshold levels.

Peak Detection Algorithm

type DynamicPeakComponent struct {
threshold float64 // kW trigger level
target float64 // kW target after reduction
monthlyPeak float64 // Current month's peak
rollingAverage float64 // 5-minute average
peakTimes []string // Historical peak periods
}

func (d *DynamicPeakComponent) CalculateTarget(state SystemState) (float64, error) {
// Update rolling average
d.updateRollingAverage(state.MeterPower)

// Predict if we're approaching peak
predicted := d.predictPeak()

if predicted > d.threshold {
// Calculate reduction needed
reduction := predicted - d.target

// Ensure battery can sustain discharge
sustainableTime := (state.SOE - 10) * d.batteryCapacity / reduction
if sustainableTime < 30 { // minutes
reduction = reduction * (sustainableTime / 30)
}

return -reduction, nil // Negative = discharge
}

// Charge during off-peak if SOE low
if state.MeterPower < d.threshold * 0.5 && state.SOE < 50 {
chargeRate := min(d.maxCharge, d.threshold - state.MeterPower)
return chargeRate, nil
}

return 0, nil
}

Configuration

components:
- type: dynamic_peak
enabled: true
priority: 2
schedule:
- days: ["all"]
start: "00:00"
end: "23:59"
config:
threshold: 150.0 # kW - start reducing above this
target: 120.0 # kW - reduce to this level
prediction_window: 5m # Look-ahead time
min_soe_discharge: 20.0 # Don't discharge below
charge_threshold: 0.5 # Charge when below 50% of threshold

Peak Prediction

func (d *DynamicPeakComponent) predictPeak() float64 {
now := time.Now()

// Check if in historical peak window
hour := now.Hour()
if hour >= 16 && hour <= 19 {
// Likely peak period
return d.rollingAverage * 1.1 // Add 10% margin
}

// Use machine learning model for better prediction
features := []float64{
float64(hour),
float64(now.Weekday()),
d.rollingAverage,
d.monthlyPeak,
}

return d.model.Predict(features)
}

Import/Export Avoidance

Import Avoidance

Prevents or minimizes grid electricity import by discharging battery to meet local demand.

func (i *ImportAvoidanceComponent) CalculateTarget(state SystemState) (float64, error) {
gridPower := state.MeterPower

// Positive grid power = importing
if gridPower > i.config.Buffer {
// Discharge to offset import
discharge := gridPower - i.config.Buffer

// Check battery constraints
maxDischarge := min(discharge, i.config.MaxPower)
if state.SOE < i.config.MinSOE {
maxDischarge = 0
}

return -maxDischarge, nil
}

return 0, nil
}

Export Avoidance

Prevents electricity export to grid by charging battery with excess generation.

func (e *ExportAvoidanceComponent) CalculateTarget(state SystemState) (float64, error) {
gridPower := state.MeterPower

// Negative grid power = exporting
if gridPower < -e.config.Buffer {
// Charge to absorb export
charge := math.Abs(gridPower) - e.config.Buffer

// Check battery constraints
maxCharge := min(charge, e.config.MaxPower)
if state.SOE > e.config.MaxSOE {
maxCharge = 0
}

return maxCharge, nil
}

return 0, nil
}

Configuration

components:
- type: import_avoidance
enabled: true
priority: 3
config:
buffer: 5.0 # kW - allow small import
max_power: 100.0 # kW - max discharge rate
min_soe: 15.0 # % - preserve minimum

- type: export_avoidance
enabled: true
priority: 3
config:
buffer: 5.0 # kW - allow small export
max_power: 100.0 # kW - max charge rate
max_soe: 95.0 # % - preserve headroom

Axle Integration Mode

Overview

Executes dispatch instructions from Axle Energy's flexibility platform for grid services and market participation.

Dispatch Handling

type AxleComponent struct {
client *axle.Client
dispatch *axle.Dispatch
lastUpdate time.Time
}

func (a *AxleComponent) CalculateTarget(state SystemState) (float64, error) {
// Fetch latest dispatch
if time.Since(a.lastUpdate) > 30*time.Second {
dispatch, err := a.client.GetActiveDispatch()
if err != nil {
return 0, err
}
a.dispatch = dispatch
a.lastUpdate = time.Now()
}

// No active dispatch
if a.dispatch == nil || !a.dispatch.IsActive() {
return 0, nil
}

// Execute dispatch
target := a.dispatch.PowerSetpoint

// Validate against constraints
if target > 0 && state.SOE > 90 {
// Can't charge, report limitation
a.client.ReportLimitation(a.dispatch.ID, "SOE too high")
target = 0
}
if target < 0 && state.SOE < 10 {
// Can't discharge, report limitation
a.client.ReportLimitation(a.dispatch.ID, "SOE too low")
target = 0
}

return target, nil
}

Dispatch Types

TypeDescriptionDurationResponse Time
FFRFirm Frequency Response30 min1 second
DFSDynamic Frequency SupportContinuous1 second
BMBalancing MechanismVariable5 minutes
TriadPeak avoidance30 minScheduled

ToSOE Mode

Overview

Targets a specific State of Energy (SOE) level by a certain time, useful for preparing for known events or market opportunities.

Implementation

type ToSOEComponent struct {
targetSOE float64
targetTime time.Time
rampRate float64
}

func (t *ToSOEComponent) CalculateTarget(state SystemState) (float64, error) {
// Calculate time remaining
timeRemaining := time.Until(t.targetTime).Hours()
if timeRemaining <= 0 {
return 0, nil // Target time passed
}

// Calculate energy needed
currentEnergy := state.SOE * t.batteryCapacity / 100
targetEnergy := t.targetSOE * t.batteryCapacity / 100
energyDelta := targetEnergy - currentEnergy

// Calculate required power
requiredPower := energyDelta / timeRemaining

// Apply ramp rate limits
if math.Abs(requiredPower - state.CurrentPower) > t.rampRate {
if requiredPower > state.CurrentPower {
requiredPower = state.CurrentPower + t.rampRate
} else {
requiredPower = state.CurrentPower - t.rampRate
}
}

// Apply power limits
requiredPower = clamp(requiredPower, -t.maxPower, t.maxPower)

return requiredPower, nil
}

Use Cases

# Prepare for evening peak
- type: to_soe
schedule:
- start: "14:00"
end: "16:00"
config:
target_soe: 90.0
ramp_rate: 5.0 # kW/min

# Discharge before cheap overnight rates
- type: to_soe
schedule:
- start: "22:00"
end: "23:30"
config:
target_soe: 20.0
ramp_rate: 3.0

Mode Prioritization

Priority Resolution

When multiple modes are active, the controller selects based on priority:

func (c *Controller) selectActiveMode(time time.Time) ControlComponent {
var activeComponents []ControlComponent

// Find all scheduled active components
for _, comp := range c.components {
if comp.IsActive(time) {
activeComponents = append(activeComponents, comp)
}
}

// Sort by priority (lower number = higher priority)
sort.Slice(activeComponents, func(i, j int) bool {
return activeComponents[i].Priority() < activeComponents[j].Priority()
})

// Return highest priority
if len(activeComponents) > 0 {
return activeComponents[0]
}

return nil // No active mode
}

Priority Guidelines

PriorityUse CaseExample Modes
0Safety/EmergencyEmergency Stop, Maintenance
1Market CommitmentsAxle Dispatch, FFR
2Economic OptimizationNIV Chase, Energy Arbitrage
3Site BenefitsPeak Avoidance, Self-Consumption
4Grid SupportImport/Export Avoidance
5PreparationToSOE, Preconditioning

Mode Transitions

Smooth Transitions

func (c *Controller) transitionMode(from, to ControlComponent) error {
if from != nil {
// Ramp down current mode
currentPower := c.getCurrentPower()
for currentPower > 1.0 {
currentPower = currentPower * 0.9
c.setPower(currentPower)
time.Sleep(1 * time.Second)
}

// Log transition
log.Printf("Transitioning from %s to %s",
from.Name(), to.Name())
}

// Activate new mode
c.activeComponent = to

return nil
}

Next Steps