solmate-optimizer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: solmate-optimizer
3
+ Version: 0.1.0
4
+ Summary: Dynamically adjusts EET SolMate injection profile based on hourly electricity price and weather forecast
5
+ Author: Harald Schilly
6
+ Author-email: Harald Schilly <harald.schilly@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Dist: solmate-sdk
9
+ Requires-Dist: httpx
10
+ Requires-Dist: click
11
+ Requires-Dist: plotext>=5.3.2
12
+ Requires-Dist: tzdata
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # SolMate Optimizer
17
+
18
+ Dynamically adjusts [EET SolMate](https://www.eet.energy/) solar battery injection profiles based on real-time electricity prices and weather data.
19
+
20
+ Run as a one-shot script once per hour — locally via cron, on [GCP Cloud Run](DEPLOYMENT.md), or any other scheduler.
21
+
22
+ ## Data sources
23
+
24
+ ### Electricity prices: aWATTar
25
+
26
+ [aWATTar Austria](https://www.awattar.at/) provides a **free public API** with hourly day-ahead electricity prices for the Austrian market (EPEX spot). No API key or registration needed.
27
+
28
+ - Endpoint: `GET https://api.awattar.at/v1/marketdata`
29
+ - Returns prices in EUR/MWh for the next ~24 hours
30
+ - Currently only the Austrian market is supported. Other hourly price providers (Tibber, ENTSO-E, etc.) could be added in the future.
31
+
32
+ ### Weather: OpenWeatherMap
33
+
34
+ [OpenWeatherMap](https://openweathermap.org/) provides current weather and a 5-day/3-hour forecast. Used to determine cloud coverage (current and forecast).
35
+
36
+ - **You need a free API key** — sign up at [openweathermap.org/api](https://openweathermap.org/api), the free tier is sufficient (current weather + 5-day forecast)
37
+ - The forecast is used to decide whether the battery can recharge via solar (sun expected vs. persistent overcast)
38
+
39
+ ### SolMate: solmate-sdk
40
+
41
+ The [solmate-sdk](https://github.com/eet-energy/solmate-sdk) connects to your EET SolMate via WebSocket (cloud API). Used to read battery state, read/write injection profiles, and activate the optimized profile.
42
+
43
+ ## What it does
44
+
45
+ Every run:
46
+
47
+ 1. Fetches hourly electricity prices from aWATTar (public API, no auth)
48
+ 2. Fetches current weather and forecast from OpenWeatherMap (free API key)
49
+ 3. Connects to your SolMate via solmate-sdk (cloud API, serial + password)
50
+ 4. Reads the current battery state and existing injection profiles
51
+ 5. Computes an optimized 24-hour injection profile based on price quantiles, weather, and time of day
52
+ 6. Compares with the current profile — only writes if something changed
53
+ 7. Writes and activates the profile on the SolMate (existing profiles are preserved)
54
+
55
+ ## Decision logic
56
+
57
+ The optimizer is **price-driven** — electricity prices already encode weather, demand, and time-of-day patterns:
58
+
59
+ | Priority | Condition | Injection | Reasoning |
60
+ |----------|-----------|-----------|-----------|
61
+ | 1 | Price < 0 (negative) | 0W | Grid pays consumers to take power — never inject |
62
+ | 2 | Price below P25 of 24h | 0W | Electricity is cheap, save battery for when it matters |
63
+ | 3 | Battery < 25% | 0–50W | Protect battery regardless of price |
64
+ | 4 | Price above P75 + battery OK + sun expected | 200–400W | Inject hard when it pays off and battery can recharge |
65
+ | 4 | Price above P75 but no sun coming | 100–200W | Price is high but can't recharge — be cautious |
66
+ | 5 | Middle prices, night (0–7, 22–24) | 20–50W | Baseload (fridge, standby) |
67
+ | 5 | Middle prices, daytime (7–18) | 0–50W | Let PV charge the battery |
68
+ | 5 | Middle prices, evening (18–22) | 50–120W | Cover active household consumption |
69
+
70
+ Price-based rules (priorities 1 and 2) always win over battery protection: even a low battery should not inject when prices are negative or very cheap.
71
+
72
+ ## Setup
73
+
74
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
75
+
76
+ ```bash
77
+ uv sync
78
+ ```
79
+
80
+ ## Configuration
81
+
82
+ All configuration is via environment variables:
83
+
84
+ | Variable | Required | Default | Description |
85
+ |----------|----------|---------|-------------|
86
+ | `SOLMATE_SERIAL` | yes | — | Your SolMate's serial number |
87
+ | `SOLMATE_PASSWORD` | yes | — | Your SolMate's user password |
88
+ | `OWM_API_KEY` | yes | — | OpenWeatherMap API key ([free tier](https://openweathermap.org/api) works) |
89
+ | `LOCATION_LATLON` | no | `48.2:16.37` | Latitude and longitude as `lat:lon` (default: Vienna) |
90
+ | `TIMEZONE` | no | `Europe/Vienna` | Timezone for price/weather hour matching and display (use IANA names, e.g. `Europe/Berlin`) |
91
+ | `SOLMATE_PROFILE_NAME` | no | `dynamic` | Name of the injection profile to create/update |
92
+ | `BATTERY_LOW_THRESHOLD` | no | `0.25` | Battery fraction (0–1) below which injection is throttled |
93
+ | `CLOUD_SUN_THRESHOLD` | no | `60` | Forecast cloud % below which "sun expected" for recharging |
94
+ | `MAX_WATTS` | no | `800` | SolMate max injection capacity in watts |
95
+
96
+ ## Run
97
+
98
+ ```bash
99
+ export SOLMATE_SERIAL="your-serial"
100
+ export SOLMATE_PASSWORD="your-password"
101
+ export OWM_API_KEY="your-owm-key"
102
+
103
+ uv run solmate # run optimizer (default)
104
+ uv run solmate optimize --dry-run # compute profile, don't write
105
+ uv run solmate optimize --no-activate # write but don't activate
106
+ uv run status # read-only status view
107
+ uv run status --graph # status with ASCII profile graphs
108
+ ```
109
+
110
+ ### Commands
111
+
112
+ | Command | Description |
113
+ |---------|-------------|
114
+ | `solmate` | Run the optimizer (default, no subcommand needed) |
115
+ | `solmate optimize` | Explicit optimizer subcommand |
116
+ | `solmate optimize --dry-run` | Compute and display profile, but don't write or activate it |
117
+ | `solmate optimize --no-activate` | Write the profile to SolMate, but don't activate it |
118
+ | `status` | Show live values and injection profiles (read-only, no OWM/aWATTar needed) |
119
+ | `status --graph` | Same, with ASCII art visualization of each profile |
120
+
121
+ ### Example output
122
+
123
+ ```
124
+ aWATTar: 13 hourly prices loaded
125
+ OpenWeatherMap: clouds 75%, 8h forecast
126
+ SolMate: PV=23W, inject=13W, battery=28%
127
+ Current 'dynamic':
128
+ max ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒
129
+ min ░░░░░░░░░░░░░░ ░░░░░░░░░░░░
130
+ *
131
+ 0 3 6 9 12 15 18 21
132
+
133
+ ======================================================================
134
+ SolMate Optimizer — 2026-04-10 11:51
135
+ ======================================================================
136
+ Price now: 11.0 ct/kWh (P25=10.1, P75=15.0, range: 8.6 – 22.6 ct/kWh)
137
+ Battery: 28%
138
+ Clouds now: 75%
139
+
140
+ Hourly profile 'dynamic':
141
+ Hour ct/kWh Cloud MinW MaxW Reason
142
+ ---- ------ ----- ----- ----- ----------------------------------------
143
+ 0 - 75% 20 50 Night/baseload
144
+ 1 - 75% 20 50 Night/baseload
145
+ 2 - 84% 20 50 Night/baseload
146
+ ...
147
+ * 11 11.0 29% 0 50 Daytime, let PV charge
148
+ 12 9.2 75% 0 0 Price low (9.2 ct <= P25=10.1 ct)
149
+ ...
150
+ 18 15.0 75% 30 100 Price high (15.0 ct >= P75=15.0 ct), no sun expected
151
+ 19 20.5 75% 30 100 Price high (20.5 ct >= P75=15.0 ct), no sun expected
152
+ 20 22.6 100% 30 100 Price high (22.6 ct >= P75=15.0 ct), no sun expected
153
+ 21 16.6 75% 30 100 Price high (16.6 ct >= P75=15.0 ct), no sun expected
154
+ 22 14.4 75% 20 50 Night/baseload
155
+ 23 13.5 85% 20 50 Night/baseload
156
+
157
+ New 'dynamic':
158
+ max ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒
159
+ min ░░░░░░░░░░░░░░ ░░░░░░░░░░░░
160
+ *
161
+ 0 3 6 9 12 15 18 21
162
+ No change — profile 'dynamic' is already up to date.
163
+ ```
164
+
165
+ ## How injection profiles work
166
+
167
+ The SolMate stores named injection profiles, each containing two 24-element arrays:
168
+ - `min[24]` — minimum injection per hour (fraction 0.0–1.0 of 800W max)
169
+ - `max[24]` — maximum injection per hour
170
+
171
+ Index 0 = midnight, index 23 = 11 PM. The optimizer creates/updates a profile (name configurable via `SOLMATE_PROFILE_NAME`, default `"dynamic"`) and activates it, leaving your existing profiles ("Sonnig", "Schlechtwetter", etc.) untouched. You can switch back to any profile via the EET app at any time.
172
+
173
+ ## Deployment
174
+
175
+ See [DEPLOYMENT.md](DEPLOYMENT.md) for instructions on running this on GCP Cloud Run with Cloud Scheduler (hourly cron).
176
+
177
+ ## Dependencies
178
+
179
+ - [solmate-sdk](https://github.com/eet-energy/solmate-sdk) — EET SolMate WebSocket API client
180
+ - [httpx](https://www.python-httpx.org/) — HTTP client for aWATTar and OpenWeatherMap
181
+
182
+ ## License
183
+
184
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,169 @@
1
+ # SolMate Optimizer
2
+
3
+ Dynamically adjusts [EET SolMate](https://www.eet.energy/) solar battery injection profiles based on real-time electricity prices and weather data.
4
+
5
+ Run as a one-shot script once per hour — locally via cron, on [GCP Cloud Run](DEPLOYMENT.md), or any other scheduler.
6
+
7
+ ## Data sources
8
+
9
+ ### Electricity prices: aWATTar
10
+
11
+ [aWATTar Austria](https://www.awattar.at/) provides a **free public API** with hourly day-ahead electricity prices for the Austrian market (EPEX spot). No API key or registration needed.
12
+
13
+ - Endpoint: `GET https://api.awattar.at/v1/marketdata`
14
+ - Returns prices in EUR/MWh for the next ~24 hours
15
+ - Currently only the Austrian market is supported. Other hourly price providers (Tibber, ENTSO-E, etc.) could be added in the future.
16
+
17
+ ### Weather: OpenWeatherMap
18
+
19
+ [OpenWeatherMap](https://openweathermap.org/) provides current weather and a 5-day/3-hour forecast. Used to determine cloud coverage (current and forecast).
20
+
21
+ - **You need a free API key** — sign up at [openweathermap.org/api](https://openweathermap.org/api), the free tier is sufficient (current weather + 5-day forecast)
22
+ - The forecast is used to decide whether the battery can recharge via solar (sun expected vs. persistent overcast)
23
+
24
+ ### SolMate: solmate-sdk
25
+
26
+ The [solmate-sdk](https://github.com/eet-energy/solmate-sdk) connects to your EET SolMate via WebSocket (cloud API). Used to read battery state, read/write injection profiles, and activate the optimized profile.
27
+
28
+ ## What it does
29
+
30
+ Every run:
31
+
32
+ 1. Fetches hourly electricity prices from aWATTar (public API, no auth)
33
+ 2. Fetches current weather and forecast from OpenWeatherMap (free API key)
34
+ 3. Connects to your SolMate via solmate-sdk (cloud API, serial + password)
35
+ 4. Reads the current battery state and existing injection profiles
36
+ 5. Computes an optimized 24-hour injection profile based on price quantiles, weather, and time of day
37
+ 6. Compares with the current profile — only writes if something changed
38
+ 7. Writes and activates the profile on the SolMate (existing profiles are preserved)
39
+
40
+ ## Decision logic
41
+
42
+ The optimizer is **price-driven** — electricity prices already encode weather, demand, and time-of-day patterns:
43
+
44
+ | Priority | Condition | Injection | Reasoning |
45
+ |----------|-----------|-----------|-----------|
46
+ | 1 | Price < 0 (negative) | 0W | Grid pays consumers to take power — never inject |
47
+ | 2 | Price below P25 of 24h | 0W | Electricity is cheap, save battery for when it matters |
48
+ | 3 | Battery < 25% | 0–50W | Protect battery regardless of price |
49
+ | 4 | Price above P75 + battery OK + sun expected | 200–400W | Inject hard when it pays off and battery can recharge |
50
+ | 4 | Price above P75 but no sun coming | 100–200W | Price is high but can't recharge — be cautious |
51
+ | 5 | Middle prices, night (0–7, 22–24) | 20–50W | Baseload (fridge, standby) |
52
+ | 5 | Middle prices, daytime (7–18) | 0–50W | Let PV charge the battery |
53
+ | 5 | Middle prices, evening (18–22) | 50–120W | Cover active household consumption |
54
+
55
+ Price-based rules (priorities 1 and 2) always win over battery protection: even a low battery should not inject when prices are negative or very cheap.
56
+
57
+ ## Setup
58
+
59
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
60
+
61
+ ```bash
62
+ uv sync
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ All configuration is via environment variables:
68
+
69
+ | Variable | Required | Default | Description |
70
+ |----------|----------|---------|-------------|
71
+ | `SOLMATE_SERIAL` | yes | — | Your SolMate's serial number |
72
+ | `SOLMATE_PASSWORD` | yes | — | Your SolMate's user password |
73
+ | `OWM_API_KEY` | yes | — | OpenWeatherMap API key ([free tier](https://openweathermap.org/api) works) |
74
+ | `LOCATION_LATLON` | no | `48.2:16.37` | Latitude and longitude as `lat:lon` (default: Vienna) |
75
+ | `TIMEZONE` | no | `Europe/Vienna` | Timezone for price/weather hour matching and display (use IANA names, e.g. `Europe/Berlin`) |
76
+ | `SOLMATE_PROFILE_NAME` | no | `dynamic` | Name of the injection profile to create/update |
77
+ | `BATTERY_LOW_THRESHOLD` | no | `0.25` | Battery fraction (0–1) below which injection is throttled |
78
+ | `CLOUD_SUN_THRESHOLD` | no | `60` | Forecast cloud % below which "sun expected" for recharging |
79
+ | `MAX_WATTS` | no | `800` | SolMate max injection capacity in watts |
80
+
81
+ ## Run
82
+
83
+ ```bash
84
+ export SOLMATE_SERIAL="your-serial"
85
+ export SOLMATE_PASSWORD="your-password"
86
+ export OWM_API_KEY="your-owm-key"
87
+
88
+ uv run solmate # run optimizer (default)
89
+ uv run solmate optimize --dry-run # compute profile, don't write
90
+ uv run solmate optimize --no-activate # write but don't activate
91
+ uv run status # read-only status view
92
+ uv run status --graph # status with ASCII profile graphs
93
+ ```
94
+
95
+ ### Commands
96
+
97
+ | Command | Description |
98
+ |---------|-------------|
99
+ | `solmate` | Run the optimizer (default, no subcommand needed) |
100
+ | `solmate optimize` | Explicit optimizer subcommand |
101
+ | `solmate optimize --dry-run` | Compute and display profile, but don't write or activate it |
102
+ | `solmate optimize --no-activate` | Write the profile to SolMate, but don't activate it |
103
+ | `status` | Show live values and injection profiles (read-only, no OWM/aWATTar needed) |
104
+ | `status --graph` | Same, with ASCII art visualization of each profile |
105
+
106
+ ### Example output
107
+
108
+ ```
109
+ aWATTar: 13 hourly prices loaded
110
+ OpenWeatherMap: clouds 75%, 8h forecast
111
+ SolMate: PV=23W, inject=13W, battery=28%
112
+ Current 'dynamic':
113
+ max ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒
114
+ min ░░░░░░░░░░░░░░ ░░░░░░░░░░░░
115
+ *
116
+ 0 3 6 9 12 15 18 21
117
+
118
+ ======================================================================
119
+ SolMate Optimizer — 2026-04-10 11:51
120
+ ======================================================================
121
+ Price now: 11.0 ct/kWh (P25=10.1, P75=15.0, range: 8.6 – 22.6 ct/kWh)
122
+ Battery: 28%
123
+ Clouds now: 75%
124
+
125
+ Hourly profile 'dynamic':
126
+ Hour ct/kWh Cloud MinW MaxW Reason
127
+ ---- ------ ----- ----- ----- ----------------------------------------
128
+ 0 - 75% 20 50 Night/baseload
129
+ 1 - 75% 20 50 Night/baseload
130
+ 2 - 84% 20 50 Night/baseload
131
+ ...
132
+ * 11 11.0 29% 0 50 Daytime, let PV charge
133
+ 12 9.2 75% 0 0 Price low (9.2 ct <= P25=10.1 ct)
134
+ ...
135
+ 18 15.0 75% 30 100 Price high (15.0 ct >= P75=15.0 ct), no sun expected
136
+ 19 20.5 75% 30 100 Price high (20.5 ct >= P75=15.0 ct), no sun expected
137
+ 20 22.6 100% 30 100 Price high (22.6 ct >= P75=15.0 ct), no sun expected
138
+ 21 16.6 75% 30 100 Price high (16.6 ct >= P75=15.0 ct), no sun expected
139
+ 22 14.4 75% 20 50 Night/baseload
140
+ 23 13.5 85% 20 50 Night/baseload
141
+
142
+ New 'dynamic':
143
+ max ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒
144
+ min ░░░░░░░░░░░░░░ ░░░░░░░░░░░░
145
+ *
146
+ 0 3 6 9 12 15 18 21
147
+ No change — profile 'dynamic' is already up to date.
148
+ ```
149
+
150
+ ## How injection profiles work
151
+
152
+ The SolMate stores named injection profiles, each containing two 24-element arrays:
153
+ - `min[24]` — minimum injection per hour (fraction 0.0–1.0 of 800W max)
154
+ - `max[24]` — maximum injection per hour
155
+
156
+ Index 0 = midnight, index 23 = 11 PM. The optimizer creates/updates a profile (name configurable via `SOLMATE_PROFILE_NAME`, default `"dynamic"`) and activates it, leaving your existing profiles ("Sonnig", "Schlechtwetter", etc.) untouched. You can switch back to any profile via the EET app at any time.
157
+
158
+ ## Deployment
159
+
160
+ See [DEPLOYMENT.md](DEPLOYMENT.md) for instructions on running this on GCP Cloud Run with Cloud Scheduler (hourly cron).
161
+
162
+ ## Dependencies
163
+
164
+ - [solmate-sdk](https://github.com/eet-energy/solmate-sdk) — EET SolMate WebSocket API client
165
+ - [httpx](https://www.python-httpx.org/) — HTTP client for aWATTar and OpenWeatherMap
166
+
167
+ ## License
168
+
169
+ Apache 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "solmate-optimizer"
3
+ version = "0.1.0"
4
+ description = "Dynamically adjusts EET SolMate injection profile based on hourly electricity price and weather forecast"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Harald Schilly", email = "harald.schilly@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "solmate-sdk",
13
+ "httpx",
14
+ "click",
15
+ "plotext>=5.3.2",
16
+ "tzdata",
17
+ ]
18
+
19
+ [project.scripts]
20
+ solmate = "solmate_optimizer.__main__:cli"
21
+ status = "solmate_optimizer.status:status"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.10.10,<1"]
25
+ build-backend = "uv_build"
@@ -0,0 +1,17 @@
1
+ import click
2
+
3
+ from solmate_optimizer.main import optimize
4
+ from solmate_optimizer.status import status
5
+
6
+
7
+ @click.group(invoke_without_command=True)
8
+ @click.pass_context
9
+ def cli(ctx: click.Context):
10
+ if ctx.invoked_subcommand is None:
11
+ ctx.invoke(optimize)
12
+
13
+
14
+ cli.add_command(optimize)
15
+ cli.add_command(status)
16
+
17
+ cli()
@@ -0,0 +1,151 @@
1
+ """Pure decision engine: electricity prices + weather + time → 24h injection profile.
2
+
3
+ Profile values are fractions 0.0–1.0 of the SolMate's max capacity (800W).
4
+ So 0.0625 = 50W, 0.125 = 100W, 0.25 = 200W, etc.
5
+ Battery state is also a fraction 0.0–1.0 (as returned by the SDK).
6
+
7
+ Decision priority:
8
+ 1. Price < 0 (negative) → never inject, grid is paying consumers to take power (0/0)
9
+ 2. Price < P25 of 24h prices → don't inject, electricity is cheap (0/0)
10
+ 3. Battery critically low → protect battery (0/50W)
11
+ 4. Price > P75 AND battery OK AND sun expected → inject hard (200/400W)
12
+ 4. Price > P75 AND battery OK but no sun coming → inject moderately (100/200W)
13
+ 5. Middle prices → moderate injection based on time of day
14
+
15
+ Price-based rules (1 and 2) always win over battery protection: even a low battery
16
+ should not inject when prices are negative or very cheap.
17
+ """
18
+
19
+ import os
20
+ from dataclasses import dataclass
21
+
22
+ # --- Configurable parameters (via env vars, with defaults) ---
23
+
24
+ BATTERY_LOW_THRESHOLD = float(os.environ.get("BATTERY_LOW_THRESHOLD", "0.25"))
25
+ CLOUD_SUN_THRESHOLD = int(os.environ.get("CLOUD_SUN_THRESHOLD", "60"))
26
+ MAX_WATTS = float(os.environ.get("MAX_WATTS", "800.0"))
27
+
28
+
29
+ @dataclass
30
+ class HourlyProfile:
31
+ min_val: list[float] # 24 elements, index 0 = midnight, fraction 0-1
32
+ max_val: list[float] # 24 elements, index 0 = midnight, fraction 0-1
33
+ reasons: list[str] # 24 elements, human-readable
34
+
35
+
36
+ def _frac(watts: float) -> float:
37
+ """Convert watts to fraction of max capacity."""
38
+ return watts / MAX_WATTS
39
+
40
+
41
+ def _quantile(values: list[float], q: float) -> float:
42
+ """Compute quantile (0-1) of a sorted list."""
43
+ sorted_v = sorted(values)
44
+ idx = q * (len(sorted_v) - 1)
45
+ lo = int(idx)
46
+ hi = min(lo + 1, len(sorted_v) - 1)
47
+ frac = idx - lo
48
+ return sorted_v[lo] * (1 - frac) + sorted_v[hi] * frac
49
+
50
+
51
+ def _sun_expected(clouds_by_hour: dict[int, int]) -> bool:
52
+ """Check if sun is expected in the upcoming daytime hours (8-18)."""
53
+ daytime = [clouds_by_hour[h] for h in range(8, 18) if h in clouds_by_hour]
54
+ if not daytime:
55
+ return False # no forecast → be conservative
56
+ avg = sum(daytime) / len(daytime)
57
+ return avg < CLOUD_SUN_THRESHOLD
58
+
59
+
60
+ def compute_profile(
61
+ prices_by_hour: dict[int, float],
62
+ clouds_now: int,
63
+ clouds_by_hour: dict[int, int],
64
+ current_hour: int,
65
+ battery_state: float | None = None,
66
+ ) -> HourlyProfile:
67
+ """Compute a 24-hour injection profile.
68
+
69
+ Args:
70
+ prices_by_hour: hour (0-23) → ct/kWh. May be partial.
71
+ clouds_now: current cloud coverage %.
72
+ clouds_by_hour: hour (0-23) → cloud %. May be partial.
73
+ current_hour: current hour (0-23).
74
+ battery_state: current battery level as fraction 0.0–1.0, or None if unknown.
75
+
76
+ Returns:
77
+ HourlyProfile with 24-element min/max arrays (fractions 0-1) and reasons.
78
+ """
79
+ min_val = [0.0] * 24
80
+ max_val = [0.0] * 24
81
+ reasons = [""] * 24
82
+
83
+ # Compute price quantiles from available data
84
+ price_values = list(prices_by_hour.values()) if prices_by_hour else []
85
+ if len(price_values) >= 4:
86
+ p25 = _quantile(price_values, 0.25)
87
+ p75 = _quantile(price_values, 0.75)
88
+ else:
89
+ # Not enough data for meaningful quantiles → disable price-based rules
90
+ p25 = None
91
+ p75 = None
92
+
93
+ sun_coming = _sun_expected(clouds_by_hour)
94
+ battery_ok = battery_state is not None and battery_state >= BATTERY_LOW_THRESHOLD
95
+
96
+ for hour in range(24):
97
+ price = prices_by_hour.get(hour)
98
+ clouds = clouds_by_hour.get(hour, clouds_now)
99
+
100
+ # --- Priority 1: Negative price → never inject (grid pays consumers to take power) ---
101
+ if price is not None and price < 0:
102
+ min_val[hour] = 0.0
103
+ max_val[hour] = 0.0
104
+ reasons[hour] = f"Negative price ({price:.1f} ct) — never inject"
105
+ continue
106
+
107
+ # --- Priority 2: Price below P25 → don't inject, electricity is cheap ---
108
+ if price is not None and p25 is not None and price <= p25:
109
+ min_val[hour] = 0.0
110
+ max_val[hour] = 0.0
111
+ reasons[hour] = f"Price low ({price:.1f} ct <= P25={p25:.1f} ct)"
112
+ continue
113
+
114
+ # --- Priority 3: Battery critically low ---
115
+ if battery_state is not None and battery_state < BATTERY_LOW_THRESHOLD:
116
+ min_val[hour] = 0.0
117
+ max_val[hour] = _frac(50)
118
+ reasons[hour] = f"Battery low ({battery_state*100:.0f}%)"
119
+ continue
120
+
121
+ # --- Priority 4: Price above P75 → inject hard if battery OK + sun expected ---
122
+ if price is not None and p75 is not None and price >= p75 and battery_ok:
123
+ if sun_coming:
124
+ min_val[hour] = _frac(200)
125
+ max_val[hour] = _frac(400)
126
+ reasons[hour] = f"Price high ({price:.1f} ct >= P75={p75:.1f} ct), battery OK, sun expected"
127
+ else:
128
+ # Price is high but no sun coming → inject moderately, don't drain battery
129
+ min_val[hour] = _frac(100)
130
+ max_val[hour] = _frac(200)
131
+ reasons[hour] = f"Price high ({price:.1f} ct >= P75={p75:.1f} ct), no sun expected"
132
+ continue
133
+
134
+ # --- Priority 5: Middle prices → moderate, time-of-day based ---
135
+ is_night = 0 <= hour < 7 or 22 <= hour < 24
136
+ is_evening = 18 <= hour < 22
137
+
138
+ if is_night:
139
+ min_val[hour] = _frac(20)
140
+ max_val[hour] = _frac(50)
141
+ reasons[hour] = "Night/baseload"
142
+ elif is_evening:
143
+ min_val[hour] = _frac(50)
144
+ max_val[hour] = _frac(120)
145
+ reasons[hour] = "Evening consumption"
146
+ else:
147
+ min_val[hour] = 0.0
148
+ max_val[hour] = _frac(50)
149
+ reasons[hour] = "Daytime, let PV charge"
150
+
151
+ return HourlyProfile(min_val=min_val, max_val=max_val, reasons=reasons)
@@ -0,0 +1,307 @@
1
+ """Orchestrator: fetch electricity prices, weather, and SolMate state, then apply optimized profile."""
2
+
3
+ import datetime
4
+ import os
5
+ import sys
6
+ import zoneinfo
7
+
8
+ import click
9
+ import httpx
10
+ from solmate_sdk import SolMateAPIClient
11
+ from solmate_sdk.utils import DATETIME_FORMAT_INJECTION_PROFILES
12
+
13
+ from solmate_optimizer.logic import MAX_WATTS, HourlyProfile, compute_profile, _quantile, _sun_expected
14
+ from solmate_optimizer.plot import plot_profile
15
+
16
+ AWATTAR_URL = "https://api.awattar.at/v1/marketdata"
17
+ OWM_CURRENT_URL = "https://api.openweathermap.org/data/2.5/weather"
18
+ OWM_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast"
19
+
20
+ # Fallback injection (fraction of 800W) if APIs fail → 30W / 80W
21
+ FALLBACK_MIN = 30 / MAX_WATTS
22
+ FALLBACK_MAX = 80 / MAX_WATTS
23
+
24
+
25
+ def _next_occurrence(now: datetime.datetime) -> dict[int, datetime.datetime]:
26
+ """For each hour 0-23, compute the next upcoming occurrence from now.
27
+
28
+ If it's 11:00 now: hour 11-23 → today, hour 0-10 → tomorrow.
29
+ """
30
+ today = now.replace(minute=0, second=0, microsecond=0)
31
+ result = {}
32
+ for h in range(24):
33
+ candidate = today.replace(hour=h)
34
+ if candidate <= now:
35
+ candidate += datetime.timedelta(days=1)
36
+ result[h] = candidate
37
+ return result
38
+
39
+
40
+ def fetch_prices(tz: datetime.tzinfo) -> dict[int, float]:
41
+ """Fetch hourly electricity prices from aWATTar. Returns hour (0-23) → ct/kWh.
42
+
43
+ For each hour, keeps the price closest to its next upcoming occurrence.
44
+ E.g. at 11:00, hour 3 uses tomorrow's price (if available), hour 15 uses today's.
45
+ """
46
+ resp = httpx.get(AWATTAR_URL, timeout=15)
47
+ resp.raise_for_status()
48
+ data = resp.json()
49
+
50
+ now = datetime.datetime.now(tz=tz)
51
+ targets = _next_occurrence(now)
52
+
53
+ # Collect all prices keyed by (day, hour), then pick the best match per hour
54
+ prices: dict[int, float] = {}
55
+ best_dist: dict[int, float] = {}
56
+ for entry in data["data"]:
57
+ ts = datetime.datetime.fromtimestamp(entry["start_timestamp"] / 1000, tz=tz)
58
+ hour = ts.hour
59
+ price = entry["marketprice"] / 10.0 # EUR/MWh → ct/kWh
60
+ dist = abs((ts - targets[hour]).total_seconds())
61
+ if hour not in prices or dist < best_dist[hour]:
62
+ prices[hour] = price
63
+ best_dist[hour] = dist
64
+ return prices
65
+
66
+
67
+ def fetch_weather(api_key: str, lat: float, lon: float, tz: datetime.tzinfo) -> tuple[int, dict[int, int]]:
68
+ """Fetch current clouds and hourly forecast from OpenWeatherMap.
69
+
70
+ For each hour, keeps the forecast closest to its next upcoming occurrence.
71
+ Returns:
72
+ (clouds_now, clouds_by_hour) where clouds_by_hour maps hour (0-23) → cloud %.
73
+ """
74
+ params = {"lat": lat, "lon": lon, "appid": api_key}
75
+
76
+ # Current weather
77
+ resp = httpx.get(OWM_CURRENT_URL, params=params, timeout=15)
78
+ resp.raise_for_status()
79
+ clouds_now = resp.json()["clouds"]["all"]
80
+
81
+ # 5-day/3h forecast
82
+ resp = httpx.get(OWM_FORECAST_URL, params=params, timeout=15)
83
+ resp.raise_for_status()
84
+ forecast = resp.json()
85
+
86
+ now = datetime.datetime.now(tz=tz)
87
+ targets = _next_occurrence(now)
88
+
89
+ clouds_by_hour: dict[int, int] = {}
90
+ best_dist: dict[int, float] = {}
91
+ for entry in forecast["list"]:
92
+ ts = datetime.datetime.fromtimestamp(entry["dt"], tz=tz)
93
+ hour = ts.hour
94
+ clouds = entry["clouds"]["all"]
95
+ dist = abs((ts - targets[hour]).total_seconds())
96
+ if hour not in clouds_by_hour or dist < best_dist[hour]:
97
+ clouds_by_hour[hour] = clouds
98
+ best_dist[hour] = dist
99
+
100
+ return clouds_now, clouds_by_hour
101
+
102
+
103
+ def connect_solmate(serial: str, password: str) -> SolMateAPIClient:
104
+ """Connect and authenticate to SolMate cloud API."""
105
+ client = SolMateAPIClient(serial)
106
+ client.quickstart(password=password)
107
+ return client
108
+
109
+
110
+
111
+
112
+ def parse_latlon(value: str) -> tuple[float, float]:
113
+ """Parse 'lat:lon' string, e.g. '48.2:16.37'."""
114
+ parts = value.split(":")
115
+ if len(parts) != 2:
116
+ raise ValueError(f"LOCATION_LATLON must be 'lat:lon', got '{value}'")
117
+ return float(parts[0]), float(parts[1])
118
+
119
+
120
+ def print_decision(profile: HourlyProfile, prices: dict[int, float], clouds_now: int, clouds_by_hour: dict[int, int], now: datetime.datetime, battery_state: float | None = None, profile_name: str = "dynamic") -> None:
121
+ """Print price/battery/clouds info and the hourly decision table."""
122
+ current_hour = now.hour
123
+
124
+ if prices:
125
+ price_values = list(prices.values())
126
+ current_price = prices.get(current_hour, None)
127
+ price_str = f"{current_price:.1f}" if current_price is not None else "n/a"
128
+ if len(price_values) >= 4:
129
+ p25 = _quantile(price_values, 0.25)
130
+ p75 = _quantile(price_values, 0.75)
131
+ print(f"Price now: {price_str} ct/kWh "
132
+ f"(P25={p25:.1f}, P75={p75:.1f}, "
133
+ f"range: {min(price_values):.1f} – {max(price_values):.1f} ct/kWh)")
134
+ else:
135
+ print(f"Price now: {price_str} ct/kWh "
136
+ f"(range: {min(price_values):.1f} – {max(price_values):.1f} ct/kWh)")
137
+ else:
138
+ print("Price: unavailable")
139
+
140
+ if battery_state is not None:
141
+ print(f"Battery: {battery_state*100:.0f}%")
142
+ print(f"Clouds now: {clouds_now}%")
143
+ print(f"\nHourly profile '{profile_name}':")
144
+ print(f" {'Hour':>4} {'ct/kWh':>6} {'Cloud':>5} {'MinW':>5} {'MaxW':>5} Reason")
145
+ print(f" {'-'*4} {'-'*6} {'-'*5} {'-'*5} {'-'*5} {'-'*40}")
146
+ for h in range(24):
147
+ marker = "*" if h == current_hour else " "
148
+ min_w = profile.min_val[h] * MAX_WATTS
149
+ max_w = profile.max_val[h] * MAX_WATTS
150
+ price = prices.get(h)
151
+ clouds = clouds_by_hour.get(h, clouds_now)
152
+ price_str = f"{price:6.1f}" if price is not None else " -"
153
+ print(f"{marker} {h:4d} {price_str} {clouds:4d}% {min_w:5.0f} {max_w:5.0f} {profile.reasons[h]}")
154
+ print()
155
+
156
+
157
+ @click.command()
158
+ @click.option("--dry-run", is_flag=True, help="Compute and display profile, but don't write or activate it")
159
+ @click.option("--no-activate", is_flag=True, help="Write the profile to SolMate, but don't activate it")
160
+ def optimize(dry_run: bool, no_activate: bool):
161
+ """Run the SolMate injection profile optimizer."""
162
+
163
+ # --- Load config from env ---
164
+ serial = os.environ.get("SOLMATE_SERIAL")
165
+ password = os.environ.get("SOLMATE_PASSWORD")
166
+ owm_key = os.environ.get("OWM_API_KEY")
167
+ profile_name = os.environ.get("SOLMATE_PROFILE_NAME", "dynamic")
168
+
169
+ if not serial or not password:
170
+ print("Error: SOLMATE_SERIAL and SOLMATE_PASSWORD must be set", file=sys.stderr)
171
+ sys.exit(1)
172
+
173
+ # Location and timezone
174
+ latlon_str = os.environ.get("LOCATION_LATLON", "48.2:16.37")
175
+ try:
176
+ lat, lon = parse_latlon(latlon_str)
177
+ except ValueError as e:
178
+ print(f"Error: {e}", file=sys.stderr)
179
+ sys.exit(1)
180
+
181
+ tz_name = os.environ.get("TIMEZONE", "Europe/Vienna")
182
+ try:
183
+ tz = zoneinfo.ZoneInfo(tz_name)
184
+ except zoneinfo.ZoneInfoNotFoundError:
185
+ print(f"Error: unknown timezone '{tz_name}'", file=sys.stderr)
186
+ sys.exit(1)
187
+
188
+ # --- Header ---
189
+ now = datetime.datetime.now(tz=tz)
190
+ print(f"\n{'='*70}")
191
+ print(f"SolMate Optimizer — {now.strftime('%Y-%m-%d %H:%M %Z')}")
192
+ print(f"{'='*70}")
193
+
194
+ # --- Fetch external data ---
195
+ prices: dict[int, float] = {}
196
+ clouds_now = 50
197
+ clouds_by_hour: dict[int, int] = {}
198
+
199
+ try:
200
+ prices = fetch_prices(tz)
201
+ print(f"aWATTar: {len(prices)} hourly prices loaded")
202
+ except Exception as e:
203
+ print(f"aWATTar error: {e} — using fallback profile", file=sys.stderr)
204
+
205
+ if owm_key:
206
+ try:
207
+ clouds_now, clouds_by_hour = fetch_weather(owm_key, lat, lon, tz)
208
+ print(f"OpenWeatherMap: clouds {clouds_now}%, {len(clouds_by_hour)}h forecast")
209
+ except Exception as e:
210
+ print(f"OpenWeatherMap error: {e} — using fallback clouds", file=sys.stderr)
211
+ else:
212
+ print("OWM_API_KEY not set — using fallback clouds 50%", file=sys.stderr)
213
+
214
+ # --- Connect to SolMate ---
215
+ try:
216
+ client = connect_solmate(serial, password)
217
+ except Exception as e:
218
+ print(f"SolMate connection failed: {e}", file=sys.stderr)
219
+ sys.exit(1)
220
+
221
+ # --- Fetch live values (battery state) ---
222
+ battery_state: float | None = None
223
+ try:
224
+ live = client.get_live_values()
225
+ pv = live.get("pv_power", 0)
226
+ inject = live.get("inject_power", 0)
227
+ battery_state = live.get("battery_state")
228
+ bat_str = f"{battery_state*100:.0f}%" if battery_state is not None else "?"
229
+ print(f"SolMate: PV={pv:.0f}W, inject={inject:.0f}W, battery={bat_str}")
230
+ except Exception as e:
231
+ print(f"Failed to read live values: {e}", file=sys.stderr)
232
+
233
+ # --- Read existing profiles ---
234
+ try:
235
+ settings = client.get_injection_profiles()
236
+ existing_profiles = settings["injection_profiles"]
237
+ except Exception as e:
238
+ print(f"Failed to read profiles: {e}", file=sys.stderr)
239
+ sys.exit(1)
240
+
241
+ current_hour = now.hour
242
+
243
+ # --- Compute profile ---
244
+ profile = compute_profile(prices, clouds_now, clouds_by_hour, current_hour, battery_state)
245
+
246
+ # If both APIs failed, use safe fallback
247
+ if not prices and not clouds_by_hour:
248
+ print("Both APIs failed — using safe fallback profile")
249
+ profile.min_val = [FALLBACK_MIN] * 24
250
+ profile.max_val = [FALLBACK_MAX] * 24
251
+ profile.reasons = ["Fallback: no data available"] * 24
252
+
253
+ # --- Check if profile actually changed ---
254
+ changed = True
255
+ old_profile = existing_profiles.get(profile_name)
256
+ if old_profile is not None:
257
+ if old_profile["min"] == profile.min_val and old_profile["max"] == profile.max_val:
258
+ changed = False
259
+
260
+ # --- Print decision ---
261
+ print_decision(profile, prices, clouds_now, clouds_by_hour, now, battery_state, profile_name)
262
+
263
+ # --- Plots ---
264
+ if changed and old_profile is not None:
265
+ plot_profile(f"Before '{profile_name}'", old_profile["min"], old_profile["max"], current_hour)
266
+ plot_profile(f"After '{profile_name}'", profile.min_val, profile.max_val, current_hour)
267
+ else:
268
+ plot_profile(f"Profile '{profile_name}'", profile.min_val, profile.max_val, current_hour)
269
+
270
+ if dry_run:
271
+ if changed:
272
+ print("Dry run — profile CHANGED but not written (--dry-run).")
273
+ else:
274
+ print("Dry run — no change needed.")
275
+ return
276
+
277
+ if not changed:
278
+ print(f"No change — profile '{profile_name}' is already up to date.")
279
+ return
280
+
281
+ # --- Write updated profile ---
282
+ existing_profiles[profile_name] = {
283
+ "min": profile.min_val,
284
+ "max": profile.max_val,
285
+ }
286
+
287
+ timestamp = now.strftime(DATETIME_FORMAT_INJECTION_PROFILES)
288
+ try:
289
+ client.set_injection_profiles(existing_profiles, timestamp)
290
+ print(f"UPDATED — profile '{profile_name}' written")
291
+ except Exception as e:
292
+ print(f"Failed to write profile: {e}", file=sys.stderr)
293
+ sys.exit(1)
294
+
295
+ if no_activate:
296
+ print(f"Profile '{profile_name}' written but not activated (--no-activate).")
297
+ return
298
+
299
+ # --- Activate profile ---
300
+ try:
301
+ client.apply_injection_profile(profile_name)
302
+ print(f"Profile '{profile_name}' activated")
303
+ except Exception as e:
304
+ print(f"Failed to activate profile: {e}", file=sys.stderr)
305
+ sys.exit(1)
306
+
307
+ print("\nDone.")
@@ -0,0 +1,21 @@
1
+ """Terminal plots for injection profiles using plotext."""
2
+
3
+ import plotext as plt
4
+
5
+ from solmate_optimizer.logic import MAX_WATTS
6
+
7
+
8
+ def plot_profile(name: str, min_frac: list[float], max_frac: list[float], current_hour: int) -> None:
9
+ """Plot a 24h min/max injection profile in the terminal."""
10
+ min_w = [v * MAX_WATTS for v in min_frac]
11
+ max_w = [v * MAX_WATTS for v in max_frac]
12
+ hours = list(range(24))
13
+ plt.clf()
14
+ plt.plot(hours, max_w, color="orange")
15
+ plt.plot(hours, min_w, color="blue+")
16
+ plt.vline(current_hour, color="red")
17
+ plt.plotsize(78, 9)
18
+ plt.xticks(list(range(0, 24, 3)))
19
+ plt.ylim(0, max(max_w) * 1.15 if max(max_w) > 0 else 100)
20
+ plt.title(name)
21
+ plt.show()
File without changes
@@ -0,0 +1,79 @@
1
+ """Read-only SolMate status: live values and injection profiles."""
2
+
3
+ import datetime
4
+ import os
5
+ import sys
6
+
7
+ import click
8
+
9
+ from solmate_optimizer.main import connect_solmate
10
+ from solmate_optimizer.logic import MAX_WATTS
11
+ from solmate_optimizer.plot import plot_profile
12
+
13
+
14
+ @click.command()
15
+ @click.option("--graph", is_flag=True, help="Show ASCII art graph for each profile")
16
+ def status(graph: bool):
17
+ """Show current SolMate live values and injection profiles (read-only)."""
18
+ serial = os.environ.get("SOLMATE_SERIAL")
19
+ password = os.environ.get("SOLMATE_PASSWORD")
20
+ if not serial or not password:
21
+ click.echo("Error: SOLMATE_SERIAL and SOLMATE_PASSWORD must be set", err=True)
22
+ sys.exit(1)
23
+
24
+ try:
25
+ client = connect_solmate(serial, password)
26
+ except Exception as e:
27
+ click.echo(f"SolMate connection failed: {e}", err=True)
28
+ sys.exit(1)
29
+
30
+ now = datetime.datetime.now()
31
+ current_hour = now.hour
32
+ click.echo(f"\nSolMate Status — {now.strftime('%Y-%m-%d %H:%M')}")
33
+ click.echo("=" * 40)
34
+
35
+ # --- Live values ---
36
+ try:
37
+ live = client.get_live_values()
38
+ pv = live.get("pv_power")
39
+ inject = live.get("inject_power")
40
+ battery = live.get("battery_state")
41
+ if pv is not None:
42
+ click.echo(f"PV power: {pv:.0f} W")
43
+ if inject is not None:
44
+ click.echo(f"Injection: {inject:.0f} W")
45
+ if battery is not None:
46
+ click.echo(f"Battery: {battery*100:.0f}%")
47
+ known = {"pv_power", "inject_power", "battery_state"}
48
+ for k, v in live.items():
49
+ if k not in known:
50
+ click.echo(f"{k}: {v}")
51
+ except Exception as e:
52
+ click.echo(f"Failed to read live values: {e}", err=True)
53
+
54
+ click.echo()
55
+
56
+ # --- Injection profiles ---
57
+ try:
58
+ settings = client.get_injection_profiles()
59
+ profiles = settings.get("injection_profiles", {})
60
+ profile_name = os.environ.get("SOLMATE_PROFILE_NAME", "dynamic")
61
+
62
+ if not profiles:
63
+ click.echo("No injection profiles found.")
64
+ else:
65
+ click.echo(f"Injection profiles ({len(profiles)}):")
66
+ for name in sorted(profiles.keys()):
67
+ marker = "*" if name == profile_name else " "
68
+ cur = profiles[name]
69
+ avg_min = sum(v * MAX_WATTS for v in cur["min"]) / 24
70
+ avg_max = sum(v * MAX_WATTS for v in cur["max"]) / 24
71
+ click.echo(f" {marker} {name:<20} avg {avg_min:.0f}–{avg_max:.0f} W")
72
+
73
+ if graph and profile_name in profiles:
74
+ cur = profiles[profile_name]
75
+ plot_profile(profile_name, cur["min"], cur["max"], current_hour)
76
+
77
+ except Exception as e:
78
+ click.echo(f"Failed to read profiles: {e}", err=True)
79
+ sys.exit(1)