Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a325492
perf(kernel): skip the discarded SoC series, and add a batch entry point
springfall2008 Aug 15, 2026
2e71022
perf(kernel): replace the hot-loop round_py with precomputed SoC perc…
springfall2008 Aug 15, 2026
be2510d
test(kernel): pin the SoC percent table, and rebuild the shipped bina…
springfall2008 Aug 15, 2026
f813d21
fix(kernel): do not clamp the batch SoC range when no range was asked…
springfall2008 Aug 15, 2026
81ceaef
docs: hand-off notes for the Python batching refactor
springfall2008 Aug 15, 2026
6693c34
docs: plan for batching the prediction fan-out from Python
springfall2008 Aug 15, 2026
7647358
fix(prediction): build the export trial window privately instead of m…
springfall2008 Aug 15, 2026
877cc8b
chore: green the quality gate on the batching branch
springfall2008 Aug 15, 2026
30d025e
feat(kernel): marshal a whole fan-out into one pk_run_batch call
springfall2008 Aug 15, 2026
5044b58
test(kernel): pin the pk_run_batch argtypes and the window memo's cac…
springfall2008 Aug 15, 2026
e29f9d1
refactor(prediction): split trial-input building out of thread_run_pr…
springfall2008 Aug 15, 2026
c1f59cb
perf(plan): batch the prediction fan-out into one kernel call
springfall2008 Aug 15, 2026
2675169
test(batch): pin the debug kernel gate by mechanism, not by value
springfall2008 Aug 15, 2026
a0b8059
perf(plan): run each batched fan-out across kernel threads
springfall2008 Aug 15, 2026
8e2c0e5
refactor(plan): remove the prediction process pool
springfall2008 Aug 15, 2026
ed15d44
fix(prediction): drain a pending batch before a saving run, and pin r…
springfall2008 Aug 15, 2026
cee8bdd
docs: describe the batch model everywhere the pool used to be
springfall2008 Aug 15, 2026
8905bb5
docs(comments): correct references to batched prediction architecture
springfall2008 Aug 15, 2026
1b5305b
docs: record what the batching refactor measured
springfall2008 Aug 15, 2026
10c7f73
docs: replace the inherited baseline with a measured one
springfall2008 Aug 15, 2026
b633d4f
chore: update prediction kernel binaries for all platforms
github-actions[bot] Aug 15, 2026
d43072e
fix(kernel): bump the ABI to 4 so a pre-batch binary is rejected, not…
springfall2008 Aug 16, 2026
9546ffe
chore: update prediction kernel binaries for all platforms
github-actions[bot] Aug 16, 2026
be9c495
perf(kernel): persistent worker pool instead of threads per batch (6%…
springfall2008 Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ armhf
armv
ASHP
asyncio
atfork
authoriser
autodocstring
autoflake
Expand All @@ -51,6 +52,7 @@ byok
cadata
calib
Cantarell
CDLL
cdn
Cerbo
cexxxx
Expand All @@ -77,6 +79,7 @@ corruptplans
cprofile
creds
crosscharge
ctypes
customauthorizer
customisation
Customise
Expand Down Expand Up @@ -378,6 +381,7 @@ preseeded
prevs
protobuf
psum
pthread
pvbat
pvenergytotal
pvlib
Expand Down Expand Up @@ -447,6 +451,7 @@ slugifies
slugify
slugifying
snakeviz
snprintf
socb
socketloop
socs
Expand All @@ -472,6 +477,7 @@ stdlib
stepline
Stromligning
strptime
strtod
Strømligning
subclassing
submessage
Expand All @@ -489,6 +495,7 @@ temperation
Teslemetry
testname
thirdparty
threadpool
timea
timeb
timec
Expand Down Expand Up @@ -520,6 +527,8 @@ unconfigured
undiscounted
unnormalised
unparseable
unpickled
unpushed
unsmoothed
unstaged
urlsafe
Expand All @@ -530,10 +539,14 @@ venv
Victron
visibilitychange
waf
waitpid
Wallbox
weblink
welink
Werror
WEXITSTATUS
WIFEXITED
WNOHANG
workmode
writeonly
wrongsha
Expand Down
54 changes: 23 additions & 31 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,44 +88,37 @@ The core prediction/planning happens in `Plan` mixin (`apps/predbat/plan.py`) wh
- `optimise_detailed_pass()` - Fine-tune charge/export windows
- `run_prediction()` - Execute battery simulation via `Prediction` class

### Thread Pool System
### Batched Predictions

**Multiprocessing for predictions** (in `apps/predbat/plan.py`):
There is no process pool and no Python thread pool. Every optimiser fan-out in `plan.py` is
launch-all-then-collect-all, so the launches queue instead of running and the whole fan-out is
handed to the C++ kernel in one call, which spreads it across its own threads with the GIL
released. Python itself is strictly serial.

```python
from multiprocessing import Pool, cpu_count

# Pool created on demand
if not self.pool:
threads = self.get_arg("threads", "auto")
if threads == "auto":
self.pool = Pool(processes=cpu_count())
else:
self.pool = Pool(processes=int(threads))
```
**How it works** (`apps/predbat/prediction_batch.py`, mixed into `Prediction`):

**How it works**:

- `self.pool` is a `multiprocessing.Pool` instance (NOT thread pool - uses processes)
- Pool runs prediction scenarios in parallel using `pool.apply_async()`
- Wrapped functions in `prediction.py`: `wrapped_run_prediction_single()`, `wrapped_run_prediction_charge()`, etc.
- Global state shared via `PRED_GLOBAL` dict (copied to child processes)
- Pool configured via `threads` config option: `"auto"` for CPU count or specific number
- Returns `DummyThread` objects when pool disabled (for single-threaded testing)
- `Plan.launch_run_prediction_*()` calls `Prediction.queue_run_prediction_*()`, which builds the
trial inputs, appends a `BatchJob` to `self.pending_batch` and returns a `BatchHandle`
- Nothing has run at that point. The first `BatchHandle.get()` calls `flush_batch()`, which resolves
prediction-cache hits and intra-batch duplicates in Python and sends the remainder through one
`run_prediction_kernel_batch()` / `pk_run_batch` call; every handle in the fan-out is then resolved
- A job the kernel will not take (no kernel, stale binary, debug run) falls back to
`Prediction.run_prediction()`, which is also the direct path `thread_run_prediction_*()` uses
- `threads` config option sets `Prediction.batch_threads`, the kernel's thread count. `"auto"` uses
the CPU count. Results are identical at any thread count - it only trades CPU for planning time

**Key pattern**:

```python
# Async call to run prediction in pool
if self.pool and self.pool._state == "RUN":
han = self.pool.apply_async(wrapped_run_prediction_charge, (args...))
return han
else:
# Fallback to synchronous execution
result = wrapped_run_prediction_charge(args...)
return DummyThread(result)
# Queue the whole fan-out, then read the handles - the first get() runs all of it
handles = [self.launch_run_prediction_charge(soc, window_n, ...) for soc in try_socs]
results = [handle.get() for handle in handles]
```

**Invariant**: a caller must not mutate any list or window dict it has passed to
`launch_run_prediction_*` until it has read the matching handle, because those inputs are not read
until the batch is flushed.

### Component System (Critical Pattern)

All major features inherit from `ComponentBase` (see `apps/predbat/component_base.py`):
Expand Down Expand Up @@ -281,8 +274,7 @@ self.log("Error: Failed with: {}".format(e))
Core prediction happens in `apps/predbat/prediction.py`:

- `Prediction` class runs battery simulation in 5-min steps
- Multiprocessing via `wrapped_run_prediction_*` functions
- Global state in `PRED_GLOBAL` dict for process sharing
- Batched predictions via `launch_run_prediction_*()` queuing jobs and `BatchHandle.get()` flushing through the C++ kernel
- Optimisation in `apps/predbat/plan.py` via `optimise_all_windows()`

### State Management
Expand Down
16 changes: 8 additions & 8 deletions apps/predbat/annual.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,14 +997,14 @@ def prepare_sample(predbat, config, weather, tariff, load_source, day, midnight_
"""
reset_sample_state(predbat)

# calculate_plan() spins up a multiprocessing Pool sized from args["threads"] (plan.py)
# unless it is exactly 0. The annual tool plans hundreds of individual days per run, each a
# small, fast calculation, so per-day pool creation is both wasted overhead and, on a
# spawn-based multiprocessing start method, unsafe: pool workers read the module-level
# PRED_GLOBAL dict in prediction.py, which is only populated in the parent process. This
# matches create_headless_predbat()'s own choice for a fully offline run; it is forced here
# too so run_day() behaves the same way against a caller-supplied PredBat instance (such as
# the standard unit test fixture) that has not gone through that bootstrap.
# calculate_plan() sizes the C++ kernel's batch thread count from args["threads"] (plan.py); it
# no longer spins up a Python multiprocessing Pool. pk_run_batch creates and joins its own
# std::thread pool fresh on every call, so leaving threads at its 'auto' default would pay that
# creation cost on each of the hundreds of small, fast per-day calculations the annual tool runs
# - overhead a plan this size cannot recoup. This matches create_headless_predbat()'s own choice
# for a fully offline run; it is forced here too so run_day() behaves the same way against a
# caller-supplied PredBat instance (such as the standard unit test fixture) that has not gone
# through that bootstrap.
predbat.args["threads"] = 0

predbat.midnight_utc = midnight_utc
Expand Down
4 changes: 1 addition & 3 deletions apps/predbat/hass.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ def log(self, msg, quiet=True):
log_size = self.logfile.tell()
if log_size > 10000000 and threading.current_thread() is threading.main_thread():
# Only rotate from the main thread to avoid race conditions with
# component threads that also call log() — a Threading.Lock cannot
# be used here because the logfile object must survive pickle for
# the multiprocessing pool.
# component threads that also call log().
for num_logs in range(max_logs - 1, 0, -1):
filename = "predbat." + format(num_logs) + ".log"
if os.path.isfile(filename):
Expand Down
6 changes: 4 additions & 2 deletions apps/predbat/load_ml_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,8 +736,10 @@ async def run(self, seconds, first):
self.log("ML Component: No training needed, model age is {} hours".format(dp2(retrain_age_seconds / 3600.0)))

if should_train or should_fetch:
# Set load_ml_calculating across all NumPy-heavy work (training + predict + save)
# so that the plan's multiprocessing pool is never fork()ed while Numpy threads are active.
# Set load_ml_calculating across all NumPy-heavy work (training + predict + save). This used
# to gate the plan's multiprocessing pool off while NumPy threads were active (fork() is
# unsafe with live threads); the pool is gone, so this is now purely a status flag surfaced
# via is_calculating() and logged by calculate_plan().
if self.base.prediction_started:
self.log("ML Component: Waiting for current prediction cycle to complete before running ML work")
while self.base.prediction_started:
Expand Down
3 changes: 1 addition & 2 deletions apps/predbat/marginal.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,7 @@ def calculate_marginal_costs(self):
if minute in modified_load:
modified_load[minute] += extra_per_step

# Create a fresh Prediction with the modified load; this updates PRED_GLOBAL
# which is safe since we run synchronously (pool is idle at this point)
# Create a fresh Prediction with the modified load.
# No need to include 10% extra load as we only run normal simulations.
pred = Prediction(self, self.pv_forecast_minute_step, self.pv_forecast_minute_step, modified_load, modified_load)

Expand Down
Loading
Loading