defmodule BACnet.Protocol.EventAlgorithms.UnsignedRange do @moduledoc """ Implements the BACnet event algorithm `UnsignedRange`. The UnsignedRange event algorithm detects whether the monitored value exceeds a range defined by a high limit and a low limit. For more specific information about the event algorithm, consult ASHRAE 135 13.3.9. """ alias BACnet.Protocol.Constants alias BACnet.Protocol.EventParameters.UnsignedRange, as: Params alias BACnet.Protocol.NotificationParameters.UnsignedRange, as: Notify alias BACnet.Protocol.StatusFlags import BACnet.Macro require Constants @const_normal Constants.macro_assert_name(:event_state, :normal) @const_high_limit Constants.macro_assert_name(:event_state, :high_limit) @const_low_limit Constants.macro_assert_name(:event_state, :low_limit) @typedoc """ Representative type for the event algorithm. """ opaquedstruct do field(:current_state, Constants.event_state(), required: true) field(:monitored_value, non_neg_integer(), required: true) field(:status_flags, StatusFlags.t(), required: true) field(:parameters, Params.t(), required: true) field(:dt_normal, DateTime.t() | nil, required: true) field(:dt_offnormal, DateTime.t() | nil, required: true) field(:last_value, non_neg_integer(), required: false) end @doc """ Creates a new algorithm state. """ @spec new(non_neg_integer(), Params.t()) :: t() def new(monitored_value, %Params{} = params) when is_integer(monitored_value) and monitored_value >= 0 do %__MODULE__{ current_state: Constants.macro_assert_name(:event_state, :normal), monitored_value: monitored_value, status_flags: StatusFlags.from_bitstring({false, false, false, false}), parameters: params, dt_normal: nil, dt_offnormal: nil, last_value: nil } end @doc """ Calculates the new state for the current state and parameters. Prior to this function invocation, the state should have been updated with `update/2`, if any of the properties has changed. `:delayed_event` helps identifying whether the algorithm needs to be called periodically in order to overcome the `time_delay` and trigger a state change. As soon as `:event` or `:no_event` is given as flag, it means the caller can go back to event orientated calling. The `status_flags` field of the notifications parameters is updated from the state with the correct `in_alarm` state, however to ensure the Status Flags have an overall correct status, the user has to make sure all bits are correctly. ASHRAE 135: > The conditions evaluated by this event algorithm are: > > (a) If pCurrentState is NORMAL, and pMonitoredValue is greater than > pHighLimit for pTimeDelay, then indicate a transition to the > HIGH_LIMIT event state. > (b) If pCurrentState is NORMAL, and pMonitoredValue is less than pLowLimit > for pTimeDelay, then indicate a transition to the LOW_LIMIT event state. > > (c) Optional: If pCurrentState is HIGH_LIMIT, and pMonitoredValue is less > than pLowLimit for pTimeDelay, then indicate a transition to the > LOW_LIMIT event state. > > (d) If pCurrentState is HIGH_LIMIT, and pMonitoredValue is equal to or less > than pHighLimit for pTimeDelayNormal, then indicate a transition to the > NORMAL event state. > (e) Optional: If pCurrentState is LOW_LIMIT, and pMonitoredValue is greater > than pHighLimit for pTimeDelay, then indicate a transition to the > HIGH_LIMIT event state. > > (f) If pCurrentState is LOW_LIMIT, and pMonitoredValue is equal to or greater > than pLowLimit, for pTimeDelayNormal, then indicate a transition to the > NORMAL event state. """ @spec execute(t()) :: {:event, new_state :: t(), Notify.t()} | {:delayed_event | :no_event, new_state :: t()} def execute(%__MODULE__{} = state) do current_normal = state.current_state == @const_normal current_high_limit = state.current_state == @const_high_limit current_low_limit = state.current_state == @const_low_limit normal_event = ((current_high_limit and state.monitored_value <= state.parameters.high_limit) or (current_low_limit and state.monitored_value >= state.parameters.low_limit)) and state.last_value != state.monitored_value high_event = (current_normal or current_low_limit) and state.monitored_value > state.parameters.high_limit and state.last_value != state.monitored_value low_event = (current_normal or current_high_limit) and state.monitored_value < state.parameters.low_limit and state.last_value != state.monitored_value offnormal_event = high_event or low_event # If we have an offnormal event, suppress the normal event normal_event = normal_event and not offnormal_event offnormal_dt = cond do offnormal_event and state.parameters.time_delay > 0 -> state.dt_offnormal || get_dt(state.parameters.time_delay) current_normal -> state.dt_offnormal true -> nil end normal_dt = cond do normal_event and (state.parameters.time_delay_normal || state.parameters.time_delay) > 0 -> state.dt_normal || get_dt(state.parameters.time_delay_normal || state.parameters.time_delay) not current_normal -> state.dt_normal true -> nil end current_dt = get_dt(0) offnormal_state = cond do high_event -> @const_high_limit low_event -> @const_low_limit true -> Constants.macro_assert_name(:event_state, :offnormal) end {event, new_event_state} = cond do offnormal_event and offnormal_dt == nil -> {true, offnormal_state} offnormal_event and DateTime.compare(current_dt, offnormal_dt) != :lt -> {true, offnormal_state} normal_event and normal_dt == nil -> {true, Constants.macro_assert_name(:event_state, :normal)} normal_event and DateTime.compare(current_dt, normal_dt) != :lt -> {true, Constants.macro_assert_name(:event_state, :normal)} true -> {false, state.current_state} end new_state = %__MODULE__{ state | current_state: new_event_state, dt_normal: unless(event, do: normal_dt), dt_offnormal: unless(event, do: offnormal_dt), last_value: if(event, do: state.monitored_value, else: state.last_value ) } compute_return(event, state, new_state) end @doc """ Updates the state using the given parameters (`monitored_value`, `parameters`, `status_flags`). """ @spec update(t(), Keyword.t()) :: t() def update(%__MODULE__{} = state, params) when is_list(params) do unless Keyword.keyword?(params) do raise ArgumentError, "Expected a keyword list as argument, got: #{inspect(params)}" end Enum.reduce(params, state, fn {:monitored_value, value}, acc -> unless is_integer(value) and value >= 0 do raise ArgumentError, "Expected a non negative integer for monitored_value, got: #{inspect(value)}" end %{acc | monitored_value: value} {:parameters, value}, acc -> unless is_struct(value, Params) do raise ArgumentError, "Expected EventParameters.UnsignedRange struct for params, got: #{inspect(value)}" end %{acc | parameters: value} {:status_flags, value}, acc -> unless is_struct(value, StatusFlags) do raise ArgumentError, "Expected StatusFlags struct for status_flags, got: #{inspect(value)}" end %{acc | status_flags: value} {key, _value}, _acc -> raise ArgumentError, "Unknown key #{key}" end) end @spec compute_return(boolean(), t(), t()) :: {:event, t(), Notify.t()} | {:delayed_event | :no_event, t()} defp compute_return(event, old_state, new_state) defp compute_return( true, %__MODULE__{current_state: state1} = _state, %__MODULE__{last_value: value, current_state: state2} = state ) when state2 == @const_high_limit or (state2 == @const_normal and state1 == @const_high_limit) do notify = %Notify{ exceeding_value: value, exceeded_limit: state.parameters.high_limit, status_flags: %{state.status_flags | in_alarm: state2 != @const_normal} } {:event, state, notify} end defp compute_return( true, %{current_state: state1} = _state, %{last_value: value, current_state: state2} = state ) when state2 == @const_low_limit or (state2 == @const_normal and state1 == @const_low_limit) do notify = %Notify{ exceeding_value: value, exceeded_limit: state.parameters.low_limit, status_flags: %{state.status_flags | in_alarm: state2 != @const_normal} } {:event, state, notify} end defp compute_return( false, %__MODULE__{} = _state, %__MODULE__{dt_normal: dtn, dt_offnormal: dto} = state ) when dtn != nil or dto != nil, do: {:delayed_event, state} defp compute_return(false, _state, state), do: {:no_event, state} @spec get_dt(non_neg_integer()) :: DateTime.t() defp get_dt(0), do: DateTime.now!(Application.get_env(:bacstack, :default_timezone, "Etc/UTC")) defp get_dt(offset), do: DateTime.add(get_dt(0), offset, :second) end