diff --git a/config/config.exs b/config/config.exs index c3d8f91..289e3e2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,5 +1,4 @@ import Config -config :elevator, num_floors: 4 if config_env() == :dev do config :pre_commit, commands: ["format --check-formatted"], verbose: true diff --git a/lib/elevator.ex b/lib/elevator.ex index d94d335..8a2294e 100644 --- a/lib/elevator.ex +++ b/lib/elevator.ex @@ -1,5 +1,6 @@ defmodule Elevator do - @num_floors Application.compile_env(:elevator, :num_floors, 4) + @num_floors 4 + @door_open_duration_ms 1000 # ms @resend_period 50 @msg_ts_cutoff 10000 @@ -9,6 +10,10 @@ defmodule Elevator do @num_floors end + def door_open_duration_ms do + @door_open_duration_ms + end + def resend_period do @resend_period end diff --git a/lib/elevator/application.ex b/lib/elevator/application.ex index 20bf75b..ebc8890 100644 --- a/lib/elevator/application.ex +++ b/lib/elevator/application.ex @@ -10,9 +10,9 @@ defmodule Elevator.Application do Elevator.Communicator, {Elevator.HallOrders, Elevator.num_floors()}, Elevator.CabOrders, - {Elevator.Hardware.Driver, [{127, 0, 0, 1}, driver_port]}, Elevator.FSM.State, Elevator.FSM.Action, + {Elevator.Hardware.Driver, [{127, 0, 0, 1}, driver_port]}, Elevator.Hardware.InputPoller ] diff --git a/lib/elevator/decision.ex b/lib/elevator/decision.ex index 8c3bf71..9ce5508 100644 --- a/lib/elevator/decision.ex +++ b/lib/elevator/decision.ex @@ -5,13 +5,11 @@ defmodule Elevator.Decision do These functions are intentionally pure to make them easy to unit test. """ - # Private helpers - - defp requests_above?(reqs, floor) do + def requests_above?(reqs, floor) do Enum.any?(reqs, fn {f, _} -> f > floor end) end - defp requests_below?(reqs, floor) do + def requests_below?(reqs, floor) do Enum.any?(reqs, fn {f, _} -> f < floor end) end @@ -31,7 +29,6 @@ defmodule Elevator.Decision do btn_type == :cab -> true direction == :up and btn_type == :hall_up -> true direction == :down and btn_type == :hall_down -> true - direction == :stop -> true true -> false end end @@ -49,9 +46,7 @@ defmodule Elevator.Decision do @doc "Single decision function for elevator behavior. Returns both direction and behavior for the current state and order snapshot." @spec next_action(Elevator.Types.combined_order_map(), Elevator.FSM.State.t()) :: - {:down, :moving | :door_open} - | {:up, :moving | :door_open} - | {:stop, :idle | :door_open} + {Elevator.Types.elev_dir(), :moving | :door_open | :idle} def next_action( orders, %Elevator.FSM.State{ @@ -61,16 +56,14 @@ defmodule Elevator.Decision do } ) do btns_at_floor = Map.get(orders, floor, MapSet.new()) + direction = if direction in [:up, :down], do: direction, else: :down cond do - between_floors and direction == :stop -> - {:down, :moving} - between_floors -> {direction, :moving} map_size(orders) == 0 -> - {:stop, :idle} + {direction, :idle} direction == :up -> cond do @@ -87,7 +80,7 @@ defmodule Elevator.Decision do {:down, :moving} true -> - {:stop, :idle} + {:up, :idle} end direction == :down -> @@ -105,21 +98,11 @@ defmodule Elevator.Decision do {:up, :moving} true -> - {:stop, :idle} - end - - direction == :stop -> - cond do - MapSet.member?(btns_at_floor, :hall_up) -> {:up, :door_open} - MapSet.member?(btns_at_floor, :hall_down) -> {:down, :door_open} - MapSet.member?(btns_at_floor, :cab) -> {:stop, :door_open} - requests_above?(orders, floor) -> {:up, :moving} - requests_below?(orders, floor) -> {:down, :moving} - true -> {:stop, :idle} + {:down, :idle} end true -> - {:stop, :idle} + {:down, :idle} end end end diff --git a/lib/elevator/fsm/action.ex b/lib/elevator/fsm/action.ex index c9dfe27..f55f45d 100644 --- a/lib/elevator/fsm/action.ex +++ b/lib/elevator/fsm/action.ex @@ -9,7 +9,6 @@ defmodule Elevator.FSM.Action do alias Elevator.HallOrders alias Elevator.Decision - @door_open_time 1000 @motor_timeout 4000 @action_interval 100 @@ -100,7 +99,7 @@ defmodule Elevator.FSM.Action do if state.behavior == :door_open and Time.after?( Time.utc_now(), - Time.add(state.door_open_time, @door_open_time, :millisecond) + Time.add(state.door_open_time, Elevator.door_open_duration_ms(), :millisecond) ) do if state.obstructed do State.open_door() diff --git a/lib/elevator/fsm/state.ex b/lib/elevator/fsm/state.ex index 1caeb71..95016a2 100644 --- a/lib/elevator/fsm/state.ex +++ b/lib/elevator/fsm/state.ex @@ -6,7 +6,7 @@ defmodule Elevator.FSM.State do alias Elevator.Hardware.Outputs alias Elevator.Types - defstruct direction: :stop, + defstruct direction: :down, behavior: :idle, floor: :unknown, between_floors: true, diff --git a/lib/elevator/hall_orders.ex b/lib/elevator/hall_orders.ex index 6aaa4bd..d76fb99 100644 --- a/lib/elevator/hall_orders.ex +++ b/lib/elevator/hall_orders.ex @@ -12,8 +12,6 @@ defmodule Elevator.HallOrders do @type floor :: Elevator.Types.floor() @type hall_btn :: Elevator.Types.hall_btn() - @tracked_key {0, :hall_up} - @hall_order_refresh_period 1000 def start_link(arg) do @@ -135,16 +133,6 @@ defmodule Elevator.HallOrders do end) |> Enum.into(%{}) - old_tracked = Map.get(order_map, @tracked_key) - new_tracked = Map.get(new_order_map, @tracked_key) - - if old_tracked != new_tracked do - Logger.info(fn -> - "Tracked hall order #{inspect(@tracked_key)} changed: " <> - "#{inspect(old_tracked)} -> #{inspect(new_tracked)}" - end) - end - {:noreply, new_order_map, {:continue, :hall_update_state}} end @@ -169,9 +157,11 @@ defmodule Elevator.HallOrders do new_order_value = order_map[key] - Logger.info(fn -> - "Hall button press #{inspect(key)}: #{inspect(old_order_value)} -> #{inspect(new_order_value)}" - end) + if old_order_value != new_order_value do + Logger.debug(fn -> + "hall_button_press floor=#{floor} button=#{direction} from=#{inspect(old_order_value)} to=#{inspect(new_order_value)}" + end) + end {:noreply, order_map, {:continue, :hall_update_state}} end diff --git a/lib/elevator/hall_orders/cost.ex b/lib/elevator/hall_orders/cost.ex index 5bed8e5..8cb3002 100644 --- a/lib/elevator/hall_orders/cost.ex +++ b/lib/elevator/hall_orders/cost.ex @@ -1,32 +1,58 @@ defmodule Elevator.HallOrders.Cost do + @moduledoc """ + Hall order cost utilities. + + Cost is estimated by simulating the local elevator with current requests plus the candidate hall request. + """ + alias Elevator.CabOrders + alias Elevator.Decision + alias Elevator.FSM.State require Logger - @doc """ - Maybe even random numbers? - """ + @travel_duration 2500 + @max_simulation_steps 256 + @unreachable_cost 30000 + + @type floor_t :: Elevator.Types.floor() + @type hall_btn_t :: Elevator.Types.hall_btn() + @type combined_orders_t :: Elevator.Types.combined_order_map() + @type cost_map_t :: %{node() => non_neg_integer()} + + @spec compute_cost({floor_t(), hall_btn_t()}, %{floor_t() => MapSet.t(hall_btn_t())}) :: + non_neg_integer() def compute_cost({floor, btn_dir}, my_hall_orders) do - state = Elevator.FSM.State.get_state() + try do + state = State.get_state() + cab_orders = CabOrders.get_my_orders() - cab_orders = CabOrders.get_my_orders() + hall_orders_with_target = + Map.update(my_hall_orders, floor, MapSet.new([btn_dir]), &MapSet.put(&1, btn_dir)) - payload_map = - state_and_orders_to_external_format({floor, btn_dir}, state, cab_orders, my_hall_orders) + combined_orders = Decision.combine_hall_and_cab(hall_orders_with_target, cab_orders) - try do - json_input = JSON.encode!(payload_map) - {output, 0} = System.cmd(Elevator.time_to_serve_executable(), ["-i", json_input]) - String.to_integer(String.trim(output)) + result = simulate_cost_until_served(combined_orders, state, {floor, btn_dir}) + + Logger.debug(fn -> + "hall_cost request=#{inspect({floor, btn_dir})} state=#{state.behavior}@#{inspect(state.floor)} dir=#{state.direction} result=#{result}" + end) + + result rescue - _ -> - 30000 + error -> + Logger.warning( + "Failed to compute hall cost for #{inspect({floor, btn_dir})}: #{inspect(error)}" + ) + + @unreachable_cost end end @doc """ - Merge two cost maps. + Merge two cost maps. Uses pessimistic merge: If two conflicting costs for the same node are found, keep the higher one. """ + @spec merge_cost(cost_map_t(), cost_map_t()) :: cost_map_t() def merge_cost(cost_map_1, cost_map_2) do MapSet.new(Map.keys(cost_map_1) ++ Map.keys(cost_map_2)) |> Enum.map(fn node -> @@ -49,6 +75,7 @@ defmodule Elevator.HallOrders.Cost do @doc """ Returns the node with the lowest cost for a given cost map and alive set. """ + @spec min_alive_cost(cost_map_t(), MapSet.t(node())) :: node() def min_alive_cost(cost_map, alive_set) do alive_costs = Enum.filter(cost_map, fn {node, _} -> MapSet.member?(alive_set, node) end) @@ -64,38 +91,148 @@ defmodule Elevator.HallOrders.Cost do min_node end - # Represent state and orders in the format expected by the time_to_serve program. - defp state_and_orders_to_external_format( - {order_floor, order_btn_dir}, - elev_state, - cab_orders, - hall_orders - ) do - behavior_remap = [idle: "idle", moving: "moving", door_open: "doorOpen"][elev_state.behavior] - order_dir_remap = [hall_up: :up, hall_down: :down][order_btn_dir] - - cab_orders_bool_table = - 0..(Elevator.num_floors() - 1) - |> Enum.map(fn floor -> MapSet.member?(cab_orders, floor) end) - - hall_orders_bool_table = - 0..(Elevator.num_floors() - 1) - |> Enum.map(fn floor -> - [ - MapSet.member?(Map.get(hall_orders, floor, MapSet.new()), :hall_up), - MapSet.member?(Map.get(hall_orders, floor, MapSet.new()), :hall_down) - ] - end) + @spec simulate_cost_until_served(combined_orders_t(), State.t(), {floor_t(), hall_btn_t()}) :: + non_neg_integer() + defp simulate_cost_until_served(_orders, %{floor: :unknown}, _target), do: @unreachable_cost + + defp simulate_cost_until_served(orders, state, target) do + normalized_state = + if state.direction in [:up, :down], do: state, else: %{state | direction: :down} - %{ - state: %{ - state: behavior_remap, - floor: elev_state.floor, - direction: elev_state.direction, - cabRequests: cab_orders_bool_table - }, - hallRequests: hall_orders_bool_table, - newOrder: %{floor: order_floor, direction: order_dir_remap} - } + if target_cleared?(orders, target) do + 0 + else + do_simulate(orders, normalized_state, target, 0, @max_simulation_steps) + end + end + + defp do_simulate(_orders, _state, _target, _time_ms, 0), do: @unreachable_cost + + defp do_simulate(orders, state, target, time_ms, steps_left) do + if target_cleared?(orders, target) do + time_ms + else + {direction, behavior} = Decision.next_action(orders, state) + + case behavior do + :idle -> + @unreachable_cost + + :moving -> + case move_one_floor(state.floor, direction) do + {:ok, next_floor} -> + next_state = %{ + state + | floor: next_floor, + between_floors: false, + direction: direction, + behavior: :moving + } + + do_simulate(orders, next_state, target, time_ms + @travel_duration, steps_left - 1) + + :error -> + @unreachable_cost + end + + :door_open -> + next_orders = clear_requests_at_floor_in_direction(orders, state.floor, direction) + + next_state = %{ + state + | direction: direction, + behavior: :idle, + between_floors: false + } + + do_simulate( + next_orders, + next_state, + target, + time_ms + Elevator.door_open_duration_ms(), + steps_left - 1 + ) + end + end + end + + defp target_cleared?(orders, {floor, btn_dir}) do + orders + |> Map.get(floor, MapSet.new()) + |> MapSet.member?(btn_dir) + |> Kernel.not() + end + + defp move_one_floor(floor, :up) when is_integer(floor) do + if floor < Elevator.num_floors() - 1, do: {:ok, floor + 1}, else: :error + end + + defp move_one_floor(floor, :down) when is_integer(floor) do + if floor > 0, do: {:ok, floor - 1}, else: :error + end + + defp move_one_floor(_, _), do: :error + + defp clear_requests_at_floor_in_direction(orders, floor, direction) do + orders + |> clear_button(floor, :cab) + |> clear_hall_for_direction(floor, direction) + |> prune_empty_floor(floor) + end + + defp clear_hall_for_direction(orders, floor, :up) do + cond do + button_present?(orders, floor, :hall_up) -> + clear_button(orders, floor, :hall_up) + + Decision.requests_above?(orders, floor) -> + orders + + true -> + clear_button(orders, floor, :hall_down) + end + end + + defp clear_hall_for_direction(orders, floor, :down) do + cond do + button_present?(orders, floor, :hall_down) -> + clear_button(orders, floor, :hall_down) + + Decision.requests_below?(orders, floor) -> + orders + + true -> + clear_button(orders, floor, :hall_up) + end + end + + defp button_present?(orders, floor, btn) do + orders + |> Map.get(floor, MapSet.new()) + |> MapSet.member?(btn) + end + + defp clear_button(orders, floor, btn) do + case Map.get(orders, floor) do + nil -> + orders + + buttons -> + Map.put(orders, floor, MapSet.delete(buttons, btn)) + end + end + + defp prune_empty_floor(orders, floor) do + case Map.get(orders, floor) do + nil -> + orders + + buttons -> + if MapSet.size(buttons) == 0 do + Map.delete(orders, floor) + else + orders + end + end end end diff --git a/lib/elevator/types.ex b/lib/elevator/types.ex index acb4786..d42bd6b 100644 --- a/lib/elevator/types.ex +++ b/lib/elevator/types.ex @@ -6,7 +6,7 @@ defmodule Elevator.Types do @type btn_dir :: :up | :down - @type elev_dir :: :up | :down | :stop + @type elev_dir :: :up | :down @type elev_behavior :: :moving | :idle | :door_open diff --git a/mix.exs b/mix.exs index fc72285..91e1781 100644 --- a/mix.exs +++ b/mix.exs @@ -28,8 +28,7 @@ defmodule Elevator.MixProject do # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} {:libcluster, "~> 3.3"}, {:dotenvy, "~> 1.0.0"}, - {:pre_commit, "~> 0.3.4", only: :dev}, - {:json, "~> 1.4"} + {:pre_commit, "~> 0.3.4", only: :dev} ] end diff --git a/mix.lock b/mix.lock index 7758ccf..be5b680 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,6 @@ %{ "dotenvy": {:hex, :dotenvy, "1.0.1", "2d5204e22e44a640c6f43f3a90035fbb65af281e1a2967be1dab63eee87aeabc", [:mix], [], "hexpm", "0727f6c08636b6d6f935c5a0ccedfe0c05bc75e638683bd9fa0a26d7a9931d15"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "json": {:hex, :json, "1.4.1", "8648f04a9439765ad449bc56a3ff7d8b11dd44ff08ffcdefc4329f7c93843dfa", [:mix], [], "hexpm", "9abf218dbe4ea4fcb875e087d5f904ef263d012ee5ed21d46e9dbca63f053d16"}, "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, "pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm", "16f684ba4f1fed1cba6b19e082b0f8d696e6f1c679285fedf442296617ba5f4e"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/test/single/cost_test.exs b/test/single/cost_test.exs new file mode 100644 index 0000000..33ae667 --- /dev/null +++ b/test/single/cost_test.exs @@ -0,0 +1,49 @@ +defmodule Test.Single.CostTest do + use ExUnit.Case, async: false + + alias Elevator.CabOrders + alias Elevator.FSM.State + alias Elevator.HallOrders.Cost + + @travel_duration 2500 + + setup do + start_supervised!({CabOrders, []}) + start_supervised!({Elevator.HallOrders, Elevator.num_floors()}) + start_supervised!(State) + + :ok + end + + test "same-floor request in current direction costs one door cycle" do + set_state(floor: 1, direction: :up, behavior: :idle) + + assert Cost.compute_cost({1, :hall_up}, %{}) == Elevator.door_open_duration_ms() + end + + test "same-floor opposite request is served immediately when no further requests" do + set_state(floor: 1, direction: :up, behavior: :idle) + + assert Cost.compute_cost({1, :hall_down}, %{}) == Elevator.door_open_duration_ms() + end + + test "one floor away request costs travel plus door time" do + set_state(floor: 0, direction: :up, behavior: :idle) + + assert Cost.compute_cost({1, :hall_up}, %{}) == + @travel_duration + Elevator.door_open_duration_ms() + end + + test "unknown floor yields unreachable cost" do + set_state(floor: :unknown, direction: :down, behavior: :idle) + + assert Cost.compute_cost({1, :hall_up}, %{}) == 30000 + end + + defp set_state(opts) do + State.set_direction(Keyword.fetch!(opts, :direction)) + State.set_behavior(Keyword.fetch!(opts, :behavior)) + State.set_floor(Keyword.fetch!(opts, :floor)) + Process.sleep(10) + end +end diff --git a/test/single/decision_test.exs b/test/single/decision_test.exs index 3fa9e0a..7151b34 100644 --- a/test/single/decision_test.exs +++ b/test/single/decision_test.exs @@ -3,27 +3,27 @@ defmodule Test.Single.DecisionTest do alias Elevator.Decision - test "unknown floor, :down -> stop,idle" do + test "unknown floor, :down -> down,idle" do orders = %{} state = %Elevator.FSM.State{floor: :unknown, direction: :down, between_floors: false} - assert Decision.next_action(orders, state) == {:stop, :idle} + assert Decision.next_action(orders, state) == {:down, :idle} end - test "no requests -> stop,idle" do + test "no requests -> keep direction,idle" do orders = %{} - state = %Elevator.FSM.State{floor: 1, direction: :stop, between_floors: false} - assert Decision.next_action(orders, state) == {:stop, :idle} + state = %Elevator.FSM.State{floor: 1, direction: :up, between_floors: false} + assert Decision.next_action(orders, state) == {:up, :idle} end test "nearest above -> up,moving" do orders = %{3 => MapSet.new([:cab]), 4 => MapSet.new([:hall_up])} - state = %Elevator.FSM.State{floor: 1, direction: :stop, between_floors: false} + state = %Elevator.FSM.State{floor: 1, direction: :up, between_floors: false} assert Decision.next_action(orders, state) == {:up, :moving} end test "nearest below -> down,moving" do orders = %{0 => MapSet.new([:cab]), 2 => MapSet.new([:cab])} - state = %Elevator.FSM.State{floor: 3, direction: :stop, between_floors: false} + state = %Elevator.FSM.State{floor: 3, direction: :down, between_floors: false} assert Decision.next_action(orders, state) == {:down, :moving} end @@ -33,21 +33,21 @@ defmodule Test.Single.DecisionTest do assert Decision.next_action(orders, state) == {:up, :moving} end - test "same floor cab -> stop,door_open" do + test "same floor cab while moving up -> up,door_open" do orders = %{2 => MapSet.new([:cab])} - state = %Elevator.FSM.State{floor: 2, direction: :stop, between_floors: false} - assert Decision.next_action(orders, state) == {:stop, :door_open} + state = %Elevator.FSM.State{floor: 2, direction: :up, between_floors: false} + assert Decision.next_action(orders, state) == {:up, :door_open} end - test "same floor hall_up -> up,door_open" do + test "same floor hall_up while idle up -> up,door_open" do orders = %{1 => MapSet.new([:hall_up])} - state = %Elevator.FSM.State{floor: 1, direction: :stop, between_floors: false} + state = %Elevator.FSM.State{floor: 1, direction: :up, between_floors: false} assert Decision.next_action(orders, state) == {:up, :door_open} end - test "same floor hall_down -> down,door_open" do + test "same floor hall_down while idle down -> down,door_open" do orders = %{1 => MapSet.new([:hall_down])} - state = %Elevator.FSM.State{floor: 1, direction: :stop, between_floors: false} + state = %Elevator.FSM.State{floor: 1, direction: :down, between_floors: false} assert Decision.next_action(orders, state) == {:down, :door_open} end @@ -81,13 +81,13 @@ defmodule Test.Single.DecisionTest do moving = %Elevator.FSM.State{floor: 2, direction: :up, behavior: :moving} assert Decision.should_clear_immediately?(moving, 2, :cab) - door_open_stop = %Elevator.FSM.State{ + door_open_down = %Elevator.FSM.State{ floor: 2, - direction: :stop, + direction: :down, behavior: :door_open, between_floors: false } - assert Decision.should_clear_immediately?(door_open_stop, 2, :hall_down) + assert Decision.should_clear_immediately?(door_open_down, 2, :hall_down) end end