Skip to content

Commit 952d5c1

Browse files
Thimpeyclaude
andcommitted
fix: keep shared-tank sources active when operating_hours == 0
Shared-tank members are temperature-driven. Master already exempts them from the running_lb pinning and the energy constraint, but the param_load_active activation loop still used `is_thermal = k in self.param_thermal` only. A shared-tank source is not in param_thermal, so with operating_hours == 0 (the natural setting for a temperature-driven load) it was deactivated: p_deferrable[k] forced to 0 for the whole horizon. With every member off the tank cannot hold its min_temperatures band, the problem goes infeasible, the relaxed fallback is infeasible too, and nothing is published. Same fix for the window-mask path: a member whose configured window falls entirely outside the horizon had its mask zeroed (same 0-W pin); reset it to all-ones and warn, since temperature constraints drive the load. Hoists shared_tank_membership into perform_optimization scope and adds it to the is_thermal exemption, matching the existing energy-constraint exemption. Two regression tests reproduce both infeasibility paths and fail on master without this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e1ffda commit 952d5c1

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

src/emhass/optimization.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3494,6 +3494,10 @@ def pad_list(input_list, target_len, fill=0):
34943494
# param_def_current_state[k].value is current when the pinning block reads it.
34953495
self._update_def_current_state_params(num_deferrable_loads)
34963496

3497+
# Shared-tank members are temperature-driven; used below to exempt them
3498+
# from the operating-timestep deactivation in the param_load_active loop.
3499+
shared_tank_membership = self._load_shared_tank_membership()
3500+
34973501
# Update Energy Constraint Parameters for Deferrable Loads
34983502
# These control the Big-M relaxation of energy/timestep constraints
34993503
for k in range(min(num_deferrable_loads, len(self.param_target_energy))):
@@ -3609,15 +3613,20 @@ def pad_list(input_list, target_len, fill=0):
36093613

