Skip to content

Building multiasset extension#777

Merged
Manish-Khanra merged 11 commits into
mainfrom
building_multiasset_extension
May 6, 2026
Merged

Building multiasset extension#777
Manish-Khanra merged 11 commits into
mainfrom
building_multiasset_extension

Conversation

@Manish-Khanra
Copy link
Copy Markdown
Contributor

Related Issue

If no issue exists, delete this line.

Description

This PR extends the building unit to support multi-asset configurations, with a particular focus on integrating multiple electric vehicles and charging stations into the building framework.

The main motivation is to move beyond the previous singleton-style setup in which only one asset of a given type could be connected to a building. With this change, buildings can now contain multiple EVs and multiple charging stations while preserving the existing building functionality for heat pumps, boilers, thermal storage, PV, and generic storage.

Main changes

  • Added support for prefix-based multi-asset handling in Building, allowing multiple components such as electric_vehicle_1, electric_vehicle_2, charging_station_1, etc.
  • Added a dedicated ChargingStation DSM component in dst_components.py with:
    • unidirectional or bidirectional operation
    • optional availability profiles
    • ramp constraints
    • binary non-simultaneity for charging/discharging in bidirectional mode
  • Extended the ElectricVehicle DSM component with:
    • availability-dependent operation
    • external range input
    • mobility-related energy usage
    • unidirectional or bidirectional power flow
    • binary non-simultaneity for charging/discharging
  • Extended building connection logic:
    • if no charging station is present, EVs are connected directly to the building/grid balance
    • if charging stations are present, charging stations connect to the building/grid and EVs connect to charging stations through assignment variables
  • Updated DSMFlex.initialize_components() to support prefixed DSM component names and to pass EV-specific external range data during model construction
  • Updated BuildingForecaster and loader_csv.py to support:
    • aggregate building profiles such as <building_id>_load_profile
    • EV-specific availability and range profiles
    • charging-station-specific availability profiles
    • building-specific electricity_price_flex for use with electricity_price_signal
  • Fixed switching between optimisation and flexibility modes in DSMFlex.switch_to_opt() so it no longer assumes that flexibility constraints exist for all flexibility measures
  • Fixed variable-cost handling when flexible electricity-price signals are activated
  • Added/updated release notes
  • Expanded tests/test_building.py to cover both legacy building functionality and the new EV + charging-station integration logic

Scope of testing

The updated building tests cover:

  • existing building assets such as heat pumps, boilers, thermal storage, PV, and generic storage
  • multiple EVs
  • multiple charging stations
  • EV availability and range-dependent usage
  • EV state-of-charge balance
  • charging-station availability and ramping
  • EV-to-charging-station assignment consistency
  • direct-grid fallback when no charging station is present
  • total building power balance with and without charging stations

Checklist

  • Documentation updated (docstrings, READMEs, user guides, inline comments, doc folder updates etc.)
  • New unit/integration tests added (if applicable)
  • Changes noted in release notes (if any)
  • Consent to release this PR's code under the GNU Affero General Public License v3.0

Additional Notes (optional)

  • Temporary CSV export utilities were used during development for internal validation of EV and charging-station operation, but they are not part of the formal test scope and are not intended as a user-facing feature.
  • Existing example inputs may need updated forecast column names and component-specific profile columns to run with the new building multi-asset functionality.
  • The release note entry for this change set was prepared as a minor feature release.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 83.07692% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.49%. Comparing base (9c7a0bf) to head (b5cdc7e).

Files with missing lines Patch % Lines
assume/units/dsm_load_shift.py 59.37% 26 Missing ⚠️
assume/scenario/loader_csv.py 0.00% 17 Missing ⚠️
assume/units/building.py 93.14% 12 Missing ⚠️
assume/units/dst_components.py 91.20% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #777      +/-   ##
==========================================
- Coverage   80.79%   80.49%   -0.31%     
==========================================
  Files          56       56              
  Lines        8676     9004     +328     
