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

Commit 9469969

Browse files
committed
minor tweaks
1 parent fecd6e1 commit 9469969

7 files changed

Lines changed: 47 additions & 74 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: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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")
@@ -913,7 +926,7 @@ def get_weather():
913926
})
914927

915928
forecast = get_weather_forecast()
916-
if not forecast:
929+
if not forecast or not forecast.get("success", False):
917930
return jsonify({
918931
"success": False,
919932
"enabled": True,
@@ -928,8 +941,7 @@ def get_weather():
928941
# Sanitize forecast to only include UI-safe fields
929942
safe_forecast = {
930943
"success": True,
931-
"daily": [],
932-
"consecutive_bad_days": forecast.get("consecutive_bad_days", 0)
944+
"daily": []
933945
}
934946
for day in forecast.get("daily", []):
935947
safe_day = {

‎templates/index.html‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,10 @@ <h2>Schedule Settings</h2>
850850
<label>Discharge SoC %</label>
851851
<input type="number" id="cutoffSoc" min="5" max="100" value="50" title="During discharge window - stop when battery reaches this level">
852852
</div>
853+
<div class="setting-item">
854+
<label>Reactivation %</label>
855+
<input type="number" id="reactivationMargin" min="0" max="20" value="5" title="Buffer above cutoff to restart discharge - prevents cycling">
856+
</div>
853857
</div>
854858
<button type="submit" class="btn btn-primary btn-sm">Save</button>
855859
</form>
@@ -1327,6 +1331,7 @@ <h2>Schedule Settings</h2>
13271331
document.getElementById('endTime').value = schedule.force_discharge_end || '19:30';
13281332
document.getElementById('minSocReserve').value = schedule.min_soc_reserve || 20;
13291333
document.getElementById('cutoffSoc').value = schedule.force_discharge_cutoff_soc || 50;
1334+
document.getElementById('reactivationMargin').value = schedule.reactivation_margin || 5;
13301335
}
13311336

13321337
// TOU table
@@ -1368,7 +1373,8 @@ <h2>Schedule Settings</h2>
13681373
force_discharge_start: document.getElementById('startTime').value,
13691374
force_discharge_end: document.getElementById('endTime').value,
13701375
min_soc_reserve: parseInt(document.getElementById('minSocReserve').value),
1371-
force_discharge_cutoff_soc: parseInt(document.getElementById('cutoffSoc').value)
1376+
force_discharge_cutoff_soc: parseInt(document.getElementById('cutoffSoc').value),
1377+
reactivation_margin: parseInt(document.getElementById('reactivationMargin').value) || 5
13721378
};
13731379

13741380
try {
@@ -1472,9 +1478,9 @@ <h2>Schedule Settings</h2>
14721478
`;
14731479
}
14741480

1475-
// Forecast cards
1481+
// Forecast cards - limit to 4 days for better mobile display
14761482
let forecastHtml = '<div class="weather-container">';
1477-
daily.forEach((day, index) => {
1483+
daily.slice(0, 4).forEach((day, index) => {
14781484
const classes = ['weather-day'];
14791485
// Only highlight future days for bad weather (not today) since discharge decision is based on future forecasts
14801486
if (!day.is_today && day.is_bad_weather) classes.push('bad-weather');

‎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')

‎tests/test_weather_client.py‎

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -279,34 +279,6 @@ def test_is_bad_weather_day_partly_cloudy(self):
279279
day = {"condition": "Clouds", "clouds": 40, "pop": 10}
280280
assert self.analyser._is_bad_weather_day(day) is False
281281

282-
def test_count_consecutive_bad_days_none(self):
283-
"""Test consecutive bad days count with no bad days"""
284-
daily = [
285-
{"is_bad_weather": False},
286-
{"is_bad_weather": False},
287-
{"is_bad_weather": False}
288-
]
289-
assert self.analyser._count_consecutive_bad_days(daily) == 0
290-
291-
def test_count_consecutive_bad_days_all(self):
292-
"""Test consecutive bad days count with all bad days"""
293-
daily = [
294-
{"is_bad_weather": True},
295-
{"is_bad_weather": True},
296-
{"is_bad_weather": True}
297-
]
298-
assert self.analyser._count_consecutive_bad_days(daily) == 3
299-
300-
def test_count_consecutive_bad_days_mixed(self):
301-
"""Test consecutive bad days count with mixed days"""
302-
daily = [
303-
{"is_bad_weather": True},
304-
{"is_bad_weather": True},
305-
{"is_bad_weather": False},
306-
{"is_bad_weather": True}
307-
]
308-
assert self.analyser._count_consecutive_bad_days(daily) == 2
309-
310282
def test_should_skip_discharge_low_solar(self):
311283
"""Test skip discharge when solar forecast is below threshold"""
312284
forecast = {

‎weather_client.py‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def _make_request_with_retry(self, url: str, params: dict, max_retries: int = 2)
176176

177177
def get_forecast(self) -> Dict[str, Any]:
178178
"""
179-
Get 7-day weather forecast with hourly data for solar predictions.
179+
Get 4-day weather forecast with hourly data for solar predictions.
180180
Uses Open-Meteo free API (no API key required).
181181
"""
182182
if self._is_cache_valid() and "forecast" in self._cache:
@@ -190,7 +190,7 @@ def get_forecast(self) -> Dict[str, Any]:
190190
"hourly": "temperature_2m,cloud_cover,precipitation_probability,precipitation,weather_code",
191191
"daily": "temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,precipitation_probability_max",
192192
"timezone": self.timezone_str,
193-
"forecast_days": 7
193+
"forecast_days": 4
194194
}
195195

196196
logger.info("Fetching weather forecast from Open-Meteo")
@@ -270,7 +270,7 @@ def _parse_forecast(self, data: Dict) -> Dict[str, Any]:
270270

271271
today = datetime.now().strftime("%Y-%m-%d")
272272

273-
for i, date_str in enumerate(dates[:7]):
273+
for i, date_str in enumerate(dates[:4]):
274274
dt = datetime.strptime(date_str, "%Y-%m-%d")
275275
weather_code = weather_codes[i] if i < len(weather_codes) else 0
276276
condition = self._weather_code_to_condition(weather_code)
@@ -465,12 +465,10 @@ def __init__(self, latitude: float, longitude: float, declination: int = None,
465465
@staticmethod
466466
def _calculate_optimal_tilt(latitude: float) -> int:
467467
"""
468-
Calculate optimal panel tilt angle based on latitude.
469-
Rule of thumb: tilt angle ~ latitude for year-round optimization.
468+
Default panel tilt angle.
469+
Most residential installs follow roof pitch (typically 15-25 degrees).
470470
"""
471-
# Use absolute latitude, clamped between 10-60 degrees
472-
optimal = abs(latitude)
473-
return int(max(10, min(60, optimal)))
471+
return 25
474472

475473
@staticmethod
476474
def _calculate_optimal_azimuth(latitude: float) -> int:
@@ -560,7 +558,7 @@ def _parse_forecast(self, data: Dict) -> Dict[str, Any]:
560558
daily_forecasts = []
561559
today = datetime.now().strftime("%Y-%m-%d")
562560

563-
for date_str, wh in sorted(daily_wh.items())[:7]:
561+
for date_str, wh in sorted(daily_wh.items())[:4]:
564562
dt = datetime.strptime(date_str, "%Y-%m-%d")
565563
kwh = wh / 1000.0
566564

@@ -696,7 +694,6 @@ def analyse_forecast(
696694
bad_weather_days.append(day["date"])
697695

698696
forecast["bad_weather_days"] = bad_weather_days
699-
forecast["consecutive_bad_days"] = self._count_consecutive_bad_days(daily)
700697

701698
return forecast
702699

@@ -717,16 +714,6 @@ def _is_bad_weather_day(self, day: Dict) -> bool:
717714

718715
return False
719716

720-
def _count_consecutive_bad_days(self, daily: List[Dict]) -> int:
721-
"""Count consecutive bad weather days starting from today"""
722-
count = 0
723-
for day in daily:
724-
if day.get("is_bad_weather"):
725-
count += 1
726-
else:
727-
break
728-
return count
729-
730717
def should_skip_discharge(
731718
self,
732719
forecast: Dict[str, Any],

0 commit comments

Comments
 (0)