@@ -3889,6 +3889,130 @@ def test_load_deactivation_with_def_total_hours(self):
38893889 )
38903890
38913891
3892+ def test_deferrable_load_group_shared_power (self ):
3893+ """Test that shared power budget constraint limits combined power of grouped loads."""
3894+ self .optim_conf .update (
3895+ {
3896+ "treat_deferrable_load_as_semi_cont" : [True , True ],
3897+ "set_deferrable_load_single_constant" : [False , False ],
3898+ "nominal_power_of_deferrable_loads" : [2000.0 , 2000.0 ],
3899+ "operating_hours_of_each_deferrable_load" : [4 , 4 ],
3900+ "deferrable_load_groups" : [
3901+ {
3902+ "names" : ["deferrable0" , "deferrable1" ],
3903+ "max_power" : 2500 ,
3904+ "mutual_exclusion" : False ,
3905+ }
3906+ ],
3907+ }
3908+ )
3909+ self .opt = self .create_optimization ()
3910+ self .df_input_data_dayahead = self .prepare_forecast_data ()
3911+ opt_res = self .opt .perform_dayahead_forecast_optim (
3912+ self .df_input_data_dayahead , self .p_pv_forecast , self .p_load_forecast
3913+ )
3914+ self .assertIn (self .opt .optim_status , VALID_OPTIMAL_STATUSES )
3915+ # Verify combined power never exceeds group max_power (with small tolerance)
3916+ combined = opt_res ["P_deferrable0" ] + opt_res ["P_deferrable1" ]
3917+ self .assertTrue (
3918+ (combined <= 2500 + 1.0 ).all (),
3919+ f"Combined power exceeded group max_power: max={ combined .max ():.1f} " ,
3920+ )
3921+
3922+ def test_deferrable_load_group_mutual_exclusion (self ):
3923+ """Test that mutual exclusion prevents simultaneous operation of grouped loads."""
3924+ self .optim_conf .update (
3925+ {
3926+ "treat_deferrable_load_as_semi_cont" : [True , True ],
3927+ "set_deferrable_load_single_constant" : [False , False ],
3928+ "nominal_power_of_deferrable_loads" : [2000.0 , 1500.0 ],
3929+ "operating_hours_of_each_deferrable_load" : [4 , 4 ],
3930+ "deferrable_load_groups" : [
3931+ {
3932+ "names" : ["deferrable0" , "deferrable1" ],
3933+ "max_power" : 2500 ,
3934+ "mutual_exclusion" : True ,
3935+ }
3936+ ],
3937+ }
3938+ )
3939+ self .opt = self .create_optimization ()
3940+ self .df_input_data_dayahead = self .prepare_forecast_data ()
3941+ opt_res = self .opt .perform_dayahead_forecast_optim (
3942+ self .df_input_data_dayahead , self .p_pv_forecast , self .p_load_forecast
3943+ )
3944+ self .assertIn (self .opt .optim_status , VALID_OPTIMAL_STATUSES )
3945+ # Verify at most one load is active at any timestep
3946+ both_active = (opt_res ["P_deferrable0" ] > 1.0 ) & (opt_res ["P_deferrable1" ] > 1.0 )
3947+ self .assertFalse (
3948+ both_active .any (),
3949+ "Mutual exclusion violated: both loads active simultaneously" ,
3950+ )
3951+
3952+ def test_deferrable_load_group_no_groups (self ):
3953+ """Test that empty deferrable_load_groups works (backward compatibility)."""
3954+ self .optim_conf ["deferrable_load_groups" ] = []
3955+ self .opt = self .create_optimization ()
3956+ self .df_input_data_dayahead = self .prepare_forecast_data ()
3957+ opt_res = self .opt .perform_dayahead_forecast_optim (
3958+ self .df_input_data_dayahead , self .p_pv_forecast , self .p_load_forecast
3959+ )
3960+ self .assertIn (self .opt .optim_status , VALID_OPTIMAL_STATUSES )
3961+
3962+ async def _build_params_with_groups (self , groups , ** config_overrides ):
3963+ """Helper to build params with deferrable_load_groups set in config."""
3964+ config = await build_config (emhass_conf , logger , emhass_conf ["defaults_path" ])
3965+ config ["deferrable_load_groups" ] = groups
3966+ for key , value in config_overrides .items ():
3967+ config [key ] = value
3968+ _ , secrets = await build_secrets (emhass_conf , logger , no_response = True )
3969+ return await build_params (emhass_conf , secrets , config , logger )
3970+
3971+ async def test_deferrable_load_group_validation_invalid_name (self ):
3972+ """Test that invalid deferrable names in groups raise errors."""
3973+ with self .assertRaises (ValueError ):
3974+ await self ._build_params_with_groups ([
3975+ {
3976+ "names" : ["deferrable0" , "deferrable99" ],
3977+ "max_power" : 2500 ,
3978+ "mutual_exclusion" : False ,
3979+ }
3980+ ])
3981+
3982+ async def test_deferrable_load_group_validation_mutual_exclusion_not_semi_cont (self ):
3983+ """Test that mutual exclusion with non-semi-continuous loads raises error."""
3984+ with self .assertRaises (ValueError ):
3985+ await self ._build_params_with_groups (
3986+ [
3987+ {
3988+ "names" : ["deferrable0" , "deferrable1" ],
3989+ "max_power" : 2500 ,
3990+ "mutual_exclusion" : True ,
3991+ }
3992+ ],
3993+ treat_deferrable_load_as_semi_cont = [False , False ],
3994+ )
3995+
3996+ async def test_deferrable_load_group_validation_overlapping_groups (self ):
3997+ """Test that a load in multiple groups raises error."""
3998+ with self .assertRaises (ValueError ):
3999+ await self ._build_params_with_groups (
4000+ [
4001+ {
4002+ "names" : ["deferrable0" , "deferrable1" ],
4003+ "max_power" : 2500 ,
4004+ "mutual_exclusion" : False ,
4005+ },
4006+ {
4007+ "names" : ["deferrable1" , "deferrable2" ],
4008+ "max_power" : 2000 ,
4009+ "mutual_exclusion" : False ,
4010+ },
4011+ ],
4012+ number_of_deferrable_loads = 3 ,
4013+ )
4014+
4015+
38924016if __name__ == "__main__" :
38934017 unittest .main ()
38944018 ch .close ()
0 commit comments