Skip to content
This repository was archived by the owner on Aug 14, 2026. It is now read-only.

Commit 04bde54

Browse files
committed
minor tweaks
1 parent fecd6e1 commit 04bde54

7 files changed

Lines changed: 142 additions & 131 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ The scheduler monitors the current time, battery SoC, and weather forecast, cont
3636

3737
The system can automatically skip battery discharge when bad weather is forecasted. This helps preserve battery charge for cloudy/rainy days when solar generation will be insufficient.
3838

39-
**Why this matters:** During consecutive bad weather days, solar panels will not generate enough power to recharge the battery. By skipping discharge before bad weather, the battery retains enough charge to cover household needs without importing from the grid.
39+
**Why this matters:** During bad weather, solar panels will not generate enough power to recharge the battery. By skipping discharge before bad weather, the battery retains enough charge to cover household needs without importing from the grid.
4040

4141
**How it works:**
42-
- Fetches 7-day weather forecast from Open-Meteo (free, no API key required)
42+
- Fetches 4-day weather forecast from Open-Meteo (free, no API key required)
4343
- Gets solar production forecasts from forecast.solar for accurate predictions
44-
- Analyses conditions: rain, thunderstorms, drizzle, snow, high cloud cover (>70%)
45-
- If bad weather is expected for X consecutive days (configurable, default 2), discharge is skipped
46-
- Displayed in the web UI with forecast cards showing good/bad days
44+
- Compares tomorrow's solar forecast against your minimum threshold (configurable)
45+
- If solar production is expected to be below threshold, discharge is skipped
46+
- Displayed in the web UI with forecast cards showing solar estimates
4747

4848
## Solar Output Estimates
4949

‎app.py‎

Lines changed: 88 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ def init_weather_client():
167167
if panel_kw and panel_kw > 0:
168168
kwp = panel_kw
169169
elif inverter_kw and inverter_kw > 0:
170-
kwp = inverter_kw * 1.25 # Common panel oversizing ratio
170+
kwp = int(inverter_kw * 1.25) # Match Android app behavior
171171
elif api_inverter_kw and api_inverter_kw > 0:
172-
kwp = api_inverter_kw * 1.25
172+
kwp = int(api_inverter_kw * 1.25)
173173
else:
174174
kwp = 5.0 # Default fallback
175175

@@ -365,6 +365,7 @@ def scheduler_loop():
365365
# Get SoC settings
366366
min_soc_reserve = schedule.get("min_soc_reserve", 20)
367367
cutoff_soc = schedule.get("force_discharge_cutoff_soc", 50)
368+
reactivation_margin = schedule.get("reactivation_margin", 5) # Hysteresis buffer
368369
max_power = current_state.get("inverter_capacity") or 10000
369370

370371
# Get current battery info
@@ -393,10 +394,22 @@ def scheduler_loop():
393394
# Check if force discharge is enabled
394395
force_discharge_enabled = schedule.get("enabled", True)
395396

396-
# Determine if we should be in force discharge mode
397-
# Force discharge when: enabled AND in window AND SoC above cutoff AND NOT weather skip
398-
should_force_discharge = force_discharge_enabled and in_window and (soc is None or soc > cutoff_soc) and not weather_skip
399-
logger.debug(f"Scheduler check: discharge={'active' if should_force_discharge else 'inactive'}")
397+
# Determine if we should be in force discharge mode with hysteresis
398+
# - If already discharging: keep going until SoC <= cutoff
399+
# - If not discharging: only start if SoC > (cutoff + margin)
400+
# This prevents rapid cycling when SoC hovers around the cutoff threshold
401+
currently_discharging = current_state.get("force_discharge_active", False)
402+
403+
if currently_discharging:
404+
# Keep discharging until we reach the cutoff
405+
soc_allows_discharge = soc is None or soc > cutoff_soc
406+
else:
407+
# Only start discharging if SoC is above cutoff + margin
408+
activation_threshold = cutoff_soc + reactivation_margin
409+
soc_allows_discharge = soc is None or soc > activation_threshold
410+
411+
should_force_discharge = force_discharge_enabled and in_window and soc_allows_discharge and not weather_skip
412+
logger.debug(f"Scheduler check: discharge={'active' if should_force_discharge else 'inactive'}, SoC={soc}, threshold={cutoff_soc if currently_discharging else cutoff_soc + reactivation_margin}")
400413

