Skip to content

Commit ba4950b

Browse files
authored
feat: opt-in sun-tracking movement minimization (coverage steps) (#555)
* feat: opt-in sun-tracking movement minimization (coverage steps) * feat: surface minimize-movements in config summary screen
1 parent d02866b commit ba4950b

21 files changed

Lines changed: 566 additions & 16 deletions

custom_components/adaptive_cover_pro/config_dynamic.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@
4444
CONF_IS_SUNNY_SENSOR,
4545
CONF_LUX_ENTITY,
4646
CONF_LUX_THRESHOLD,
47+
CONF_MAX_COVERAGE_STEPS,
4748
CONF_MAX_ELEVATION,
4849
CONF_MIN_ELEVATION,
50+
CONF_MINIMIZE_MOVEMENTS,
4951
CONF_OUTSIDE_THRESHOLD,
5052
CONF_OUTSIDETEMP_ENTITY,
5153
CONF_PRESENCE_ENTITY,
@@ -71,6 +73,8 @@
7173
CONF_WINTER_CLOSE_INSULATION,
7274
DEFAULT_CLOUD_COVERAGE_THRESHOLD,
7375
DEFAULT_GLARE_ZONE_Z,
76+
DEFAULT_MAX_COVERAGE_STEPS,
77+
DEFAULT_MINIMIZE_MOVEMENTS,
7478
DEFAULT_WEATHER_RAIN_THRESHOLD,
7579
DEFAULT_WEATHER_TIMEOUT,
7680
DEFAULT_WEATHER_WIND_DIRECTION_TOLERANCE,
@@ -170,6 +174,19 @@ def sun_tracking_schema(hass: HomeAssistant | None = None) -> vol.Schema:
170174
vol.Optional(
171175
CONF_ENABLE_BLIND_SPOT, default=False
172176
): selector.BooleanSelector(),
177+
vol.Optional(
178+
CONF_MINIMIZE_MOVEMENTS, default=DEFAULT_MINIMIZE_MOVEMENTS
179+
): selector.BooleanSelector(),
180+
vol.Optional(
181+
CONF_MAX_COVERAGE_STEPS, default=DEFAULT_MAX_COVERAGE_STEPS
182+
): selector.NumberSelector(
183+
selector.NumberSelectorConfig(
184+
min=1,
185+
max=10,
186+
step=1,
187+
mode=selector.NumberSelectorMode.SLIDER,
188+
)
189+
),
173190
}
174191
)
175192

custom_components/adaptive_cover_pro/config_fields.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@
9696
CONF_MANUAL_OVERRIDE_DURATION,
9797
CONF_MANUAL_OVERRIDE_RESET,
9898
CONF_MANUAL_THRESHOLD,
99+
CONF_MAX_COVERAGE_STEPS,
99100
CONF_MAX_ELEVATION,
100101
CONF_MAX_POSITION,
101102
CONF_MAX_TILT,
102103
CONF_MIN_ELEVATION,
103104
CONF_MIN_POSITION,
104105
CONF_MIN_POSITION_SUN_TRACKING,
105106
CONF_MIN_TILT,
107+
CONF_MINIMIZE_MOVEMENTS,
106108
CONF_MOTION_MEDIA_PLAYERS,
107109
CONF_MOTION_SENSORS,
108110
CONF_MOTION_TIMEOUT,
@@ -159,6 +161,8 @@
159161
DEFAULT_CLOUD_COVERAGE_THRESHOLD,
160162
DEFAULT_DEBUG_EVENT_BUFFER_SIZE,
161163
DEFAULT_ENABLE_MY_POSITION_ENTITIES,
164+
DEFAULT_MAX_COVERAGE_STEPS,
165+
DEFAULT_MINIMIZE_MOVEMENTS,
162166
DEFAULT_MOTION_TIMEOUT,
163167
DEFAULT_MOTION_TIMEOUT_MODE,
164168
DEFAULT_TRANSIT_TIMEOUT_SECONDS,
@@ -481,6 +485,21 @@ def _spec(*specs: FieldSpec) -> list[FieldSpec]:
481485
default=False,
482486
make_selector=_bool(),
483487
),
488+
FieldSpec(
489+
CONF_MINIMIZE_MOVEMENTS,
490+
SECTION_SUN_TRACKING,
491+
ValidatorKind.BOOL,
492+
default=DEFAULT_MINIMIZE_MOVEMENTS,
493+
make_selector=_bool(),
494+
),
495+
FieldSpec(
496+
CONF_MAX_COVERAGE_STEPS,
497+
SECTION_SUN_TRACKING,
498+
ValidatorKind.RANGE,
499+
rng=const._RANGE_MAX_COVERAGE_STEPS,
500+
default=DEFAULT_MAX_COVERAGE_STEPS,
501+
make_selector=_number(minimum=1, maximum=10, step=1),
502+
),
484503
)
485504