36103614
# Update load active parameters: deactivate non-thermal loads with 0 operating timesteps,
36113615
# OR with a configured window that's entirely outside the optimization horizon.
3612-
# Thermal loads (thermal_config, thermal_battery) are always active since they're
3613-
# driven by temperature constraints, not operating timesteps. Sequence loads
3616+
# Thermal loads (thermal_config, thermal_battery, and shared-tank sources) are
3617+
# always active since they're driven by temperature constraints, not operating
3618+
# timesteps. Shared-tank members already skip the energy/operating constraints
3619+
# above (is_thermal_battery), so they must not be deactivated here either —
3620+
# otherwise a member with operating_hours == 0 (the natural setting for a
3621+
# temperature-driven source) is pinned to 0 W, the tank cannot hold its
3622+
# min_temperatures band, and the problem goes infeasible. Sequence loads
36143623
# (list-valued nominal power) are likewise always active: their runtime is the
36153624
# length of the sequence and operating_hours is meaningless for them, so a value
36163625
# of 0 must not deactivate the load (issue #887). The energy constraint already
36173626
# exempts sequence loads, so this keeps param_load_active consistent with it.
36183627
nominal_powers = self.optim_conf["nominal_power_of_deferrable_loads"]
36193628
for k in range(min(num_deferrable_loads, len(self.param_load_active))):
3620-
is_thermal = k in self.param_thermal
3629+
is_thermal = k in self.param_thermal or k in shared_tank_membership
36213630
is_sequence = k < len(nominal_powers) and isinstance(nominal_powers[k], list)
36223631
has_operating_requirement = (
36233632
def_total_timestep and k < len(def_total_timestep) and def_total_timestep[k] > 0
@@ -3627,6 +3636,20 @@ def pad_list(input_list, target_len, fill=0):
36273636
# Thermal loads are still driven by temperature constraints
36283637
# even if their configured window is outside the horizon.
36293638
self.param_load_active[k].value = 1.0
3639+
# Shared-tank members must also keep an open window mask: a
3640+
# configured window outside the horizon zeroes the mask, which
3641+
# would pin every member to 0 W and make the tank's
3642+
# min_temperatures unreachable (infeasible, then the relaxed
3643+
# fallback fails too and nothing is published).
3644+
if k in shared_tank_membership and window_outside_horizon:
3645+
if k < len(self.param_window_masks):
3646+
self.param_window_masks[k].value = np.ones(n)
3647+
self.logger.warning(
3648+
"Deferrable load %d is a shared-tank source with a configured "
3649+
"window outside the horizon; ignoring the window (temperature "
3650+
"constraints drive this load).",
3651+
k,
3652+
)
36303653
elif (has_operating_requirement or is_sequence) and not window_outside_horizon:
36313654
self.param_load_active[k].value = 1.0
36323655
else:

tests/test_optimization.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3403,6 +3403,86 @@ def test_shared_thermal_tank_two_sources(self):
34033403
total = res["P_deferrable0"].sum() + res["P_deferrable1"].sum()
34043404
self.assertGreater(total, 0, "Expected some dispatch to satisfy draw_off demand")
34053405

3406+
def _run_shared_tank_no_cap(
3407+
self, operating_hours, start_timesteps, end_timesteps, single_constant=(False, False)
3408+
):
3409+
"""One shared DHW tank fed by two temperature-driven sources (no caps).
3410+
3411+
A mid-horizon min_temperature of 55 C forces a heating dispatch so that
3412+
deactivating the members (pinning them to 0 W) would make the problem
3413+
infeasible. Used to exercise the param_load_active / window-mask handling
3414+
for shared-tank members independently of any source feature."""
3415+
self.df_input_data_dayahead = self.prepare_forecast_data()
3416+
self.df_input_data_dayahead["outdoor_temperature_forecast"] = [10.0] * 48
3417+
min_t = [45.0] * 48
3418+
for i in range(28, 34):
3419+
min_t[i] = 55.0
3420+
self.optim_conf["number_of_deferrable_loads"] = 2
3421+
self.optim_conf["nominal_power_of_deferrable_loads"] = [3500, 3000]
3422+
self.optim_conf["minimum_power_of_deferrable_loads"] = [0, 0]
3423+
self.optim_conf["operating_hours_of_each_deferrable_load"] = list(operating_hours)
3424+
self.optim_conf["treat_deferrable_load_as_semi_cont"] = [False, False]
3425+
self.optim_conf["set_deferrable_load_single_constant"] = list(single_constant)
3426+
self.optim_conf["set_deferrable_startup_penalty"] = [0.0, 0.0]
3427+
self.optim_conf["set_deferrable_max_startups"] = [0, 0]
3428+
self.optim_conf["start_timesteps_of_each_deferrable_load"] = list(start_timesteps)
3429+
self.optim_conf["end_timesteps_of_each_deferrable_load"] = list(end_timesteps)
3430+
self.optim_conf["def_load_config"] = [
3431+
{"thermal_source": {"supply_temperature": 55.0, "carnot_efficiency": 0.40}},
3432+
{"thermal_source": {"efficiency": 1.0}},
3433+
]
3434+
self.optim_conf["shared_thermal_tanks"] = [
3435+
{
3436+
"id": "dhw",
3437+
"load_ids": [0, 1],
3438+
"volume": 0.20,
3439+
"density": 1000,
3440+
"heat_capacity": 4.186,
3441+
"start_temperature": 48.0,
3442+
"thermal_loss": 0.10,
3443+
"min_temperatures": min_t,
3444+
"max_temperatures": [65.0] * 48,
3445+
}
3446+
]
3447+
opt = self.create_optimization()
3448+
ulc = self.df_input_data_dayahead[opt.var_load_cost].values
3449+
upp = self.df_input_data_dayahead[opt.var_prod_price].values
3450+
res = opt.perform_optimization(
3451+
self.df_input_data_dayahead,
3452+
self.p_pv_forecast.values.ravel(),
3453+
self.p_load_forecast.values.ravel(),
3454+
ulc,
3455+
upp,
3456+
)
3457+
return opt, res
3458+
3459+
def test_shared_tank_member_zero_operating_hours_stays_active(self):
3460+
"""A shared-tank source with operating_hours == 0 (the natural setting for a
3461+
temperature-driven load) must not be deactivated by the param_load_active
3462+
loop. Before the fix both members were pinned to 0 W, the tank could not
3463+
hold its min_temperatures band, and the problem went infeasible."""
3464+
opt, res = self._run_shared_tank_no_cap(
3465+
operating_hours=(0, 0), start_timesteps=(0, 0), end_timesteps=(0, 0)
3466+
)
3467+
self.assertEqual(opt.optim_status, "Optimal")
3468+
total = res["P_deferrable0"].sum() + res["P_deferrable1"].sum()
3469+
self.assertGreater(total, 0, "Shared-tank members were deactivated; tank cannot heat")
3470+
3471+
def test_shared_tank_window_outside_horizon_stays_optimal(self):
3472+
"""A shared-tank source whose configured window is entirely outside the
3473+
horizon must have its window mask reset to all-ones (temperature
3474+
constraints drive the load). Before the fix the mask was zeroed, pinning
3475+
every member to 0 W and making the problem infeasible."""
3476+
opt, res = self._run_shared_tank_no_cap(
3477+
operating_hours=(0, 0),
3478+
start_timesteps=(600, 600),
3479+
end_timesteps=(800, 800),
3480+
single_constant=(True, True),
3481+
)
3482+
self.assertEqual(opt.optim_status, "Optimal")
3483+
total = res["P_deferrable0"].sum() + res["P_deferrable1"].sum()
3484+
self.assertGreater(total, 0, "Window outside horizon gagged the shared-tank members")
3485+
34063486
def test_is_electric_load_excludes_load_from_grid_balance(self):
34073487
"""A load with is_electric_load[k]=False must not appear in p_def_sum
34083488
(and hence not in grid_pos / grid_neg balance constraints).

0 commit comments

Comments
 (0)