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
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand All @@ -81,7 +123,7 @@ disabled.)
| Entity | Meaning |
|---|---|
| `select.<zone>_shade_mode` | current mode — **the only thing policy writes** |
| `sensor.<zone>_glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window` |
| `sensor.<zone>_glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window`, `constraint` (`direct` / `reflected` / `none` — what bound the position) |
| `sensor.<zone>_shade_target` | what the actuator wants; attrs: `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active`), `hold_until`, `last_command` |
| `binary_sensor.<zone>_sun_in_window` | direct sun geometrically possible now |
| `binary_sensor.<zone>_shade_hold` | a human moved a cover; engine is standing down |
Expand Down Expand Up @@ -170,7 +212,9 @@ Run in shadow mode first: configure zones, restart, and graph
`sensor.<zone>_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

Expand Down
107 changes: 95 additions & 12 deletions custom_components/shade_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import logging
import math

import voluptuous as vol

Expand All @@ -32,16 +33,19 @@
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,
CONF_AZIMUTH,
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
115 changes: 104 additions & 11 deletions custom_components/shade_engine/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""
Expand All @@ -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)."""
Expand All @@ -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)
Expand All @@ -41,19 +85,55 @@ class GlareResult:
sun_in_window: bool
gamma: float
profile_angle: float
constraint: str = "none"


def relative_azimuth(sun_azimuth: float, window_azimuth: float) -> float:
"""Signed sun-to-window azimuth difference, wrapped to [-180, 180)."""
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
Expand All @@ -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)
Loading
Loading