Skip to content

EPIC 2: speed up eDisGo runtime on large grids (#671)#680

Open
joda9 wants to merge 8 commits into
devfrom
671-epic-2-speed-up-edisgo-runtime-on-large-grids
Open

EPIC 2: speed up eDisGo runtime on large grids (#671)#680
joda9 wants to merge 8 commits into
devfrom
671-epic-2-speed-up-edisgo-runtime-on-large-grids

Conversation

@joda9

@joda9 joda9 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Umbrella PR for EPIC 2 (#671) — speed up the eDisGo runtime on large grids.
The recurring root cause across the codebase was super-linear accumulation:
pd.concat / add_component_time_series inside per-element loops (O(n²)) and
per-row pandas work in Python loops. Each sub-issue removes one such hotspot.
Unless noted, the outputs are unchanged (numerically identical, or identical up
to negligible floating-point reordering noise).

Changes per sub-issue

Issue Area Change Output
#672 tools.geo.find_nearest_bus Vectorised the per-bus geodesic loop (equirectangular NN, exact geodesic only for the winner) distance exact
#673 network.electromobility.get_flexibility_bands Removed the per-charging-process iterrows loop; bands built via difference arrays + a single cumsum (vectorised numpy.add.at) unchanged (~1e-13 reorder noise)
#674 io.timeseries_import.get_residential_electricity_profiles_per_building Replaced the O(n²) per-building pd.concat with a single sparse matrix product (incidence × profiles); adds scipy dependency unchanged (~1e-18 reorder noise)
#675 io.electromobility_import.distribute_private_charging_demand Deferred the per-car full-frame write-back to one vectorised post-loop assignment; per-car capacity lookup via groupby bit-identical
#677 flex_opt.charging_strategies.charging_strategy Batched the per-park time-series writes in the dumb/reduced branches (one add_component_time_series instead of O(parks²) concat); residual already batched unchanged
#678 io.timeseries_import.get_cts_profiles_per_building Concatenate per-grid CTS profiles once instead of inside the bus_id loop unchanged

Not in this PR

Verification

  • Each refactor was checked for output equivalence (synthetic harnesses replaying
    old vs new logic; numerical identity or float-reorder noise on the order of
    1e-13…1e-18).
  • Relevant test modules pass locally: test_geo, test_electromobility
    (flexibility bands), test_timeseries_import (residential + CTS, against the
    OEP DB), test_electromobility_import (distribute_charging_demand),
    test_charging_strategy.

Checklist

  • New and adjusted code is formatted using the pre-commit hooks (ruff; one
    unrelated pre-existing E501 in electromobility_import.py left untouched)
  • New and adjusted code includes type hinting
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (docstrings + whatsnew)
  • I have added tests that prove my fix is effective or that my feature works
    (new find_nearest_bus tests; the other refactors are output-preserving and
    covered by existing tests + equivalence harnesses)
  • New and existing unit tests pass locally with my changes (DB-dependent OEP
    tests are environment-dependent and unaffected by these changes)
  • The Read the Docs documentation is compiling correctly (not built locally)
  • If new packages are needed, I added them to setup.py, rtd_requirements.txt,
    eDisGo_env.yml and eDisGo_env_dev.yml (scipy for 2.3 — Remove O(n²) concat in residential demand-profile assembly #674)
  • I have added new features to the corresponding whatsnew file

Closes #672, #673, #674, #675, #677, #678. Part of #671.

joda9 added 7 commits June 17, 2026 15:55
Computing an exact geopy geodesic to every candidate bus per charging
park is O(parks x buses) of iterative Karney calls and dominated wall
time on large grids (e.g. 32064); small grids were unaffected.

Replace the per-bus loop with a vectorised equirectangular
nearest-neighbour search (preserves NN ordering at grid-district scale)
and compute the exact geodesic only for the winning bus, so the returned
distance -- used as the connection line length -- stays exact.

The dropped bus_target[dist] side-effect column was unused by all
callers (components.py, topology.py, edisgo.py, generators_import.py).

Verified numerically identical: 200 random grid-district-scale trials
show 0 nearest-neighbour mismatches vs. the old method and winner
distances matching the exact geodesic to <1e-9 km.

Part of #671.
…674)

Replace the O(n^2) per-building pd.concat loop in
get_residential_electricity_profiles_per_building with a single sparse
incidence-matrix product (profiles @ incidence) followed by a per-building
factor scaling. The previous concat-in-loop copied the whole growing frame
on every iteration and was the dominant cost on large grids with thousands
of buildings.

Building columns are sorted to match the previous groupby(sort=True) order
and the trailing dropna(axis=1) preserves the drop-on-NaN-factor behaviour,
so the returned profiles are unchanged apart from negligible floating-point
reordering noise (~1e-18). Type-hint the adjusted function and add scipy as
a dependency.

Part of #671.
Build the upper power, upper energy and lower energy bands without the
per-charging-process Python loop. Each process adds a constant value to a
contiguous range of time steps; encoding every range as two point updates on
a difference array lets all processes be applied at once with a few
numpy.add.at calls, and a single cumsum per band reconstructs the values.

This removes the iterrows loop that dominated runtime on large grids (~9x
faster on a 300k-process / 2000-charging-point / 35040-step case) and avoids
the per-row pandas label-slice assignments that could intermittently segfault
in _setitem_with_indexer. Band values are unchanged apart from negligible
floating-point reordering noise (~1e-13). Type-hint the method and document
the algorithm in the docstring Notes section.

Part of #671.
…g_demand (#675)

Record the drawn charging park and charging point per (car_id, destination)
and write them back to charging_processes_df in a single vectorised step after
the loop, instead of once per car. The previous per-car write-back matched
car_id and destination against the whole frame for every car -- O(cars x rows)
and the dominant cost on large grids; collecting the assignment and applying it
once is O(rows). The per-car nominal-capacity lookup is likewise replaced by a
single groupby.

The weighted random draws and the capacity bookkeeping are left untouched, so
the resulting assignment is bit-identical (verified against the original loop
on synthetic data). This intentionally preserves the pre-existing home-branch
bug where capacity is booked to a stale charging_park_id; that is tracked
separately in #679. Type-hint the function and document the approach in the
docstring Notes section.

Part of #671.
scipy was added as a direct dependency in setup.py but was missing from
rtd_requirements.txt, eDisGo_env.yml and eDisGo_env_dev.yml, which also list
the other direct dependencies. Add it there with the same constraint
(scipy < 1.18.0).

Part of #671.
In the dumb and reduced branches, collect each charging park's load
series and add them to loads_active_power in a single
add_component_time_series call after the loop, instead of one call per park.
Each call concatenates onto the growing time-series frame, so the previous
per-park add was O(parks^2) and dominated the runtime on grids with many
charging parks. The residual branch already builds and adds its columns in
one go and is left unchanged.

harmonize_charging_processes_df does not read the load time series, so moving
the write out of the loop is safe; the resulting time series are identical
(concatenation of independent columns in the same order).

Part of #671.
…_building (#678)

Collect the per-grid CTS profiles in a list and concatenate them in a single
pd.concat after the loop, instead of concatenating onto a growing frame on
every iteration (O(grids^2)). None results (grids without a substation profile)
are dropped explicitly, matching the previous behaviour where the per-iteration
pd.concat silently ignored them. The returned profiles are unchanged.

Profiling the rest of the set_time_series_active_power_predefined umbrella
(get_cts_profiles_per_grid, the industrial and feed-in importers) showed no
further concat-in-loop or per-row pattern; the remaining cost is dominated by
the database queries and the explode/pivot of the imported series.

Part of #671.
@joda9 joda9 linked an issue Jun 17, 2026 that may be closed by this pull request
Follow-up to the EPIC 2 audit (#671): a codebase-wide sweep for super-linear
accumulation and per-row Python work on pandas/graph objects. All changes are
output-identical apart from negligible floating-point reordering noise where
noted.

- io/powermodels_io.py: replace the O(n^2) per-component lookup in
  (linear name scan + per-call frame rebuild) with a cached
  and hoist the loop-invariant generator-type masks out of

- tools/pseudo_coordinates.py: rewrite the per-node
  searches in  as single single-source traversals (+ deque)
- flex_opt/check_tech_constraints.py: collect per-grid/per-station frames and
  concat once in ,  and

- flex_opt/battery_storage_operation.py: replace the per-time-step
  in  with a numpy array loop (sequential SoE recursion
  unchanged) and defer the per-storage  concat to a single call
- flex_opt/reinforce_measures.py: batch the per-station transformer
  additions/removals into single concat/drop operations after the loop
- network/timeseries.py: vectorise the  time-series setters
  ( -> broadcast multiply)
- io/generators_import.py, io/heat_pump_import.py, io/storage_import.py: batch
  the per-component grid-integration loops (single concat + Index build)
- tools/tools.py: vectorise ; replace the per-bus
  shortest-path loops in  with single-source
  traversals (existing LV metric preserved pending #681)
- tools/powermodels_io.py: pull the time-series frames to numpy arrays once in
  /
- flex_opt/costs.py, edisgo.py, tools/spatial_complexity_reduction.py,
  tools/preprocess_pypsa_opf_structure.py: vectorise/defer the remaining
  growing-frame concats and per-bus shortest-path loops

Verified output-identical with synthetic harnesses replaying old vs new logic
(max abs diff 0.0; battery checked against the previous implementation) and
benchmarked at realistic scale.

Part of #671

closes #682
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EPIC 2 — Speed up eDisGo runtime on large grids 2.1 — Vectorise find_nearest_bus (drop the per-bus geodesic loop)

1 participant