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
22 changes: 8 additions & 14 deletions config.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,6 @@ THROTTLE_INTERVAL = 0
## relayed and batteries decide on their own.
#ACTIVE_CONTROL = True

## --- Smoothing ---
## EMA alpha for the grid reading (0.0-1.0). Higher = tracks load changes faster;
## lower = filters noise but adds lag that can cause overshoot with multiple batteries.
## The battery's own ramp rate already filters noise, so 0.9 works well when the
## powermeter updates at >= 1 Hz. Reduce toward 0.3 for slower powermeters.
#SMOOTH_TARGET_ALPHA = 0.9
## When |grid total| is within this band (W), the smoothed target decays toward zero
## instead of chasing noise. Keeps batteries from hunting around the zero-crossing.
## 10-30 W is sensible; 0 disables.
#DEADBAND = 20
## Maximum watts the smoothed target may change per cycle. Acts as a slew-rate limit.
## 0 disables (default). Rarely needed at high alpha.
#MAX_SMOOTH_STEP = 0

## --- Fair distribution (multi-battery balancing) ---
## Adjust each battery's target so they share the load evenly.
## Only matters with 2+ batteries.
Expand Down Expand Up @@ -149,6 +135,14 @@ THROTTLE_INTERVAL = 0
## Per-powermeter throttling override (optional)
## Shelly devices are typically fast, so throttling may not be needed
#THROTTLE_INTERVAL = 0
## Per-powermeter smoothing (optional, defaults come from [GENERAL])
## EMA alpha (0.0-1.0). Higher = tracks faster; lower = filters noise.
## 0.9 works well at >= 1 Hz; reduce toward 0.3 for slower powermeters.
#SMOOTH_TARGET_ALPHA = 0.9
## Return zeros when |grid total| < DEADBAND (W). 10-30 W sensible; 0 disables.
#DEADBAND = 20
## Max watts the smoothed target may change per cycle (slew-rate limit). 0 disables.
#MAX_SMOOTH_STEP = 0

#[TASMOTA]
#IP = 192.168.1.101
Expand Down
52 changes: 52 additions & 0 deletions src/astrameter/config/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
VZLogger,
parse_sml_obis_config,
)
from astrameter.powermeter.wrappers.smoothing import (
DeadbandPowermeter,
SmoothedPowermeter,
)