==========================================
+ Hits         7010     7248     +238     
- Misses       1666     1756      +90     
Flag Coverage Δ
pytest 80.49% <83.07%> (-0.31%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Collaborator

@carl-wanninger carl-wanninger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a review draft. Ask for second opinion.

Comment thread assume/units/building.py Outdated
Comment thread assume/units/building.py
Comment thread assume/units/building.py Outdated
Comment thread assume/units/building.py Outdated
Comment thread assume/units/building.py Outdated
Comment thread assume/units/building.py
return m.total_power_input[t] == total_power

def define_constraints(self):
"""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the rationale behind getting rid of these docstrings?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have repased the old docstring with the new one becsue I thinnk the updated one can bring more elaboration to the users, in my perspective. What do you think about it?

if technology in demand_side_technologies:
# Get the class from the dictionary mapping (adjust `demand_side_technologies` to hold classes)
component_class = demand_side_technologies[technology]
base_technology = next(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini suggestion:

for tech in demand_side_technologies:
    if technology.startswith(tech):
        base_technology = tech
        break
else:
    raise ValueError(f"Unknown DSM component technology: {technology}")

Specific electricity consumption for driving.
Interpreted here as energy per unit of external_range.
Example: if external_range is km, mileage should be MWh/km.
power_flow_directionality : str, default "unidirectional"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would find
bidirectional: boolean | False
simpler.

Comment thread tests/test_building.py
@@ -6,763 +6,805 @@
import pyomo.environ as pyo
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it is a little confusing that there are ev tests in both test_building.py and test_ev.py .

Comment thread tests/test_building.py
@@ -6,763 +6,805 @@
import pyomo.environ as pyo
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think test section would heavily profit from testing optimization.
That is: Here is a price signal now let us check that our building/EV actually finds the optimal solution. ( For various lengths).

Comment thread assume/scenario/loader_csv.py Outdated
)

# Apply predefined charging profile constraints if provided
@model_block.Constraint(self.time_steps)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In our understanding, this constraint (no charging while driving and vice versa) should be handled in the input validation and not during runtime

Copy link
Copy Markdown
Collaborator

@carl-wanninger carl-wanninger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the provided tests and an additional test, the optimization seems to work as intended. Nice work!

We think two more things would be necessary for the code to be fully functional.

  1. Right now I can have an EV with availability [1, 1] and trip_distances [80, 80]. This is physically not possible and should raise a validation error.

Either:
Soft validation constraint trip_distance > 0 -> avalability must be smaller than one.
or:
Hard validation constraint: trip_distance > 0 -> availability must be 0.

  1. We again strongly encourage that instead of trip_distance + mileage as input, the model should directly take used_energy as input. The reasoning is that if a dataset provides mileage and trip distance, it is very simple to calculate used energy. However, if a dataset only provides used energy (such as synpro from Fraunhofer ISE), one does have to fingate trip distance, which is very cumbersome.

In a future extension it might be nice to have soc pivots where the EV is requested to have a certain SoC at certain points of time - but this has not to be handled by this PR.

@carl-wanninger
Copy link
Copy Markdown
Collaborator

Here is a test we constructed based on the provided tests to convince ourselves of the simultanoues arbitrage possiblity and trip distance handling.

def test_ev_able_to_make_arbitrage_and_meet_demand(time_index):
# --- 1. Setup Data & Forecaster ---
prices = [-10, 10, -10, 10, 100, -100, -100, 100]
market_prices = {"EOM": _series(prices, time_index)}

# EV 1: Available except hours 1 & 2. Trip in hours 1 & 2.
avail_1 = [1, 0, 0, 1, 1, 1, 1, 1]
dist_1  = [0, 40, 40, 0, 0, 0, 0, 0]

# EV 4: Available except hours 2 & 3. Trip in hours 2 & 3.
avail_2 = [1, 1, 0, 0, 1, 1, 1, 1]
dist_2  = [0, 0, 40, 40, 0, 0, 0, 0]

forecaster = BuildingForecaster(
    index=time_index,
    fuel_prices={"natural_gas": _series([20] * 8, time_index)},
    market_prices=market_prices,
    electricity_price_flex=_series(prices, time_index),
    A360_electric_vehicle_1_availability_profile=_series(avail_1, time_index),
    A360_electric_vehicle_1_trip_distance=_series(dist_1, time_index),
    A360_electric_vehicle_2_availability_profile=_series(avail_2, time_index),
    A360_electric_vehicle_2_trip_distance=_series(dist_2, time_index),
)

# --- 2. Define Components ---
ev_config = {
    "capacity": 80.0,
    "min_soc": 0.0,
    "max_soc": 1.0,
    "max_power_charge": 11.0,
    "max_power_discharge": 11.0,
    "initial_soc": 0.7,
    "efficiency_charge": 0.95,
    "efficiency_discharge": 0.95,
    "ramp_up": 11.0,
    "ramp_down": 11.0,
    "storage_loss_rate": 0.0,
    "mileage": 0.5,
    "power_flow_directionality": "bidirectional",
}

components = {
    "electric_vehicle_1": ev_config.copy(),
    "electric_vehicle_2": ev_config.copy()
}

# --- 3. Solve ---
building = _make_building(forecaster, components, prosumer="Yes")
building, instance, _ = _solve_building_opt(building)

# --- 4. Extractions & Assertions ---
for ev_id in ["electric_vehicle_1", "electric_vehicle_2"]:
    ev_block = instance.dsm_blocks[ev_id]
    
    charge = [_val(ev_block.charge[t]) for t in range(8)]
    discharge = [_val(ev_block.discharge[t]) for t in range(8)]
    usage = [_val(ev_block.usage[t]) for t in range(8)]
    soc = [_val(ev_block.soc[t]) for t in range(8)]

# 1. Arbitrage at Peak: Should discharge at t=4 (Price 100) 
assert discharge[4] > 0, "EV should discharge at t=4 to take advantage of the 100 price"

# 2. Maximum Charging at Bottom: Should charge heavily at t=5 or t=6 (Price -100)
assert sum(charge[5:7]) > sum(charge[0:2]), \
    "EV should prioritize charging at t=5,6 over the milder negative prices at t=0,2"

# 3. Final Peak: Should discharge again at t=7 (Price 100)
assert discharge[7] > 0, "EV should discharge at final price peak (t=7)"

# 4. Trip Energy: Meet trip energy needs at t=2
assert usage[2] == 40 * 0.5, "EV should meet trip distance at t=2"

@Manish-Khanra
Copy link
Copy Markdown
Contributor Author

Manish-Khanra commented Apr 30, 2026

Based on the provided tests and an additional test, the optimization seems to work as intended. Nice work!

We think two more things would be necessary for the code to be fully functional.

1. Right now I can have an EV with availability [1, 1] and trip_distances [80, 80]. This is physically not possible and should raise a validation error.

Either: Soft validation constraint trip_distance > 0 -> avalability must be smaller than one. or: Hard validation constraint: trip_distance > 0 -> availability must be 0.

2. We again strongly encourage that instead of trip_distance + mileage as input, the model should directly take used_energy as input. The reasoning is that if a dataset provides mileage and trip distance, it is very simple to calculate used energy. However, if a dataset only provides used energy (such as synpro from Fraunhofer ISE), one does have to fingate trip distance, which is very cumbersome.

In a future extension it might be nice to have soc pivots where the EV is requested to have a certain SoC at certain points of time - but this has not to be handled by this PR.

@carl-wanninger Included both the requests.

Co-authored-by: Copilot <copilot@github.com>
Copy link
Copy Markdown
Collaborator

@carl-wanninger carl-wanninger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We approve this PR.
Thank you for your good work, Manish!

@Manish-Khanra Manish-Khanra merged commit b7a0212 into main May 6, 2026
9 checks passed
@Manish-Khanra Manish-Khanra deleted the building_multiasset_extension branch May 6, 2026 13:47
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.

3 participants