401414
if weather_skip and in_window:
402415
logger.debug("Skipping discharge due to weather conditions")
@@ -896,72 +909,80 @@ def get_soc():
896909
@app.route('/api/weather')
897910
def get_weather():
898911
"""Get weather forecast"""
899-
weather_config = config.get("weather", {})
912+
try:
913+
weather_config = config.get("weather", {})
900914

901-
if not weather_config.get("enabled", False):
902-
return jsonify({
915+
if not weather_config.get("enabled", False):
916+
return jsonify({
917+
"success": True,
918+
"enabled": False,
919+
"message": "Weather feature is disabled"
920+
})
921+
922+
if not weather_client:
923+
return jsonify({
924+
"success": False,
925+
"enabled": True,
926+
"error": "Weather client not initialised - check API key and location"
927+
})
928+
929+
forecast = get_weather_forecast()
930+
if not forecast or not forecast.get("success", False):
931+
return jsonify({
932+
"success": False,
933+
"enabled": True,
934+
"error": "Failed to fetch weather forecast"
935+
})
936+
937+
skip_active, _ = should_skip_discharge_for_weather()
938+
939+
# Sanitize skip_reason to avoid exposing internal details
940+
safe_skip_reason = "Weather conditions unfavorable" if skip_active else "Weather OK"
941+
942+
# Sanitize forecast to only include UI-safe fields
943+
safe_forecast = {
903944
"success": True,
904-
"enabled": False,
905-
"message": "Weather feature is disabled"
906-
})
945+
"daily": []
946+
}
947+
for day in forecast.get("daily", []):
948+
safe_day = {
949+
"date": day.get("date"),
950+
"day_name": day.get("day_name"),
951+
"condition": day.get("condition"),
952+
"icon": day.get("icon"),
953+
"temp_max": day.get("temp_max"),
954+
"temp_min": day.get("temp_min"),
955+
"cloud_cover": day.get("cloud_cover"),
956+
"precipitation_probability": day.get("precipitation_probability"),
957+
"is_bad_weather": day.get("is_bad_weather", False),
958+
"estimated_solar_kwh": day.get("estimated_solar_kwh"),
959+
"solar_source": day.get("solar_source")
960+
}
961+
safe_forecast["daily"].append(safe_day)
907962

908-
if not weather_client:
909963
return jsonify({
910-
"success": False,
964+
"success": True,
911965
"enabled": True,
912-
"error": "Weather client not initialised - check API key and location"
966+
"forecast": safe_forecast,
967+
"skip_discharge": skip_active,
968+
"skip_reason": safe_skip_reason,
969+
"min_solar_threshold_kwh": weather_config.get("min_solar_threshold_kwh", 0),
970+
"last_update": weather_forecast_cache.get("last_update").isoformat() if weather_forecast_cache.get("last_update") else None
913971
})
914-
915-
forecast = get_weather_forecast()
916-
if not forecast:
972+
except Exception as e:
973+
logger.error(f"Error getting weather forecast: {e}")
917974
return jsonify({
918975
"success": False,
919976
"enabled": True,
920-
"error": "Failed to fetch weather forecast"
921-
})
922-
923-
skip_active, skip_reason = should_skip_discharge_for_weather()
924-
925-
# Sanitize skip_reason to avoid exposing internal details
926-
safe_skip_reason = "Weather conditions unfavorable" if skip_active else "Weather OK"
927-
928-
# Sanitize forecast to only include UI-safe fields
929-
safe_forecast = {
930-
"success": True,
931-
"daily": [],
932-
"consecutive_bad_days": forecast.get("consecutive_bad_days", 0)
933-
}
934-
for day in forecast.get("daily", []):
935-
safe_day = {
936-
"date": day.get("date"),
937-
"day_name": day.get("day_name"),
938-
"condition": day.get("condition"),
939-
"icon": day.get("icon"),
940-
"temp_max": day.get("temp_max"),
941-
"temp_min": day.get("temp_min"),
942-
"cloud_cover": day.get("cloud_cover"),
943-
"precipitation_probability": day.get("precipitation_probability"),
944-
"is_bad_weather": day.get("is_bad_weather", False),
945-
"estimated_solar_kwh": day.get("estimated_solar_kwh"),
946-
"solar_source": day.get("solar_source")
947-
}
948-
safe_forecast["daily"].append(safe_day)
949-
950-
return jsonify({
951-
"success": True,
952-
"enabled": True,
953-
"forecast": safe_forecast,
954-
"skip_discharge": skip_active,
955-
"skip_reason": safe_skip_reason,
956-
"min_solar_threshold_kwh": weather_config.get("min_solar_threshold_kwh", 0),
957-
"last_update": weather_forecast_cache.get("last_update").isoformat() if weather_forecast_cache.get("last_update") else None
958-
})
977+
"error": "Failed to get weather forecast. Check logs for details."
978+
}), 500
959979

