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