From 06033d314fa89be401bd0789c77acbad1443fc9b Mon Sep 17 00:00:00 2001 From: Vincent Filby Date: Mon, 17 Aug 2026 09:45:24 -0700 Subject: [PATCH] Add eye-zone model with reflected-glare constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protect_depth protects a strip of floor; the real target is eyes. New optional window config models them directly: - eye_zone: a height range and depth range to keep sun out of, replacing protect_depth (which remains as the degenerate floor-level case and is unchanged for existing configs). - reflectors: horizontal reflective surfaces (floor, counters) as height + distance span. Each adds one linear constraint by unfolding the mirror: a bounce into the eye zone is a straight ray into the zone's reflection below the surface. Constraints are clipped to the strip that can actually bounce into the zone and the patch the sun actually lights, so the escape case (bounces landing beyond the hazard) falls out for free. - sill_height: absolute heights now matter, so the bottom of the glass is a real input (default 0 keeps old behavior). The published position is the highest satisfying every constraint — non-monotonic over a day by design: high sun can close (floor bounce climbs into eyes), mid-descent opens, low sun closes again. The glare sensor gains a 'constraint' attribute (direct/reflected/none) for shadow-mode tuning. Worst-case assumptions (specular bounce, full window width) over-shade rather than under-shade; narrow a reflector's span if it binds at hours nobody experiences glare. Co-Authored-By: Claude Fable 5 --- README.md | 48 +++++++- custom_components/shade_engine/__init__.py | 107 ++++++++++++++-- custom_components/shade_engine/calculator.py | 115 ++++++++++++++++-- custom_components/shade_engine/const.py | 6 + custom_components/shade_engine/manifest.json | 2 +- custom_components/shade_engine/sensor.py | 1 + tests/test_calculator.py | 121 ++++++++++++++++++- 7 files changed, 373 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d141e15..4a959aa 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,48 @@ names are yours; each maps to either a constant position (0–100), the string `glare` (pure calculator passthrough), or a mapping with `min`/`max` clamps applied to the calculator value. +### Eye zone & reflected glare + +`protect_depth` protects a strip of floor. If your actual problem is *eyes* — +including sun that bounces off a shiny floor or countertop and up into them — +replace it with an `eye_zone` and optional `reflectors`: + +```yaml + window: + azimuth: 268 + height: 0.74 + sill_height: 0.9 # meters from floor to the bottom of the glass + eye_zone: + height: [0.8, 1.4] # meters above the floor to keep sun out of + depth: [2.0, 4.0] # meters from the window where eyes live + reflectors: + - height: 0.0 # the floor + - height: 0.75 # a countertop... + from: 0.0 # ...spanning this range of distance + to: 0.6 # from the window (omit "to" for unbounded) +``` + +All geometry is solved in the vertical plane along the sun's azimuth. Direct +glare is excluded when the steepest admitted ray passes below the eye zone +before reaching it. Each reflector adds one more constraint by mirror +symmetry: a bounce off a surface at height *r* into the zone is a straight +ray into the zone's reflection below that surface. The published position is +the highest one satisfying every constraint — which is naturally +**non-monotonic** over a day: high sun can force the shade down (floor bounce +climbs into eyes), mid-descent can open up (bounces fall short of the zone), +low sun closes again (direct rays at eye height). + +Notes: + +- `protect_depth` is exactly `eye_zone: {height: [0, x], depth: [d, inf]}` — + existing configs behave identically. Provide one of the two. +- Reflectors assume worst-case specular (mirror) bounce and full window + width. That over-shades rather than under-shades; if a reflector closes + the shade at hours nobody experiences glare, narrow its `from`/`to` span + or remove it. +- `reflectors` require an `eye_zone`, and each reflector must sit below the + zone's lower height. + ## Entities (per zone) Each zone appears as a **device** under Settings → Devices & Services → @@ -81,7 +123,7 @@ disabled.) | Entity | Meaning | |---|---| | `select._shade_mode` | current mode — **the only thing policy writes** | -| `sensor._glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window` | +| `sensor._glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window`, `constraint` (`direct` / `reflected` / `none` — what bound the position) | | `sensor._shade_target` | what the actuator wants; attrs: `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active`), `hold_until`, `last_command` | | `binary_sensor._sun_in_window` | direct sun geometrically possible now | | `binary_sensor._shade_hold` | a human moved a cover; engine is standing down | @@ -170,7 +212,9 @@ Run in shadow mode first: configure zones, restart, and graph `sensor._glare_position` against the sun for a couple of sunny days **before** pointing `covers` at anything real (or set every mode to a constant while you watch). Tune `protect_depth` until the curve drops when -glare actually reaches the spot you care about. +glare actually reaches the spot you care about. With an `eye_zone`, watch the +`constraint` attribute too: `reflected` at hours when nothing actually +bounces into your eyes means a reflector span is too generous. ## Development diff --git a/custom_components/shade_engine/__init__.py b/custom_components/shade_engine/__init__.py index 7706e32..9bd3eae 100644 --- a/custom_components/shade_engine/__init__.py +++ b/custom_components/shade_engine/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +import math import voluptuous as vol @@ -32,7 +33,7 @@ from homeassistant.util import dt as dt_util from datetime import timedelta -from .calculator import GlareResult, WindowGeometry, glare +from .calculator import EyeZone, GlareResult, Reflector, WindowGeometry, glare from .const import ( ATTR_DURATION, ATTR_ZONE, @@ -40,8 +41,11 @@ CONF_COVERS, CONF_DEADBAND, CONF_DEFAULT_MODE, + CONF_DEPTH, + CONF_EYE_ZONE, CONF_FOV_LEFT, CONF_FOV_RIGHT, + CONF_FROM, CONF_HEIGHT, CONF_HOLD_DURATION, CONF_MAX, @@ -52,7 +56,10 @@ CONF_MOTION, CONF_NAME, CONF_PROTECT_DEPTH, + CONF_REFLECTORS, CONF_SETTLE, + CONF_SILL_HEIGHT, + CONF_TO, CONF_WINDOW, CONF_ZONES, DOMAIN, @@ -101,23 +108,79 @@ def _mode_target(value): raise vol.Invalid("mode target must be a position, 'glare', or a mapping") -WINDOW_SCHEMA = vol.Schema( +def _span(value): + """Validate a [low, high] pair of non-negative meters.""" + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise vol.Invalid("expected a two-element list: [low, high]") + low, high = (vol.Coerce(float)(v) for v in value) + if low < 0: + raise vol.Invalid("values must be non-negative") + if low >= high: + raise vol.Invalid("first value must be less than the second") + return (low, high) + + +EYE_ZONE_SCHEMA = vol.Schema( { - vol.Required(CONF_AZIMUTH): vol.All(vol.Coerce(float), vol.Range(0, 360)), - vol.Optional(CONF_FOV_LEFT, default=90.0): vol.All( - vol.Coerce(float), vol.Range(0, 180) - ), - vol.Optional(CONF_FOV_RIGHT, default=90.0): vol.All( - vol.Coerce(float), vol.Range(0, 180) + vol.Required(CONF_HEIGHT): _span, + vol.Required(CONF_DEPTH): _span, + } +) + +REFLECTOR_SCHEMA = vol.Schema( + { + vol.Optional(CONF_HEIGHT, default=0.0): vol.All( + vol.Coerce(float), vol.Range(min=0.0) ), - vol.Required(CONF_HEIGHT): vol.All(vol.Coerce(float), vol.Range(min=0.05)), - vol.Required(CONF_PROTECT_DEPTH): vol.All( + vol.Optional(CONF_FROM, default=0.0): vol.All( vol.Coerce(float), vol.Range(min=0.0) ), - vol.Optional(CONF_MIN_ELEVATION, default=0.0): vol.Coerce(float), + vol.Optional(CONF_TO): vol.All(vol.Coerce(float), vol.Range(min=0.0)), } ) + +def _validate_window(win: dict) -> dict: + """Cross-field rules the per-key schemas can't express.""" + if CONF_PROTECT_DEPTH not in win and CONF_EYE_ZONE not in win: + raise vol.Invalid("window needs protect_depth or eye_zone") + if win[CONF_REFLECTORS] and CONF_EYE_ZONE not in win: + raise vol.Invalid("reflectors require an eye_zone to protect") + for ref in win[CONF_REFLECTORS]: + if CONF_TO in ref and ref[CONF_TO] <= ref[CONF_FROM]: + raise vol.Invalid("reflector 'to' must be greater than 'from'") + if ref[CONF_HEIGHT] >= win[CONF_EYE_ZONE][CONF_HEIGHT][0]: + raise vol.Invalid("reflector must sit below the eye_zone") + return win + + +WINDOW_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_AZIMUTH): vol.All(vol.Coerce(float), vol.Range(0, 360)), + vol.Optional(CONF_FOV_LEFT, default=90.0): vol.All( + vol.Coerce(float), vol.Range(0, 180) + ), + vol.Optional(CONF_FOV_RIGHT, default=90.0): vol.All( + vol.Coerce(float), vol.Range(0, 180) + ), + vol.Required(CONF_HEIGHT): vol.All(vol.Coerce(float), vol.Range(min=0.05)), + vol.Optional(CONF_PROTECT_DEPTH): vol.All( + vol.Coerce(float), vol.Range(min=0.0) + ), + vol.Optional(CONF_MIN_ELEVATION, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SILL_HEIGHT, default=0.0): vol.All( + vol.Coerce(float), vol.Range(min=0.0) + ), + vol.Optional(CONF_EYE_ZONE): EYE_ZONE_SCHEMA, + vol.Optional(CONF_REFLECTORS, default=[]): vol.All( + cv.ensure_list, [REFLECTOR_SCHEMA] + ), + } + ), + _validate_window, +) + MOTION_SCHEMA = vol.Schema( { vol.Optional(CONF_DEADBAND, default=3): cv.positive_int, @@ -153,13 +216,33 @@ def __init__(self, zone_id: str, conf: dict) -> None: self.zone_id = zone_id self.name: str = conf.get(CONF_NAME) or zone_id.replace("_", " ").title() win = conf[CONF_WINDOW] + eye = win.get(CONF_EYE_ZONE) self.geometry = WindowGeometry( azimuth=win[CONF_AZIMUTH], fov_left=win[CONF_FOV_LEFT], fov_right=win[CONF_FOV_RIGHT], height=win[CONF_HEIGHT], - protect_depth=win[CONF_PROTECT_DEPTH], + protect_depth=win.get(CONF_PROTECT_DEPTH, 0.0), min_elevation=win[CONF_MIN_ELEVATION], + sill_height=win[CONF_SILL_HEIGHT], + eye_zone=( + EyeZone( + low=eye[CONF_HEIGHT][0], + high=eye[CONF_HEIGHT][1], + near=eye[CONF_DEPTH][0], + far=eye[CONF_DEPTH][1], + ) + if eye + else None + ), + reflectors=tuple( + Reflector( + height=ref[CONF_HEIGHT], + start=ref[CONF_FROM], + end=ref.get(CONF_TO, math.inf), + ) + for ref in win[CONF_REFLECTORS] + ), ) modes: dict[str, ModeTarget] = conf[CONF_MODES] default_mode = conf.get(CONF_DEFAULT_MODE) or next(iter(modes)) diff --git a/custom_components/shade_engine/calculator.py b/custom_components/shade_engine/calculator.py index 2ab3750..542c174 100644 --- a/custom_components/shade_engine/calculator.py +++ b/custom_components/shade_engine/calculator.py @@ -3,14 +3,36 @@ No Home Assistant imports — this module is importable and testable anywhere. Model: a vertical shade drops from the top of the glass. The uncovered -opening at the bottom (height ``h``) admits direct sun that penetrates the -room a horizontal distance ``x = h / tan(profile_angle)``, where the profile -angle is the sun's elevation projected onto the window's normal plane. -Solving for the largest opening that keeps penetration at or under the -protected depth ``d``: +opening at the bottom (height ``h`` above the sill) admits direct sun. All +geometry is worked in the vertical plane along the sun's azimuth, using the +profile angle (the sun's elevation projected onto the window's normal plane), +so a single angle ``t = tan(profile)`` drives everything. - h = d * tan(profile_angle) - position% = clamp(h / glass_height, 0, 1) * 100 +Two protection models, chosen per window: + +* ``protect_depth`` (legacy): keep direct sun off the floor past depth ``d``. + The steepest admitted ray grazes the shade's bottom edge, so + ``h = d * t``. + +* ``eye_zone``: keep sun out of a rectangle of room — heights + ``[low, high]`` above the floor across distances ``[near, far]`` from the + window. Direct sun is excluded when the shade-edge ray is already below + the zone on arrival:: + + sill + h - near * t <= low + + Reflections off horizontal surfaces (floor, counters) are handled by + unfolding the mirror: a ray bouncing off a plane at height ``r`` into the + zone is a straight ray into the zone's mirror image at heights + ``2r - [high, low]``. Each reflector contributes one more linear + constraint, clipped to the strip of the reflector that can actually bounce + into the zone and to the patch of it the sun actually lights (which gives + the "escape" case for free: when the sunlit patch lands beyond the hazard + strip, the bounce rises past the zone and the constraint vanishes). + +The published position is the highest one satisfying every constraint: + + position% = clamp(min(h_direct, h_reflector...) / glass_height, 0, 1) * 100 Position 100 is fully open, 0 fully closed (Home Assistant convention). """ @@ -21,6 +43,25 @@ from dataclasses import dataclass +@dataclass(frozen=True) +class EyeZone: + """Region to keep sun out of: heights [low, high] over depths [near, far].""" + + low: float + high: float + near: float + far: float = math.inf + + +@dataclass(frozen=True) +class Reflector: + """A horizontal reflective surface spanning [start, end] from the window.""" + + height: float = 0.0 + start: float = 0.0 + end: float = math.inf + + @dataclass(frozen=True) class WindowGeometry: """Static geometry for one window (or a bank of identical windows).""" @@ -31,6 +72,9 @@ class WindowGeometry: height: float = 1.0 protect_depth: float = 1.0 min_elevation: float = 0.0 + sill_height: float = 0.0 + eye_zone: EyeZone | None = None + reflectors: tuple[Reflector, ...] = () @dataclass(frozen=True) @@ -41,6 +85,7 @@ class GlareResult: sun_in_window: bool gamma: float profile_angle: float + constraint: str = "none" def relative_azimuth(sun_azimuth: float, window_azimuth: float) -> float: @@ -48,12 +93,47 @@ def relative_azimuth(sun_azimuth: float, window_azimuth: float) -> float: return ((sun_azimuth - window_azimuth + 180.0) % 360.0) - 180.0 +def _open_direct(t: float, geo: WindowGeometry) -> float: + """Max opening keeping direct sun out of the eye zone (inf if unconstrained).""" + ez = geo.eye_zone + if ez is None: + return geo.protect_depth * t + # The beam's lowest ray grazes the sill; if it still clears the zone's top + # at the far edge, every admitted ray passes over the zone entirely. + if geo.sill_height - ez.far * t >= ez.high: + return math.inf + return (ez.low - geo.sill_height) + ez.near * t + + +def _open_reflected(t: float, geo: WindowGeometry, ref: Reflector) -> float: + """Max opening keeping bounced sun out of the eye zone (inf if unconstrained).""" + ez = geo.eye_zone + r = ref.height + if r >= ez.low: + # A reflector at or above the zone can't bounce up into it in this model. + return math.inf + # Bounce points on the plane whose reflected ray crosses the zone. + strip_lo = max(ez.near - (ez.high - r) / t, ref.start, 0.0) + strip_hi = min(ez.far - (ez.low - r) / t, ref.end) + if strip_hi <= strip_lo: + return math.inf + # Nearest sunlit point on the plane is fixed by the sill, not the shade: + # if even that lands beyond the hazard strip, no opening can light it. + patch_start = max(geo.sill_height - r, 0.0) / t + if patch_start >= strip_hi: + return math.inf + # Otherwise the shade-edge ray must land at or before the strip: + # (sill + h - r) / t <= strip_lo. + return (r - geo.sill_height) + strip_lo * t + + def glare(sun_azimuth: float, sun_elevation: float, geo: WindowGeometry) -> GlareResult: - """Highest shade position that keeps direct sun off the protected depth. + """Highest shade position that keeps sun out of the protected region. Returns position 100 (fully open) whenever direct sun cannot reach the window: below the elevation floor, outside the field of view, or grazing - the glass at nearly 90 degrees. + the glass at nearly 90 degrees. The ``constraint`` field names what bound + the result: ``direct``, ``reflected``, or ``none``. """ gamma = relative_azimuth(sun_azimuth, geo.azimuth) in_fov = -geo.fov_left <= gamma <= geo.fov_right @@ -68,6 +148,19 @@ def glare(sun_azimuth: float, sun_elevation: float, geo: WindowGeometry) -> Glar profile = math.degrees( math.atan2(math.tan(math.radians(sun_elevation)), cos_gamma) ) - open_height = geo.protect_depth * math.tan(math.radians(profile)) + t = math.tan(math.radians(profile)) + + if t <= 1e-9: + open_height, constraint = 0.0, "direct" + else: + open_height, constraint = _open_direct(t, geo), "direct" + if geo.eye_zone is not None: + for ref in geo.reflectors: + bounce = _open_reflected(t, geo, ref) + if bounce < open_height: + open_height, constraint = bounce, "reflected" + fraction = max(0.0, min(1.0, open_height / geo.height)) - return GlareResult(round(fraction * 100), True, gamma, profile) + if fraction >= 1.0: + constraint = "none" + return GlareResult(round(fraction * 100), True, gamma, profile, constraint) diff --git a/custom_components/shade_engine/const.py b/custom_components/shade_engine/const.py index 1688755..5ebd9aa 100644 --- a/custom_components/shade_engine/const.py +++ b/custom_components/shade_engine/const.py @@ -12,6 +12,12 @@ CONF_HEIGHT = "height" CONF_PROTECT_DEPTH = "protect_depth" CONF_MIN_ELEVATION = "min_elevation" +CONF_SILL_HEIGHT = "sill_height" +CONF_EYE_ZONE = "eye_zone" +CONF_DEPTH = "depth" +CONF_REFLECTORS = "reflectors" +CONF_FROM = "from" +CONF_TO = "to" CONF_MODES = "modes" CONF_DEFAULT_MODE = "default_mode" CONF_MOTION = "motion" diff --git a/custom_components/shade_engine/manifest.json b/custom_components/shade_engine/manifest.json index 050aa5f..b52b309 100644 --- a/custom_components/shade_engine/manifest.json +++ b/custom_components/shade_engine/manifest.json @@ -10,5 +10,5 @@ "issue_tracker": "https://github.com/vfilby/shade-engine/issues", "requirements": [], "single_config_entry": true, - "version": "0.2.0" + "version": "0.3.0" } diff --git a/custom_components/shade_engine/sensor.py b/custom_components/shade_engine/sensor.py index 43b44d0..91fdc21 100644 --- a/custom_components/shade_engine/sensor.py +++ b/custom_components/shade_engine/sensor.py @@ -69,6 +69,7 @@ def extra_state_attributes(self) -> dict: "sun_in_window": self._zone.glare.sun_in_window, "gamma": round(self._zone.glare.gamma, 1), "profile_angle": round(self._zone.glare.profile_angle, 1), + "constraint": self._zone.glare.constraint, } diff --git a/tests/test_calculator.py b/tests/test_calculator.py index 9e0f1f9..0b5ec03 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -1,14 +1,30 @@ """Tests for the pure glare calculator.""" +import math import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "custom_components" / "shade_engine")) -from calculator import WindowGeometry, glare, relative_azimuth # noqa: E402 +from calculator import ( # noqa: E402 + EyeZone, + Reflector, + WindowGeometry, + glare, + relative_azimuth, +) WEST = WindowGeometry(azimuth=268, height=0.74, protect_depth=1.7) +# A tall patio-door style window: glass from floor to 2 m, eyes protected +# between 0.8 and 1.4 m high, 2-4 m into the room, shiny floor. +PATIO = WindowGeometry( + azimuth=268, + height=2.0, + eye_zone=EyeZone(low=0.8, high=1.4, near=2.0, far=4.0), + reflectors=(Reflector(height=0.0),), +) + def test_relative_azimuth_wraps(): assert relative_azimuth(270, 268) == 2 @@ -73,3 +89,106 @@ def test_grazing_angle_is_open(): def test_position_monotonic_in_elevation(): positions = [glare(268, el, WEST).position for el in range(1, 60)] assert positions == sorted(positions) + + +# -- eye zone + reflections -------------------------------------------------- + + +def test_eye_zone_floor_matches_protect_depth(): + # eye_zone with low=0, near=protect_depth, far=inf is the legacy model. + eye = WindowGeometry( + azimuth=268, + height=0.74, + eye_zone=EyeZone(low=0.0, high=1.4, near=1.7), + ) + for elevation in range(1, 80, 3): + assert glare(268, elevation, eye).position == glare(268, elevation, WEST).position + + +def test_high_sun_reflection_closes(): + # At 45 degrees direct sun stops well short of the eye zone, but the + # floor bounce climbs back into it: mirror formula near*t - high = 0.6 m. + result = glare(268, 45, PATIO) + assert result.position == 30 + assert result.constraint == "reflected" + + no_reflector = WindowGeometry( + azimuth=268, + height=2.0, + eye_zone=EyeZone(low=0.8, high=1.4, near=2.0, far=4.0), + ) + assert glare(268, 45, no_reflector).position == 100 + + +def test_daily_pattern_is_non_monotonic(): + # Glare when high (reflection), better mid-descent (bounce falls short of + # the zone), tightening again as the sun drops toward eye level. + high = glare(268, 45, PATIO) + mid = glare(268, 10, PATIO) + assert high.position == 30 and high.constraint == "reflected" + assert mid.position == 58 and mid.constraint == "direct" + assert mid.position > high.position + + +def test_counter_reflector_extent(): + counter_zone = EyeZone(low=1.0, high=1.6, near=2.0, far=4.0) + under_window = WindowGeometry( + azimuth=268, + height=2.0, + eye_zone=counter_zone, + reflectors=(Reflector(height=0.9, start=0.0, end=0.6),), + ) + elevation = math.degrees(math.atan(0.3)) + result = glare(268, elevation, under_window) + assert result.position == 45 + assert result.constraint == "reflected" + + # Same counter moved deep into the room: the lit patch that matters is + # farther out, the bounce constraint loosens past direct, direct binds. + deep = WindowGeometry( + azimuth=268, + height=2.0, + eye_zone=counter_zone, + reflectors=(Reflector(height=0.9, start=3.0, end=3.5),), + ) + result = glare(268, elevation, deep) + assert result.position == 80 + assert result.constraint == "direct" + + +def test_sill_escape_branch(): + # Counter-height sill: at 20 degrees the nearest sunlit floor point is + # already beyond the hazard strip, so the reflection constraint vanishes; + # at 30 degrees the strip is lit and even a crack of opening bounces in. + kitchen = WindowGeometry( + azimuth=268, + height=0.74, + sill_height=0.9, + eye_zone=EyeZone(low=0.8, high=1.4, near=2.0, far=4.0), + reflectors=(Reflector(height=0.0),), + ) + escaped = glare(268, 20, kitchen) + assert escaped.position == 85 + assert escaped.constraint == "direct" + + lit = glare(268, 30, kitchen) + assert lit.position == 0 + assert lit.constraint == "reflected" + + +def test_reflector_above_eye_zone_is_ignored(): + zone = EyeZone(low=0.8, high=1.4, near=2.0, far=4.0) + base = WindowGeometry(azimuth=268, height=2.0, eye_zone=zone) + shelved = WindowGeometry( + azimuth=268, + height=2.0, + eye_zone=zone, + reflectors=(Reflector(height=1.0),), + ) + for elevation in range(1, 80, 3): + assert glare(268, elevation, shelved) == glare(268, elevation, base) + + +def test_constraint_attribute_when_unbound(): + assert glare(268, -5, WEST).constraint == "none" + assert glare(268, 60, WEST).constraint == "none" # clamps fully open