From 341988a2111cb14ef5b8197e7a3e46de1b5bf8f9 Mon Sep 17 00:00:00 2001 From: ikaratass <89934937+ikaratass@users.noreply.github.com> Date: Wed, 19 Oct 2022 17:36:55 +0100 Subject: [PATCH] pmax value checked in powerdeliveryReq schedule p_max values are checked against if exceed the boundaryC enhanced the ev profile profile validation by skipping entries that were already scanned and tested --- iso15118/secc/states/iso15118_2_states.py | 109 +++++++- tests/secc/states/test_iso15118_2_states.py | 58 ++++ tests/secc/states/test_messages.py | 277 ++++++++++++++++++++ 3 files changed, 443 insertions(+), 1 deletion(-) diff --git a/iso15118/secc/states/iso15118_2_states.py b/iso15118/secc/states/iso15118_2_states.py index f72e9991..c1edfb9b 100644 --- a/iso15118/secc/states/iso15118_2_states.py +++ b/iso15118/secc/states/iso15118_2_states.py @@ -1472,7 +1472,21 @@ async def process_message( # ) # return - # TODO We should also do a more detailed check of the charging profile + # [V2G2-225] The SECC shall send the negative ResponseCode + # FAILED_ChargingProfileInvalid in + # the PowerDelivery response message if the EVCC sends a ChargingProfile which + # is not adhering to the PMax values of all PMaxScheduleEntry elements according + # to the chosen SAScheduleTuple element in the last ChargeParameterDiscoveryRes + # message sent by the SECC. + if power_delivery_req.charging_profile: + if not self._is_charging_profile_valid(power_delivery_req): + self.stop_state_machine( + "[V2G2-225] ChargingProfile is not adhering to the Pmax values in " + "ChargeParameterDiscoveryRes", + message, + ResponseCode.FAILED_CHARGING_PROFILE_INVALID, + ) + return logger.debug(f"ChargeProgress set to {power_delivery_req.charge_progress}") @@ -1611,6 +1625,99 @@ async def check_state(): CpState.D2, ] + def _is_charging_profile_valid(self, power_delivery_req: PowerDeliveryReq) -> bool: + for schedule in self.comm_session.offered_schedules: + if schedule.sa_schedule_tuple_id == power_delivery_req.sa_schedule_tuple_id: + schedule_entries = schedule.p_max_schedule.schedule_entries + # schedule_entries shall look like [PMaxScheduleEntry_0, + # PMaxScheduleEntry_1, ...]. The enumerate will pair an index with + # each list entry: (0, PMaxScheduleEntry_0), (1, + # PMaxScheduleEntry_1), ... + ev_profile = power_delivery_req.charging_profile + ev_profile_entries = ev_profile.profile_entries + cached_start_idx_ev: int = 0 + last_ev_running_idx: int = 0 + + for idx, sa_profile_entry in enumerate(schedule_entries): + sa_profile_entry_start = sa_profile_entry.time_interval.start + + sa_entry_pmax = ( + sa_profile_entry.p_max.value + * 10**sa_profile_entry.p_max.multiplier + ) + + try: + # By getting the next entry/slot, we can know when + # the current entry ends + next_sa_profile_entry = schedule_entries[idx + 1] + sa_profile_entry_end = next_sa_profile_entry.time_interval.start + + except IndexError: + # As the index is out of rage, it signals this is + # the final entry of the sa profile. The last entry + # must have a duration field, so we can calculate + # the end of the period summing the relative start + # of the entry with the duration of it + sa_profile_entry_end = ( + sa_profile_entry_start + + sa_profile_entry.time_interval.duration + ) + # The cached index and last ev running index are helpers that + # allow the for loop to start in the ev profile entry belonging + # to the start range of the SA entry. This avoids looping all + # over the ev profiles again for each SA entry schedule. + # For example, if the SA time schedule is + # [0; 1000[ [1000; 2000[ [2000; 3000[ + # And the EV profile time schedule is + # [0, 100[ [100, 400[ [400; 1000[ [1000, inf+[ + # The first three entries of the ev profile belong + # to the time range [0; 1000[ of the SA schedule and + # if the EV power, for those three entries + # does not surpass the limit imposed by the SA schedule + # during that time, then for the next loop iteration, + # we dont need to go test those EV time entries again, + # as they dont belong to the [1000; 2000[ SA time schedule slot. + cached_start_idx_ev += last_ev_running_idx + last_ev_running_idx = 0 + # fmt: off + for (ev_profile_idx, ev_profile_entry) in enumerate( + ev_profile_entries[cached_start_idx_ev:] + ): + _is_last_ev_profile = ( + ev_profile_entry.start == ev_profile_entries[-1].start + ) + + if (ev_profile_entry.start < sa_profile_entry_end or _is_last_ev_profile ): # noqa + ev_entry_pmax = (ev_profile_entry.max_power.value * 10 ** ev_profile_entry.max_power.multiplier) # noqa + if ev_entry_pmax > sa_entry_pmax: + logger.error( + f"EV Profile start {ev_profile_entry.start}s" + f"is out of power range: " + f"EV Max {ev_entry_pmax} W > EVSE Max " + f"{sa_entry_pmax} W \n" + ) + return False + + if not _is_last_ev_profile: + ev_profile_entry_end = ev_profile_entries[ + ev_profile_idx + 1 + ].start + if ev_profile_entry_end <= sa_profile_entry_end: + last_ev_running_idx = ev_profile_idx + 1 + else: + logger.debug( + f"EV last Profile start " + f"{ev_profile_entry.start}s is " + f"within time range [{sa_profile_entry_start}; " + f"{sa_profile_entry_end}[ and power range: " + f"EV Max {ev_entry_pmax} W <= EVSE Max " + f"{sa_entry_pmax} W \n" + ) + else: + break + # fmt: on + return True + class MeteringReceipt(StateSECC): """ diff --git a/tests/secc/states/test_iso15118_2_states.py b/tests/secc/states/test_iso15118_2_states.py index 5356186c..e762a341 100644 --- a/tests/secc/states/test_iso15118_2_states.py +++ b/tests/secc/states/test_iso15118_2_states.py @@ -31,13 +31,19 @@ get_charge_parameter_discovery_req_message_departure_time_one_hour, get_charge_parameter_discovery_req_message_no_departure_time, get_dummy_charging_status_req, + get_dummy_sa_schedule, get_dummy_v2g_message_authorization_req, get_dummy_v2g_message_payment_details_req, get_dummy_v2g_message_power_delivery_req_charge_start, get_dummy_v2g_message_power_delivery_req_charge_stop, get_dummy_v2g_message_service_discovery_req, get_dummy_v2g_message_welding_detection_req, + get_power_delivery_req_charging_profile_in_boundary_invalid, + get_power_delivery_req_charging_profile_in_limits, + get_power_delivery_req_charging_profile_not_in_limits_span_over_sa, + get_power_delivery_req_charging_profile_out_of_boundary, get_v2g_message_power_delivery_req, + get_v2g_message_power_delivery_req_charging_profile_in_boundary_valid, ) @@ -360,6 +366,58 @@ async def test_charge_parameter_discovery_res_v2g2_761(self): assert found_entry_indicating_start_without_delay is True + @pytest.mark.parametrize( + "power_delivery_message,expected_state, expected_response_code", + [ + ( + get_v2g_message_power_delivery_req_charging_profile_in_boundary_valid(), + CurrentDemand, + ResponseCode.OK, + ), + ( + get_power_delivery_req_charging_profile_in_boundary_invalid(), + Terminate, + ResponseCode.FAILED_CHARGING_PROFILE_INVALID, + ), + ( + get_power_delivery_req_charging_profile_in_limits(), + CurrentDemand, + ResponseCode.OK, + ), + ( + get_power_delivery_req_charging_profile_not_in_limits_span_over_sa(), + Terminate, + ResponseCode.FAILED_CHARGING_PROFILE_INVALID, + ), + ( + get_power_delivery_req_charging_profile_out_of_boundary(), + Terminate, + ResponseCode.FAILED_CHARGING_PROFILE_INVALID, + ), + ], + ) + async def test_charge_parameter_discovery_req_v2g2_225( + self, power_delivery_message, expected_state, expected_response_code + ): + # [V2G2-225] The SECC shall send the negative ResponseCode + # FAILED_ChargingProfileInvalid in + # the PowerDelivery response message if the EVCC sends a ChargingProfile which + # is not adhering to the PMax values of all PMaxScheduleEntry elements according + # to the chosen SAScheduleTuple element in the last ChargeParameterDiscoveryRes + # message sent by the SECC. + self.comm_session.writer = Mock() + self.comm_session.writer.get_extra_info = Mock() + + self.comm_session.offered_schedules = get_dummy_sa_schedule() + power_delivery = PowerDelivery(self.comm_session) + + await power_delivery.process_message(message=power_delivery_message) + assert power_delivery.next_state is expected_state + assert ( + power_delivery.message.body.power_delivery_res.response_code + is expected_response_code + ) + async def test_power_delivery_set_hlc_charging( self, ): diff --git a/tests/secc/states/test_messages.py b/tests/secc/states/test_messages.py index c52deddb..34a10ebf 100644 --- a/tests/secc/states/test_messages.py +++ b/tests/secc/states/test_messages.py @@ -21,11 +21,13 @@ from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVChargeParameter, ChargeProgress, + ChargingProfile, ChargingSession, DCEVErrorCode, DCEVStatus, PMaxSchedule, PMaxScheduleEntry, + ProfileEntryDetails, PVPMax, RelativeTimeInterval, SalesTariff, @@ -205,6 +207,281 @@ def get_dummy_v2g_message_power_delivery_req_charge_start(): ) +def get_v2g_message_power_delivery_req_invalid_charging_profile(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=12000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1800, + max_power=PVPMax(multiplier=0, value=7000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_v2g_message_power_delivery_req_charging_profile_in_boundary_valid(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1800, + max_power=PVPMax(multiplier=0, value=7000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_power_delivery_req_charging_profile_in_boundary_invalid(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=10000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1800, + max_power=PVPMax(multiplier=0, value=8000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_power_delivery_req_charging_profile_in_limits(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=10000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1200, + max_power=PVPMax(multiplier=0, value=8000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1800, + max_power=PVPMax(multiplier=0, value=6000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_power_delivery_req_charging_profile_not_in_limits(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=10000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2000, + max_power=PVPMax(multiplier=0, value=8000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_power_delivery_req_charging_profile_not_in_limits_span_over_sa(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1200, + max_power=PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1900, + max_power=PVPMax(multiplier=0, value=7000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + +def get_dummy_sa_schedule(): + sa_schedule_list: list[SAScheduleTuple] = [] + # PMaxSchedule + p_max_schedule_entry_1 = PMaxScheduleEntry( + p_max=PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT), + time_interval=RelativeTimeInterval(start=0, duration=1800), + ) + p_max_schedule_entry_2 = PMaxScheduleEntry( + p_max=PVPMax(multiplier=0, value=7000, unit=UnitSymbol.WATT), + time_interval=RelativeTimeInterval(start=1800, duration=1800), + ) + + p_max_schedule = PMaxSchedule( + schedule_entries=[p_max_schedule_entry_1, p_max_schedule_entry_2] + ) + # Putting the list of SAScheduleTuple entries together + sa_schedule_tuple = SAScheduleTuple( + sa_schedule_tuple_id=1, + p_max_schedule=p_max_schedule, + ) + sa_schedule_list.append(sa_schedule_tuple) + return sa_schedule_list + + +def get_power_delivery_req_charging_profile_out_of_boundary(): + charging_profile = ChargingProfile( + profile_entries=[ + ProfileEntryDetails( + start=0, + max_power=PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1000, + max_power=PVPMax(multiplier=0, value=10000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=1800, + max_power=PVPMax(multiplier=0, value=7000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2000, + max_power=PVPMax(multiplier=0, value=5000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2100, + max_power=PVPMax(multiplier=0, value=6300, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2300, + max_power=PVPMax(multiplier=0, value=2000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2400, + max_power=PVPMax(multiplier=0, value=4000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2450, + max_power=PVPMax(multiplier=0, value=700, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=2500, + max_power=PVPMax(multiplier=0, value=6999, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=3000, + max_power=PVPMax(multiplier=0, value=1400, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ProfileEntryDetails( + start=3100, + max_power=PVPMax(multiplier=0, value=8000, unit=UnitSymbol.WATT), + max_phases_in_use=3, + ), + ] + ) + + power_delivery_req = PowerDeliveryReq( + charge_progress=ChargeProgress.START, + sa_schedule_tuple_id=1, + charging_profile=charging_profile, + ) + + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(power_delivery_req=power_delivery_req), + ) + + def get_dummy_v2g_message_power_delivery_req_charge_stop(): power_delivery_req = PowerDeliveryReq( charge_progress=ChargeProgress.STOP,