960980

961981
@app.route('/api/weather/config')
962982
def get_weather_config():
963983
"""Get weather configuration"""
964984
weather_config = config.get("weather", {})
985+
solar_config = weather_config.get("solar", {})
965986
return jsonify({
966987
"enabled": weather_config.get("enabled", False),
967988
"city_name": weather_config.get("city_name", ""),
@@ -973,6 +994,8 @@ def get_weather_config():
973994
"min_cloud_cover_percent": weather_config.get("min_cloud_cover_percent", 70),
974995
"inverter_capacity_kw": weather_config.get("inverter_capacity_kw", 0),
975996
"panel_capacity_kw": weather_config.get("panel_capacity_kw", 0),
997+
"panel_tilt": solar_config.get("declination"),
998+
"panel_azimuth": solar_config.get("azimuth"),
976999
"location_configured": weather_config.get("latitude") is not None and weather_config.get("longitude") is not None
9771000
})
9781001

@@ -1023,6 +1046,14 @@ def update_weather_config():
10231046
if "panel_capacity_kw" in body:
10241047
config["weather"]["panel_capacity_kw"] = body["panel_capacity_kw"]
10251048

1049+
# Solar panel configuration (tilt/azimuth)
1050+
if "solar" not in config["weather"]:
1051+
config["weather"]["solar"] = {}
1052+
if "panel_tilt" in body:
1053+
config["weather"]["solar"]["declination"] = body["panel_tilt"]
1054+
if "panel_azimuth" in body:
1055+
config["weather"]["solar"]["azimuth"] = body["panel_azimuth"]
1056+
10261057
save_config()
10271058

10281059
# Reinitialise weather client with new config

‎templates/index.html‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,18 @@ <h2>
813813
<small style="color: #666; font-size: 0.8em;">Leave blank to auto-estimate (inverter × 1.25)</small>
814814
</div>
815815
</div>
816+
<div class="weather-settings-row">
817+
<div class="setting-item">
818+
<label>Panel Tilt (°)</label>
819+
<input type="number" id="weatherPanelTilt" min="0" max="90" step="1" value="" placeholder="Default: 25">
820+
<small style="color: #666; font-size: 0.8em;">Roof angle (0=flat, 90=vertical)</small>
821+
</div>
822+
<div class="setting-item">
823+
<label>Panel Azimuth (°)</label>
824+
<input type="number" id="weatherPanelAzimuth" min="-180" max="180" step="1" value="" placeholder="Auto from location">
825+
<small style="color: #666; font-size: 0.8em;">Direction: 0=North, 90=East, 180=South, -90=West</small>
826+
</div>
827+
</div>
816828
<button type="submit" class="btn btn-primary btn-sm">Save Weather Settings</button>
817829
</form>
818830
</div>
@@ -850,6 +862,10 @@ <h2>Schedule Settings</h2>
850862
<label>Discharge SoC %</label>
851863
<input type="number" id="cutoffSoc" min="5" max="100" value="50" title="During discharge window - stop when battery reaches this level">
852864
</div>
865+
<div class="setting-item">
866+
<label>Reactivation %</label>
867+
<input type="number" id="reactivationMargin" min="0" max="20" value="5" title="Buffer above cutoff to restart discharge - prevents cycling">
868+
</div>
853869
</div>
854870
<button type="submit" class="btn btn-primary btn-sm">Save</button>
855871
</form>
@@ -1327,6 +1343,7 @@ <h2>Schedule Settings</h2>
13271343
document.getElementById('endTime').value = schedule.force_discharge_end || '19:30';
13281344
document.getElementById('minSocReserve').value = schedule.min_soc_reserve || 20;
13291345
document.getElementById('cutoffSoc').value = schedule.force_discharge_cutoff_soc || 50;
1346+
document.getElementById('reactivationMargin').value = schedule.reactivation_margin || 5;
13301347
}
13311348

