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.
- solmate_optimizer-0.1.0/PKG-INFO +184 -0
- solmate_optimizer-0.1.0/README.md +169 -0
- solmate_optimizer-0.1.0/pyproject.toml +25 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/__init__.py +0 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/__main__.py +17 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/logic.py +151 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/main.py +307 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/plot.py +21 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/py.typed +0 -0
- solmate_optimizer-0.1.0/src/solmate_optimizer/status.py +79 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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)
|