486505

custom_components/adaptive_cover_pro/config_flow.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,13 @@
7474
CONF_MANUAL_OVERRIDE_DURATION,
7575
CONF_MANUAL_OVERRIDE_RESET,
7676
CONF_MANUAL_THRESHOLD,
77+
CONF_MAX_COVERAGE_STEPS,
7778
CONF_MAX_ELEVATION,
7879
CONF_MAX_POSITION,
7980
CONF_MIN_ELEVATION,
8081
CONF_MIN_POSITION,
8182
CONF_MIN_POSITION_SUN_TRACKING,
83+
CONF_MINIMIZE_MOVEMENTS,
8284
CONF_MODE,
8385
CONF_MOTION_MEDIA_PLAYERS,
8486
CONF_MOTION_SENSORS,
@@ -1345,6 +1347,17 @@ def _offset_str(minutes: int) -> str:
13451347
f"☀️ Tracks the sun{sun_desc} and calculates position to block "
13461348
f"direct sunlight{today_str}{_badge(40)}"
13471349
)
1350+
if config.get(CONF_MINIMIZE_MOVEMENTS, False):
1351+
steps = int(config.get(CONF_MAX_COVERAGE_STEPS, 1))
1352+
indent = "\u00a0" * 4
1353+
if steps <= 1:
1354+
detail = "moves straight to full coverage and holds (1 step)"
1355+
else:
1356+
detail = f"reaches full coverage in up to {steps} steps"
1357+
lines.append(
1358+
f"{indent}🪟 Minimize movements — {detail}, rounding toward more "
1359+
"coverage to reduce motor movements."
1360+
)
13481361
else:
13491362
lines.append(
13501363
"☀️ Sun tracking disabled — covers hold position; climate, manual override, "
@@ -1669,6 +1682,8 @@ async def _get_device_name_for_entity(
16691682
CONF_MAX_ELEVATION,
16701683
CONF_DISTANCE,
16711684
CONF_ENABLE_BLIND_SPOT,
1685+
CONF_MINIMIZE_MOVEMENTS,
1686+
CONF_MAX_COVERAGE_STEPS,
16721687
}
16731688
),
16741689
"blind_spot": frozenset(

custom_components/adaptive_cover_pro/config_types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ class TrackingSlice:
268268
interp_end: Any
269269
interp_list: Any
270270
interp_list_new: Any
271+
# Opt-in sun-tracking movement minimization (quantize to N coverage levels).
272+
minimize_movements: bool = False
273+
max_coverage_steps: int = 1
271274

272275

273276
@dataclass(frozen=True, slots=True)
@@ -323,6 +326,8 @@ def from_options(cls, options: dict) -> RuntimeConfig:
323326
CONF_MANUAL_OVERRIDE_DURATION,
324327
CONF_MANUAL_OVERRIDE_RESET,
325328
CONF_MANUAL_THRESHOLD,
329+
CONF_MAX_COVERAGE_STEPS,
330+
CONF_MINIMIZE_MOVEMENTS,
326331
CONF_MOTION_MEDIA_PLAYERS,
327332
CONF_MOTION_SENSORS,
328333
CONF_MOTION_TIMEOUT,
@@ -345,6 +350,8 @@ def from_options(cls, options: dict) -> RuntimeConfig:
345350
CONF_WEATHER_WIND_SPEED_SENSOR,
346351
CONF_WEATHER_WIND_SPEED_THRESHOLD,
347352
DEFAULT_DEBUG_EVENT_BUFFER_SIZE,
353+
DEFAULT_MAX_COVERAGE_STEPS,
354+
DEFAULT_MINIMIZE_MOVEMENTS,
348355
DEFAULT_MOTION_TIMEOUT,
349356
DEFAULT_VENETIAN_BACKROTATE_PUBLISH_LAG_SECONDS,
350357
DEFAULT_VENETIAN_MODE,
@@ -373,6 +380,12 @@ def from_options(cls, options: dict) -> RuntimeConfig:
373380
interp_end=options.get(CONF_INTERP_END),
374381
interp_list=options.get(CONF_INTERP_LIST),
375382
interp_list_new=options.get(CONF_INTERP_LIST_NEW),
383+
minimize_movements=options.get(
384+
CONF_MINIMIZE_MOVEMENTS, DEFAULT_MINIMIZE_MOVEMENTS
385+
),
386+
max_coverage_steps=int(
387+
options.get(CONF_MAX_COVERAGE_STEPS, DEFAULT_MAX_COVERAGE_STEPS)
388+
),
376389
),
377390
manual_override=ManualOverrideSlice(
378391
reset=options.get(CONF_MANUAL_OVERRIDE_RESET, False),

custom_components/adaptive_cover_pro/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@
182182
CONF_ENABLE_SUN_TRACKING = "enable_sun_tracking"
183183
CONF_MIN_ELEVATION = "min_elevation" # sun must be at least this high, deg 0-90
184184
CONF_MAX_ELEVATION = "max_elevation" # tracking off above this elevation, 0-90
185+
# Opt-in movement minimization: quantize the sun-tracked position into at most
186+
# N evenly-spaced coverage levels, rounding TOWARD full coverage so protection
187+
# is never reduced. N=1 snaps straight to full coverage while the sun is in FOV.
188+
CONF_MINIMIZE_MOVEMENTS = "minimize_movements" # opt-in toggle
189+
CONF_MAX_COVERAGE_STEPS = "max_coverage_steps" # discrete coverage levels, 1-10
190+
DEFAULT_MINIMIZE_MOVEMENTS = False
191+
DEFAULT_MAX_COVERAGE_STEPS = 1
185192
# True if blind passes some light even when closed (used by glare/climate).
186193
CONF_TRANSPARENT_BLIND = "transparent_blind"
187194

@@ -874,6 +881,9 @@ class ControlStatus:
874881
# Interpolation.
875882
_RANGE_INTERP_VALUE = (0, 100) # interp start/end, percent
876883

884+
# Sun-tracking movement minimization.
885+
_RANGE_MAX_COVERAGE_STEPS = (1, 10) # CONF_MAX_COVERAGE_STEPS, discrete levels
886+
877887
# Automation timing.
878888
_RANGE_DELTA_POSITION = (1, 90) # CONF_DELTA_POSITION, percent
879889
_RANGE_DELTA_TIME = (2, 60) # CONF_DELTA_TIME, seconds

custom_components/adaptive_cover_pro/cover_types/venetian/policy.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,19 @@
2323

2424
from ...const import (
2525
CONF_INVERSE_TILT,
26+
CONF_MAX_COVERAGE_STEPS,
2627
CONF_MAX_TILT,
2728
CONF_MIN_TILT,
29+
CONF_MINIMIZE_MOVEMENTS,
2830
CONF_VENETIAN_BACKROTATE_PUBLISH_LAG,
2931
CONF_VENETIAN_MODE,
3032
CONF_VENETIAN_POST_SETTLE_HOLD,
3133
CONF_VENETIAN_TILT_SKIP_ABOVE,
3234
ControlMethod,
35+
DEFAULT_MAX_COVERAGE_STEPS,
3336
DEFAULT_MAX_TILT,
3437
DEFAULT_MIN_TILT,
38+
DEFAULT_MINIMIZE_MOVEMENTS,
3539
DEFAULT_VENETIAN_BACKROTATE_PUBLISH_LAG_SECONDS,
3640
DEFAULT_VENETIAN_MODE,
3741
DEFAULT_VENETIAN_POST_SETTLE_HOLD_SECONDS,
@@ -49,6 +53,7 @@
4953
from ...engine.covers import AdaptiveVerticalCover, VenetianCoverCalculation
5054
from ...managers.manual_override import SecondaryAxisCheck
5155
from ...pipeline.types import DecisionStep
56+
from ...position_utils import PositionConverter
5257
from .._helpers import window_dimensions_lines
5358
from ..base import (
5459
CAP_HAS_SET_POSITION,
@@ -428,6 +433,16 @@ def post_pipeline_resolve(
428433
logger=logger,
429434
)
430435
tilt = venetian_calc.tilt_for_position(result.position)
436+
# Movement minimization: quantize the slat tilt into the same number of
437+
# discrete coverage levels as the carriage position (which the solar
438+
# branch already quantized). The tilt axis closes at 0%, so full coverage
439+
# is at zero. N=1 → slats fully closed while the sun is in the FOV.
440+
if options.get(CONF_MINIMIZE_MOVEMENTS, DEFAULT_MINIMIZE_MOVEMENTS):
441+
tilt = PositionConverter.quantize_to_coverage_steps(
442+
tilt,
443+
int(options.get(CONF_MAX_COVERAGE_STEPS, DEFAULT_MAX_COVERAGE_STEPS)),
444+
full_coverage_at_zero=not self.axes[1].open_blocks_sun,
445+
)
431446
position = result.position
432447
trace = list(result.decision_trace)
433448

custom_components/adaptive_cover_pro/forecast.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,19 @@
2323

2424
from .const import (
2525
CONF_DEFAULT_HEIGHT,
26+
CONF_MAX_COVERAGE_STEPS,
27+
CONF_MINIMIZE_MOVEMENTS,
2628
DEFAULT_DEFAULT_HEIGHT,
29+
DEFAULT_MAX_COVERAGE_STEPS,
30+
DEFAULT_MINIMIZE_MOVEMENTS,
2731
EVENT_FOV_ENTER,
2832
EVENT_FOV_EXIT,
2933
EVENT_SUNRISE,
3034
EVENT_SUNSET,
3135
FORECAST_STEP_MINUTES,
3236
SUN_DATA_STEP_SECONDS,
3337
)
38+
from .position_utils import PositionConverter
3439

3540
if TYPE_CHECKING:
3641
from .coordinator import AdaptiveDataUpdateCoordinator
@@ -88,6 +93,9 @@ def build_forecast(
8893
default_position: int,
8994
now: datetime,
9095
step_minutes: int = FORECAST_STEP_MINUTES,
96+
minimize_movements: bool = False,
97+
max_coverage_steps: int = 1,
98+
full_coverage_at_zero: bool = True,
9199
) -> Forecast:
92100
"""Compute the forecast for one cover.
93101
@@ -110,6 +118,9 @@ def build_forecast(
110118
cover_factory=cover_factory,
111119
default_position=default_position,
112120
step_minutes=step_minutes,
121+
minimize_movements=minimize_movements,
122+
max_coverage_steps=max_coverage_steps,
123+
full_coverage_at_zero=full_coverage_at_zero,
113124
)
114125
events = _build_events(
115126
sun_data=sun_data, cover_factory=cover_factory, samples=samples
@@ -123,6 +134,9 @@ def _build_samples(
123134
cover_factory: Callable[[float, float], AdaptiveGeneralCover],
124135
default_position: int,
125136
step_minutes: int,
137+
minimize_movements: bool = False,
138+
max_coverage_steps: int = 1,
139+
full_coverage_at_zero: bool = True,
126140
) -> list[ForecastSample]:
127141
"""Walk the sun_data table at *step_minutes* cadence over the full calendar day.
128142
@@ -156,11 +170,14 @@ def _build_samples(
156170
# and every sample collapses to the default position (issue #516).
157171
cover.eval_time = t
158172
if cover.direct_sun_valid:
159-
samples.append(
160-
ForecastSample(
161-
t=t, position=int(cover.calculate_percentage()), handler="solar"
173+
pos = int(cover.calculate_percentage())
174+
if minimize_movements:
175+
# Mirror the live solar branch so the forecast strip matches the
176+
# quantized positions the cover will actually be commanded to.
177+
pos = PositionConverter.quantize_to_coverage_steps(
178+
pos, max_coverage_steps, full_coverage_at_zero
162179
)
163-
)
180+
samples.append(ForecastSample(t=t, position=pos, handler="solar"))
164181
else:
165182
samples.append(
166183
ForecastSample(t=t, position=int(default_position), handler="default")
@@ -308,9 +325,21 @@ def make_cover(azi: float, ele: float) -> AdaptiveGeneralCover:
308325
options=options,
309326
)
310327

328+
# Coverage direction comes from the policy's primary axis (single source of
329+
# truth): awning blocks the sun when open (full coverage at 100%), every
330+
# other cover type at 0%.
331+
full_coverage_at_zero = not coord._policy.axes[0].open_blocks_sun # noqa: SLF001
332+
311333
return build_forecast(
312334
sun_data=sun_data,
313335
cover_factory=make_cover,
314336
default_position=int(options.get(CONF_DEFAULT_HEIGHT, DEFAULT_DEFAULT_HEIGHT)),
315337
now=dt_util.now(),
338+
minimize_movements=bool(
339+
options.get(CONF_MINIMIZE_MOVEMENTS, DEFAULT_MINIMIZE_MOVEMENTS)
340+
),
341+
max_coverage_steps=int(
342+
options.get(CONF_MAX_COVERAGE_STEPS, DEFAULT_MAX_COVERAGE_STEPS)
343+
),
344+
full_coverage_at_zero=full_coverage_at_zero,
316345
)

custom_components/adaptive_cover_pro/pipeline/handlers/solar.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,14 @@ def evaluate(self, snapshot: PipelineSnapshot) -> PipelineResult | None:
2828
return None
2929

3030
position = compute_solar_position(snapshot)
31+
reason = f"sun in FOV — position {position}%"
32+
if getattr(snapshot, "minimize_movements", False):
33+
steps = getattr(snapshot, "max_coverage_steps", 1)
34+
reason += f" (coverage step, max {steps})"
3135
return PipelineResult(
3236
position=position,
3337
control_method=ControlMethod.SOLAR,
34-
reason=f"sun in FOV — position {position}%",
38+
reason=reason,
3539
raw_calculated_position=position,
3640
)
3741

custom_components/adaptive_cover_pro/pipeline/helpers.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ def compute_solar_position(snapshot: PipelineSnapshot) -> int:
5353
"""Return the sun-tracked position with all standard transforms applied.
5454
5555
1. Calls ``cover.calculate_percentage()`` (pure geometry).
56-
2. Floors the result at 1 % so open/close-only covers never close while
56+
2. Optionally quantizes the result into the configured number of discrete
57+
coverage levels (movement minimization — opt-in, rounds toward coverage).
58+
3. Floors the result at 1 % so open/close-only covers never close while
5759
the sun is still in the field of view.
58-
3. Applies the configured min/max position limits.
60+
4. Applies the configured min/max position limits.
5961
6062
Should only be called when ``snapshot.cover.direct_sun_valid`` is True.
6163
@@ -67,6 +69,13 @@ def compute_solar_position(snapshot: PipelineSnapshot) -> int:
6769
6870
"""
6971
state = int(round(snapshot.cover.calculate_percentage()))
72+
policy = getattr(snapshot, "policy", None)
73+
if getattr(snapshot, "minimize_movements", False) and policy is not None:
74+
state = PositionConverter.quantize_to_coverage_steps(
75+
state,
76+
getattr(snapshot, "max_coverage_steps", 1),
77+
full_coverage_at_zero=not policy.axes[0].open_blocks_sun,
78+
)
7079
state = max(state, 1)
7180
return apply_snapshot_limits(snapshot, state, sun_valid=True)
7281

0 commit comments

Comments
 (0)