13321349
// TOU table
@@ -1368,7 +1385,8 @@ <h2>Schedule Settings</h2>
13681385
force_discharge_start: document.getElementById('startTime').value,
13691386
force_discharge_end: document.getElementById('endTime').value,
13701387
min_soc_reserve: parseInt(document.getElementById('minSocReserve').value),
1371-
force_discharge_cutoff_soc: parseInt(document.getElementById('cutoffSoc').value)
1388+
force_discharge_cutoff_soc: parseInt(document.getElementById('cutoffSoc').value),
1389+
reactivation_margin: parseInt(document.getElementById('reactivationMargin').value) || 5
13721390
};
13731391

13741392
try {
@@ -1472,9 +1490,9 @@ <h2>Schedule Settings</h2>
14721490
`;
14731491
}
14741492

1475-
// Forecast cards
1493+
// Forecast cards - limit to 4 days for better mobile display
14761494
let forecastHtml = '<div class="weather-container">';
1477-
daily.forEach((day, index) => {
1495+
daily.slice(0, 4).forEach((day, index) => {
14781496
const classes = ['weather-day'];
14791497
// Only highlight future days for bad weather (not today) since discharge decision is based on future forecasts
14801498
if (!day.is_today && day.is_bad_weather) classes.push('bad-weather');
@@ -1530,6 +1548,8 @@ <h2>Schedule Settings</h2>
15301548
document.getElementById('weatherSolarThreshold').value = config.min_solar_threshold_kwh || 15;
15311549
document.getElementById('weatherInverterCapacity').value = config.inverter_capacity_kw || '';
15321550
document.getElementById('weatherPanelCapacity').value = config.panel_capacity_kw || '';
1551+
document.getElementById('weatherPanelTilt').value = config.panel_tilt || '';
1552+
document.getElementById('weatherPanelAzimuth').value = config.panel_azimuth !== null ? config.panel_azimuth : '';
15331553
} catch (error) {
15341554
console.error('Error loading weather config:', error);
15351555
}
@@ -1599,6 +1619,9 @@ <h2>Schedule Settings</h2>
15991619
const lat = document.getElementById('weatherLatitude').value;
16001620
const lon = document.getElementById('weatherLongitude').value;
16011621

1622+
const tiltVal = document.getElementById('weatherPanelTilt').value;
1623+
const azimuthVal = document.getElementById('weatherPanelAzimuth').value;
1624+
16021625
const payload = {
16031626
enabled: document.getElementById('weatherEnabled').checked,
16041627
city_name: document.getElementById('weatherCityName').value || '',
@@ -1607,7 +1630,9 @@ <h2>Schedule Settings</h2>
16071630
timezone: document.getElementById('weatherTimezone').value || 'auto',
16081631
min_solar_threshold_kwh: parseFloat(document.getElementById('weatherSolarThreshold').value) || 15,
16091632
inverter_capacity_kw: parseFloat(document.getElementById('weatherInverterCapacity').value) || 0,
1610-
panel_capacity_kw: parseFloat(document.getElementById('weatherPanelCapacity').value) || 0
1633+
panel_capacity_kw: parseFloat(document.getElementById('weatherPanelCapacity').value) || 0,
1634+
panel_tilt: tiltVal ? parseInt(tiltVal) : null,
1635+
panel_azimuth: azimuthVal !== '' ? parseInt(azimuthVal) : null
16111636
};
16121637

16131638
try {

‎tests/conftest.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ def sample_forecast_good():
8484
"is_bad_weather": False
8585
}
8686
],
87-
"consecutive_bad_days": 0,
8887
"bad_weather_days": []
8988
}
9089

@@ -142,6 +141,5 @@ def sample_forecast_bad():
142141
"is_bad_weather": True
143142
}
144143
],
145-
"consecutive_bad_days": 3,
146144
"bad_weather_days": ["2023-12-22", "2023-12-23", "2023-12-24"]
147145
}

‎tests/test_app.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ def test_weather_api_success(self, mock_forecast, app_client):
9090
"success": True,
9191
"daily": [
9292
{"date": "2023-12-22", "condition": "Clear", "is_bad_weather": False}
93-
],
94-
"consecutive_bad_days": 0
93+
]
9594
}
9695

9796
# Initialise weather client
@@ -272,21 +271,20 @@ def test_skip_no_forecast(self, mock_forecast):
272271
def test_skip_bad_weather(self, mock_forecast):
273272
"""Test skip check triggers for bad weather"""
274273
mock_forecast.return_value = {
275-
"success": True,
276-
"consecutive_bad_days": 3
274+
"success": True
277275
}
278276

279277
with patch('app.DeyeCloudClient'):
280278
import app as app_module
281279
app_module.config = {"weather": {"enabled": True, "min_solar_threshold_kwh": 5.0}}
282280
app_module.weather_client = Mock()
283281
app_module.weather_analyser = Mock()
284-
app_module.weather_analyser.should_skip_discharge.return_value = (True, "3 bad days")
282+
app_module.weather_analyser.should_skip_discharge.return_value = (True, "Low solar forecast")
285283

286284
should_skip, reason = app_module.should_skip_discharge_for_weather()
287285

288286
assert should_skip is True
289-
assert "3 bad days" in reason
287+
assert "Low solar" in reason
290288

291289

292290
class TestGetWeatherForecast:
@@ -391,7 +389,7 @@ class TestSchedulerWeatherIntegration:
391389
def test_scheduler_skips_for_weather(self, mock_window, mock_weather_skip):
392390
"""Test scheduler skips discharge when weather is bad"""
393391
mock_window.return_value = True
394-
mock_weather_skip.return_value = (True, "Bad weather for 3 days")
392+
mock_weather_skip.return_value = (True, "Low solar forecast")
395393

396394
with patch('app.DeyeCloudClient') as mock_deye:
397395
mock_client = Mock()
@@ -1295,7 +1293,7 @@ def stop_after_one(*args):
12951293
def test_scheduler_weather_skip_logs(self, mock_weather, mock_window, mock_sleep):
12961294
"""Test scheduler logs weather skip"""
12971295
mock_window.return_value = True
1298-
mock_weather.return_value = (True, "Bad weather for 3 days")
1296+
mock_weather.return_value = (True, "Low solar forecast")
12991297

13001298
with patch('app.DeyeCloudClient') as mock_deye:
13011299
mock_client = Mock()
@@ -1336,7 +1334,7 @@ def stop_after_one(*args):
13361334
app_module.scheduler_loop()
13371335

13381336
assert app_module.current_state["weather_skip_active"] is True
1339-
assert app_module.current_state["weather_skip_reason"] == "Bad weather for 3 days"
1337+
assert app_module.current_state["weather_skip_reason"] == "Low solar forecast"
13401338

13411339
@patch('app.time.sleep')
13421340
@patch('app.is_within_discharge_window')

0 commit comments

Comments
 (0)