SHELLY_SECTION = "SHELLY"
TASMOTA_SECTION = "TASMOTA"
Expand Down Expand Up @@ -153,6 +157,11 @@ def read_all_powermeter_configs(
global_wait_for_next_message = config.getboolean(
"GENERAL", "WAIT_FOR_NEXT_MESSAGE", fallback=True
)
global_smooth_alpha = config.getfloat(
"GENERAL", "SMOOTH_TARGET_ALPHA", fallback=0.0
)
global_max_smooth_step = config.getfloat("GENERAL", "MAX_SMOOTH_STEP", fallback=0.0)
global_deadband = config.getfloat("GENERAL", "DEADBAND", fallback=0.0)
global_pid_kp = config.getfloat("GENERAL", "PID_KP", fallback=0.0)
global_pid_ki = config.getfloat("GENERAL", "PID_KI", fallback=0.0)
global_pid_kd = config.getfloat("GENERAL", "PID_KD", fallback=0.0)
Expand Down Expand Up @@ -199,6 +208,49 @@ def read_all_powermeter_configs(
)
powermeter = ThrottledPowermeter(powermeter, section_throttle_interval)

section_smooth_alpha = config.getfloat(
section, "SMOOTH_TARGET_ALPHA", fallback=global_smooth_alpha
)
if section_smooth_alpha > 0:
section_smooth_alpha = max(0.01, min(1.0, section_smooth_alpha))
section_max_smooth_step = config.getfloat(
section, "MAX_SMOOTH_STEP", fallback=global_max_smooth_step
)
smooth_source = (
"section-specific"
if config.has_option(section, "SMOOTH_TARGET_ALPHA")
else "global"
)
logger.info(
"Applying %s EMA smoothing (alpha=%.2f, max_step=%.0f) to %s",
smooth_source,
section_smooth_alpha,
section_max_smooth_step,
section,
)
powermeter = SmoothedPowermeter(
powermeter,
alpha=section_smooth_alpha,
max_step=section_max_smooth_step,
)

section_deadband = config.getfloat(
section, "DEADBAND", fallback=global_deadband
)
if section_deadband > 0:
deadband_source = (
"section-specific"
if config.has_option(section, "DEADBAND")
else "global"
)
logger.info(
"Applying %s deadband (%.0fW) to %s",
deadband_source,
section_deadband,
section,
)
powermeter = DeadbandPowermeter(powermeter, deadband=section_deadband)

section_pid_kp = config.getfloat(section, "PID_KP", fallback=global_pid_kp)
if section_pid_kp > 0:
pid_source = (
Expand Down
69 changes: 32 additions & 37 deletions src/astrameter/ct002/balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from astrameter.config.logger import logger

from .protocol import parse_int
from .smoother import TargetSmoother

EFFICIENCY_HYSTERESIS_FACTOR = 1.2
# Seconds to suppress saturation checks after a battery is promoted from
Expand Down Expand Up @@ -285,7 +284,7 @@ def __init__(
*,
saturation_enabled: bool = True,
clock: Callable[[], float] | None = None,
smoother: TargetSmoother | None = None,
reset_fn: Callable[[], None] | None = None,
) -> None:
self._clock = clock or time.time
self._cfg = config
Expand All @@ -298,10 +297,10 @@ def __init__(
clock=self._clock,
)
self._saturation_grace_seconds = max(0.0, saturation_grace_seconds)
# Optional: the meter smoother is reseeded after every probe
# commit / rejection so post-handoff state cannot drag in a
# stale pre-probe EMA value. Injected by CT002 at construction.
self._smoother = smoother
# Optional: called after every probe commit / rejection so
# post-handoff state cannot drag in stale pre-probe EMA values.
# Injected by CT002 at construction.
self._reset_fn = reset_fn
self._consumers: dict[str, BalancerConsumerState] = {}
self._deprioritized: set[str] = set()
self._priority: list[str] = []
Expand Down Expand Up @@ -425,7 +424,7 @@ def _commit_probe(self, reports: dict, now: float, actual: float) -> None:
actual,
)
self._invalidate_efficiency_cache()
# Reseed the meter smoother so the post-handoff balance runs
# Reset powermeter wrapper state so the post-handoff balance runs
# against a fresh baseline instead of an EMA that still carries
# pre-probe state (including the transient zero-crossing that
# happens while the candidate ramps up and the backup drops out).
Expand All @@ -434,12 +433,12 @@ def _commit_probe(self, reports: dict, now: float, actual: float) -> None:
# ``_resolve_probe_state`` which is called from
# ``_compute_efficiency_deprioritized`` from
# ``_compute_auto_target`` — the current ``compute_target`` call
# has already captured ``smoothed_target`` as a parameter, so
# the reseed here does NOT affect the current tick's target.
# It only affects the NEXT ``_compute_smooth_target`` call in
# :class:`CT002`, which is the desired semantics.
if self._smoother is not None:
self._smoother.reseed()
# has already captured ``grid_total`` as a parameter, so the
# reset here does NOT affect the current tick's target. It only
# affects the NEXT powermeter reading, which is the desired
# semantics.
if self._reset_fn is not None:
self._reset_fn()

def _reject_probe(self, now: float, reason: str) -> None:
probe = self._probe_state
Expand Down Expand Up @@ -469,11 +468,11 @@ def _reject_probe(self, now: float, reason: str) -> None:
self._invalidate_efficiency_cache()
# See _commit_probe — same rationale: force a fresh baseline
# after the probe window ends.
if self._smoother is not None:
self._smoother.reseed()
if self._reset_fn is not None:
self._reset_fn()

def _resolve_probe_state(
self, reports: dict, now: float, smoothed_target: float
self, reports: dict, now: float, grid_total: float
) -> bool:
probe = self._probe_state
if probe is None:
Expand All @@ -488,7 +487,7 @@ def _resolve_probe_state(
actual = parse_int(reports.get(probe.candidate_id, {}).get("power", 0))
desired_total = (
sum(parse_int(report.get("power", 0)) for report in reports.values())
+ smoothed_target
+ grid_total
)
probe_success_threshold = self._probe_success_threshold
demand_sign = 1 if desired_total > 0 else -1 if desired_total < 0 else 0
Expand Down Expand Up @@ -536,7 +535,7 @@ def _compute_probe_target(
self,
consumer_id: str | None,
reports: dict,
smoothed_target: float,
grid_total: float,
eff_part: dict[str, float],
) -> list[float] | None:
probe = self._probe_state
Expand All @@ -558,7 +557,7 @@ def _compute_probe_target(

desired_total = (
sum(parse_int(report.get("power", 0)) for report in reports.values())
+ smoothed_target
+ grid_total
)
state = self._get_consumer(consumer_id)
probe_actual = parse_int(reports.get(candidate_id, {}).get("power", 0))
Expand Down Expand Up @@ -614,8 +613,7 @@ def compute_target(
consumer_id: str | None,
consumer_mode: ConsumerMode,
all_reports: dict,
smoothed_target: float,
raw_total: float,
grid_total: float,
inactive: frozenset[str],
manual: frozenset[str],
sample_id: tuple = (),
Expand Down Expand Up @@ -675,9 +673,7 @@ def compute_target(
# Auto-pool reports (exclude manual consumers)
reports = {cid: r for cid, r in active_reports.items() if cid not in manual}

return self._compute_auto_target(
consumer_id, reports, smoothed_target, raw_total, sample_id
)
return self._compute_auto_target(consumer_id, reports, grid_total, sample_id)

# ------------------------------------------------------------------
# Lifecycle
Expand Down Expand Up @@ -808,8 +804,7 @@ def _compute_auto_target(
self,
consumer_id: str | None,
reports: dict,
smoothed_target: float,
raw_total: float,
grid_total: float,
sample_id: tuple = (),
) -> list[float]:
"""Automatic allocation for auto-pool consumers."""
Expand All @@ -818,15 +813,15 @@ def _compute_auto_target(
eff_part = {cid: max(0.01, 1.0 - saturation.get(cid, 0.0)) for cid in reports}

efficiency_adjustments = self._compute_efficiency_deprioritized(
reports, sample_id, smoothed_target
reports, sample_id, grid_total
)
faded_adjustments = self._fade_efficiency_weights(
efficiency_adjustments, set(reports.keys())
)
any_fading = any(0.0 < w < 1.0 for w in faded_adjustments.values())

probe_target = self._compute_probe_target(
consumer_id, reports, smoothed_target, eff_part
consumer_id, reports, grid_total, eff_part
)
if probe_target is not None:
return probe_target
Expand All @@ -842,7 +837,7 @@ def _compute_auto_target(
total_battery = sum(
parse_int(reports.get(cid, {}).get("power", 0)) for cid in reports
)
demand = total_battery + smoothed_target
demand = total_battery + grid_total
total_fade = sum(self._get_consumer(cid).fade_weight for cid in reports)
desired = demand * fade_w / total_fade if total_fade > 0 else 0.0
target = desired - reported
Expand All @@ -864,17 +859,16 @@ def _compute_auto_target(

total_effective = sum(eff_part.values())
fair_share = (
(smoothed_target / total_effective) * eff_part.get(consumer_id, 1.0)
(grid_total / total_effective) * eff_part.get(consumer_id, 1.0)
if consumer_id and consumer_id in reports
else smoothed_target / num_consumers
else grid_total / num_consumers
)

cfg = self._cfg
if (
not cfg.fair_distribution
or consumer_id is None
or consumer_id not in reports
or (cfg.deadband > 0 and abs(raw_total) < cfg.deadband)
):
target = fair_share
elif consumer_id in eff_part:
Expand All @@ -884,8 +878,9 @@ def _compute_auto_target(
else:
target = fair_share

# Clamp sign disagreement
if (raw_total < 0 and target > 0) or (raw_total > 0 and target < 0):
# Clamp sign disagreement: prevent the inverter from acting
# against the current grid direction.
if (grid_total < 0 and target > 0) or (grid_total > 0 and target < 0):
target = 0

if consumer_id:
Expand Down Expand Up @@ -938,7 +933,7 @@ def _balance_correction(
# ------------------------------------------------------------------

def _compute_efficiency_deprioritized(
self, reports: dict, sample_id: tuple, smoothed_target: float
self, reports: dict, sample_id: tuple, grid_total: float
) -> dict[str, float]:
"""Decide which consumers to deprioritize for efficiency."""
cfg = self._cfg
Expand All @@ -964,7 +959,7 @@ def _compute_efficiency_deprioritized(
0, min(len(self._priority), len(self._priority) - len(self._deprioritized))
)
previous_active = tuple(self._priority[:prev_slots])
probe_resolved = self._resolve_probe_state(reports, now, smoothed_target)
probe_resolved = self._resolve_probe_state(reports, now, grid_total)
probe_active = self._probe_state is not None

# Rotation check BEFORE cache
Expand Down Expand Up @@ -1003,7 +998,7 @@ def _compute_efficiency_deprioritized(
total_battery_power = sum(
parse_int(reports.get(cid, {}).get("power", 0)) for cid in self._priority
)
abs_target = abs(total_battery_power + smoothed_target)
abs_target = abs(total_battery_power + grid_total)
n = len(self._priority)
per_consumer = abs_target / n

Expand Down
Loading
Loading