Skip to content

Commit e7888b1

Browse files
Start time function expressions (#56)
* Add support for constraint functions to bound dynamic start times. `notBefore`, `notAfter`, `clamp`, `min`, `max` * Add mix() function for weighted blending of solar times and fixed times * Add implementation of smooth() for exponentially smoothing a time expression over past days
1 parent 7dd197f commit e7888b1

8 files changed

Lines changed: 725 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1+
## [0.16.0] - 2026-04-06
2+
3+
### Added
4+
- **Start time function expressions** (#48): New composable functions for constraining and blending solar and fixed times in start time expressions. All arguments may be fixed times, sun keywords (with optional offset), or nested function calls.
5+
- `notBefore(expr, min)` — ensures the result is not earlier than `min`; returns the latter of the two. Alias: `max(a, b)`.
6+
- `notAfter(expr, max)` — ensures the result is not later than `max`; returns the earlier of the two. Alias: `min(a, b)`.
7+
- `clamp(expr, min, max)` — constrains the result to the `[min, max]` range.
8+
- `mix(a, b, weight)` — linearly interpolates between two times with the given `weight` in `[0..1]` or as a percentage (`0%..100%`). A weight of `1` returns `a`, `0` returns `b`. Example: `mix(sunrise, 08:00, 0.35)` blends toward a fixed anchor, softening seasonal drift.
9+
- `smooth(expr, halfLife)` — exponentially smooths a time expression over past days to reduce day-to-day variation. `halfLife` specifies how many days it takes for the smoothing influence to halve (e.g. `14d`). Useful for softening sharp seasonal sunrise/sunset swings. Example: `smooth(sunrise, 14d)`.
10+
- Functions are case-insensitive and fully composable, e.g. `clamp(smooth(sunrise, 14d), 07:00, 09:00)`.
11+
- Full documentation is available in the [Light Configuration docs](/docs/light_configuration.md#constraint-functions).
12+
113
## [0.15.0] - 2026-03-18
214

315
### Added
4-
- **Scene scheduling for groups** (`scene:<name>`) (#47):
16+
- **Scene scheduling for groups** (`scene:<name>`) (#29):
517
- Load per-light states from an existing Hue scene and schedule them as a group state. Each light retains its individual brightness, color temperature, color, effect, and gradient settings.
618
- Auto-reloads the light states on scene changes.
719
- Proportional brightness scaling with `bri` (e.g., `bri:50%` dims all lights to half; values above `100%` boost proportionally, capped per light).

docs/light_configuration.md

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Each state has a start time, specified either as a fixed time (24-hour `HH:mm[:s
6060

6161
These times vary by location and date. To see your current values, start Hue Scheduler with an empty input file.
6262

63+
> Note: Background on twilight terms: [Twilight - Wikipedia](https://en.wikipedia.org/wiki/Twilight) and [Twilight - commons-suncalc](https://shredzone.org/maven/commons-suncalc/usage.html#twilight).
64+
6365
You can also **offset** solar times:
6466

6567
```yacas
@@ -68,7 +70,91 @@ You can also **offset** solar times:
6870

6971
Examples: `sunset-30` (30 minutes before sunset), `sunrise+60` (one hour after sunrise). Offsets update daily with the sun.
7072

71-
> Note: Background on twilight terms: [Twilight - Wikipedia](https://en.wikipedia.org/wiki/Twilight) and [Twilight - commons-suncalc](https://shredzone.org/maven/commons-suncalc/usage.html#twilight).
73+
### Constraint Functions
74+
75+
You can wrap any start time expression in a **constraint function** to bound dynamic solar times to fixed limits. This is useful when sunrise or sunset varies too much across seasons.
76+
77+
Available functions:
78+
79+
| Function | Args | Returns |
80+
|--------------------------|------|----------------------------------------------------------------------------------------------------------------------------------|
81+
| `notBefore(expr, limit)` | 2 | The **later** of `expr` and `limit` (ensures start is not before `limit`). E.g. `notBefore(sunrise, 06:30)` |
82+
| `notAfter(expr, limit)` | 2 | The **earlier** of `expr` and `limit` (ensures start is not after `limit`). E.g. `notAfter(sunset+30, 21:00)` |
83+
| `clamp(expr, min, max)` | 3 | `expr` bounded to `[min, max]`; if `min > max`, logs a warning and returns `expr` unchanged. E.g. `clamp(sunrise, 06:30, 08:00)` |
84+
| `max(a, b)` | 2 | Alias for `notBefore` — returns the later of two times |
85+
| `min(a, b)` | 2 | Alias for `notAfter` — returns the earlier of two times |
86+
| `mix(a, b, w)` | 3 | **Experimental**: Places the time between `a` and `b` using weight `w` (`0..1` or `%`). E.g. `mix(sunrise, 07:30, 35%)` |
87+
| `smooth(expr, halfLife)` | 2 | **Experimental**: Smooths `expr` by averaging it over past days. E.g. `smooth(sunrise, 14d)` |
88+
89+
Each argument can be a fixed time (`HH:mm[:ss]`), a solar keyword with optional offset, or another nested function call.
90+
91+
Function names are **case-insensitive**. Whitespace inside arguments is trimmed.
92+
93+
Further examples:
94+
95+
```
96+
# Ensure lights don't turn on before 06:30 even in summer when sunrise is early
97+
Kitchen notBefore(sunrise, 06:30) bri:100%
98+
99+
# Cap sunset-based scheduling to no later than 21:00
100+
Porch notAfter(sunset+30, 21:00) bri:80%
101+
102+
# Keep sunrise between 06:30 and 08:00 year-round
103+
Office clamp(sunrise, 06:30, 08:00) bri:100%
104+
105+
# Equivalent using min/max aliases
106+
Office min(max(sunrise, 06:30), 08:00) bri:100%
107+
108+
# Nested functions
109+
Hallway notAfter(notBefore(sunrise, 06:30), 08:00) bri:100%
110+
111+
# Experimental: blend sunrise with a fixed anchor to reduce seasonal swings
112+
Kitchen mix(sunrise, 07:30, 0.35) bri:40%
113+
114+
# Blend two solar times directly
115+
Living room mix(golden_hour, sunset, 0.5) bri:45%
116+
117+
# Morning routine: smooth + bounded
118+
Bedroom clamp(mix(sunrise, 07:30, 0.35), 06:30, 08:00) bri:30%
119+
120+
# Evening routine: follow sunset, but dampened and bounded
121+
Living room clamp(mix(sunset+30, 22:30, 0.5), 19:00, 23:00) bri:45%
122+
123+
# Pure-solar smoothing (no fixed anchor), then practical bounds
124+
Bedroom clamp(smooth(sunrise, 14d), 06:30, 08:00) bri:30%
125+
```
126+
127+
#### Experimental: `mix(...)` — blend two time expressions
128+
129+
`mix(a, b, w)` places the trigger time between two time expressions `a` and `b`. The weight `w` controls how close the result is to `a`:
130+
131+
- `w = 1` → exactly `a`
132+
- `w = 0` → exactly `b`
133+
- `w = 0.5` → midpoint between `a` and `b`
134+
135+
A common use is blending a solar time with a fixed clock time: `mix(sunrise, 07:30, 0.35)` takes sunrise but pulls it 65% toward `07:30` — so the schedule still moves with the seasons, but much more gently. You can also blend two solar times directly: `mix(golden_hour, sunset, 0.5)`. The order of `a` and `b` does not matter — the result is always between the two.
136+
137+
**Why use it:** If you like solar-based schedules but want them to behave more like a stable routine (e.g., "around 07:30, but still season-aware"), `mix` **narrows the seasonal range**. However, since both values are recalculated fresh each day, day-to-day jumps are not smoothed out.
138+
139+
#### Experimental: `smooth(...)` — keep it fully solar, but slow down seasonal swings
140+
141+
`smooth(expr, halfLife)` keeps the schedule 100% solar-based, but smooths out rapid seasonal changes by averaging the solar time over past days — more recent days count more, older days fade out.
142+
143+
- `halfLife` is in days (e.g., `14d`, `14`) and controls how "inert" the time is. With `halfLife = 14d`, the value from ~14 days ago still contributes about half as much as today's; older days fade out quickly
144+
- Larger half-life → smoother, slower movement; smaller → more responsive
145+
146+
**Why use it:** If sunrise/sunset schedules feel like they shift too quickly in spring and autumn, `smooth` addresses that directly — without introducing a fixed routine time. The schedule still tracks the seasons, just more gradually. Over time, it will still reach the full seasonal extreme. To add hard limits, wrap in `clamp`: `clamp(smooth(sunrise, 14d), 06:30, 08:00)`.
147+
148+
#### Choosing between `mix` and `smooth`
149+
150+
| | `mix(a, b, w)` | `smooth(expr, halfLife)` |
151+
|-----------------------------------|------------------------------------------|------------------------------------------|
152+
| **How it works** | Blends two expressions evaluated *today* | Averages one expression over past days |
153+
| **Requires a second expression?** | Yes (`b`) | No |
154+
| **Reduces seasonal range?** | Yes — pulls toward `b` | No — eventually reaches the true extreme |
155+
| **Reduces day-to-day jumps?** | No — recalculated fresh each day | Yes — changes gradually |
156+
157+
**Rule of thumb:** Use `mix` to narrow the seasonal range; use `smooth` to slow day-to-day changes. Combine them — or add `clamp` — for maximum control.
72158

73159
### FAQ: How is the end of a state determined?
74160

@@ -274,4 +360,3 @@ Desk 17:00 gradient:[oklch(0.7 0.2 30), #00FF00, oklch(0.5 0.15 270)]@random_p
274360
From 0.14.0, setting `force:true` with `on:true` also forces the light to be **always on**.
275361

276362
With `--require-scene-activation`, `force:true` still applies the state even if a synced scene wasn't activated.
277-

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>at.sv.hue</groupId>
88
<artifactId>hue-scheduler</artifactId>
9-
<version>0.15.0</version>
9+
<version>0.16.0-SNAPSHOT</version>
1010

1111
<scm>
1212
<connection>scm:git:git@github.com:stefanvictora/hue-scheduler.git</connection>

src/main/java/at/sv/hue/HueScheduler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161

6262
import static at.sv.hue.InputConfigurationParser.parseBrightnessPercentValue;
6363

64-
@Command(name = "HueScheduler", version = "0.15.0", mixinStandardHelpOptions = true, sortOptions = false)
64+
@Command(name = "HueScheduler", version = "0.16.0-SNAPSHOT", mixinStandardHelpOptions = true, sortOptions = false)
6565
public final class HueScheduler implements Runnable {
6666

6767
private static final Logger LOG = LoggerFactory.getLogger(HueScheduler.class);

src/main/java/at/sv/hue/time/StartTimeProvider.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@
44

55
public interface StartTimeProvider {
66
/**
7-
* @param input a ISO_LOCAL_TIME formatted string, or a sun keyword with optional offset
7+
* @param input one of the following start time expressions:
8+
* <ul>
9+
* <li>a {@link java.time.format.DateTimeFormatter#ISO_LOCAL_TIME ISO_LOCAL_TIME} formatted string</li>
10+
* <li>a supported sun keyword (optionally with minute offset using + or -)</li>
11+
* <li>a function expression with syntax {@code functionName(arg1,arg2,...)} where arguments may be
12+
* fixed times, sun keywords, keyword offsets, or nested function expressions</li>
13+
* </ul>
14+
* Supported function names are {@code notBefore}, {@code notAfter}, {@code clamp}, {@code min}, {@code max},
15+
* {@code mix}, and {@code smooth}. The {@code mix(a,b,w)} function interpolates between {@code a} and {@code b}
16+
* with weight {@code w} where {@code w} can be in {@code [0..1]} or as percentage ({@code 0%..100%}).
17+
* The {@code smooth(expr,halfLife)} function exponentially smooths a time expression across past days,
18+
* where {@code halfLife} is specified in days (e.g. {@code 14d}).
819
* @param dateTime the date to use a reference for resolving solar times
920
* @return the start time corresponding to the input and dateTime
1021
* @throws InvalidStartTimeExpression if the input is neither a valid {@link java.time.format.DateTimeFormatter#ISO_LOCAL_TIME}
11-
* or a supported sun keyword with optional offset.
22+
* nor a supported sun keyword expression, nor a supported function expression.
1223
*/
1324
ZonedDateTime getStart(String input, ZonedDateTime dateTime);
1425

0 commit comments

Comments
 (0)