Lucas Sifoni

A remote-controlled telescope with Elixir, Vue, & Rust
Part 1 : Simulating movement & state with Elixir

optics elixir


Project summary

This is the first entry of a series about a project I’m working on : a daylight photographic telescope, remote controlled with a smartphone, or with a little hardware remote. I’m already polishing its mirror on the side, a quite fast 114mm mirror with a focal length of 400mm. This mirror has the particularity to have a large hole cored in its center, so it naturally has a central obstruction of 45%.

The mirror, resting on its LP66 polishing tool.

This telescope will have a webcam sensor at its focal plane, so, no secondary mirror. Focusing will be done by translating the primary mirror with a mechanism akin to a clement focuser, but with a hinged design. Clement’s metallic flexures design is patented, and I have had luck building a hinged version already. More details on the build itself will go into other blog posts.

The brains will be a small RISC-V SOC called the MangoPI MQ Pro, which is supported by the Nerves Project, a platform for embedded Elixir, thanks to the open-source work of Frank Hunleth.

Controlling the telescope

I want to have two ways of interacting with the telescope :

Having those two control mechanisms allows me to abstract them behind a similar interface, giving a lot of benefits on the system design itself, as outlined in this previous post : 12/26/2022 Planning an hardware interface when you’re from the software side. It allows to iterate on a software prototype of the control tool quickly, and build the hardware controller to mimic the software one afterwards.

Both the hardware interface and the physical remote control will interact with the telescope’s program by exchanging messages on a websocket. We can leverage Phoenix for that.

Modeling the telescope state

We’ll start by defining a struct for our telescope : it has a name (to find it in a telescope registry ?), its position on the altitude axis, its position on the azimuth axis, and its focus position. That says a lot about its physical configuration : indeed, I want to use encoding strips on both the altitude and azimuth axis, to have positional feedback after a move. I also want a home sensor for the azimuth axis (even if we can freely rotate around the axis), and endstops for the alt position and focus.

defmodule Scope.Telescope do
  defstruct name: "",
            position_alt: 0,
            position_az: 0,
            position_focus: 0,
            home_az: false,
            lower_alt_stop: false,
            upper_alt_stop: false,
            lower_focus_stop: false,
            upper_focus_stop: false
end

We’ll make that a GenServer, and will start implement its API. To begin, we need to be able to home the telescope. Indeed, when it comes online, the azimuth, focus, and altitude, are all three in undefined stated. Maybe some endstops are activated, but we will still have a homed?/1 helper and a home(pid) call to move the scope to a known position.

  use GenServer
  alias Scope.Telescope

  def init(name) do
    {:ok,
     %Telescope{
       name: name,
       position_alt: -1,
       position_az: -1,
       home_az: false,
       lower_alt_stop: false,
       upper_alt_stop: false,
       lower_focus_stop: false,
       upper_focus_stop: false
     }
    }
  end

  def home(pid) do
    GenServer.call(pid, :home, 10000)
  end

  def homed?(%Telescope{position_alt: -1}), do: false
  def homed?(_), do: true

  def handle_call(:home, _, state) do
    new_state = %{
      state |
      lower_alt_stop: true,
      lower_focus_stop: true,
      home_az: true,
      position_alt: 0,
      position_az: 0
    }
    {:reply, :ok, new_state}
  end

Homing and moving

Our new virtual telescope can then be homed. Note that this does not take the physical time needed to do it into account. For that, we’ll need to implement a few more things below.

iex(15)> {:ok, scope} = GenServer.start_link(Scope.Telescope, "fooscope")
{:ok, #PID<0.754.0>}
iex(16)> scope |> Scope.Telescope.home
:ok
iex(17)> scope |> Scope.Telescope.show
:ok
%Scope.Telescope{
  name: "fooscope",
  position_alt: 0,
  position_az: 0,
  position_focus: 0,
  home_az: true,
  lower_alt_stop: true,
  upper_alt_stop: false,
  lower_focus_stop: true,
  upper_focus_stop: false
}

We’ll implement :start_move_up and :stop_move_up. Let’s establish a few facts :

That’s way too fast for the user to have precise positioning. We’ll reduce it 10x to take 15 seconds to cover the full altitude range. I’d like positioning to reach 0.1deg precision, which is enough for my non-stellar purpose. 0.1deg is 1/900th of the altitude range. If we cover the full range in 15 seconds, 1/900th of that is 16ms. That means our motor control loop will need to run at 16ms max per iteration.

I want to be able to (manually) move an axis at a time. I’ll add to my Telescope struct a :moving field, that can take the values :no | :up | :down | :right | :left.

We’ll start with the user-facing start_move_up(pid) and stop_move_up(pid).

  def start_move_up(pid) do
    GenServer.cast(pid, :start_move_up)
  end

  def stop_move_up(pid) do
    GenServer.cast(pid, :stop_move_up)
  end

  def handle_cast(:start_move_up, %Telescope{} = state) do
    if homed?(state) and not state.upper_alt_stop do
      GenServer.cast(self(), :move_up)
    end

    {:noreply,
     %Telescope{
       state
       | moving: :up
     }}
  end

In the handle_cast(:start_move_up.. callback, we check a few preconditions, then update the state to moving: :up and cast a message. Let’s implement the handle_cast(:move_up) callback. First, I’ll define a few constants as module attributes. They could become parameters later.

  @alt_time 15
  @alt_divisions 900
  @alt_increment :math.pi() / 2 / @alt_divisions
  @time_interval trunc(@alt_time / @alt_divisions * 1000)

Then, we calculate the next position, whether we will hit an endstop or not, and either stop the move, or continue moving up after 16ms. Of course, we will need to drive the motors when they physically exist.

  def handle_cast(:move_up, %Telescope{moving: :up} = state) do
    pos = state.position_alt + @alt_increment

    {new_pos, new_stop_status} =
      if pos >= :math.pi() / 2, do: {:math.pi() / 2, true}, else: {pos, false}

    new_state = %Telescope{
      state
      | position_alt: new_pos,
        upper_alt_stop: new_stop_status
    }

    if new_stop_status do
      IO.inspect("Hit upper alt endstop.")
      GenServer.cast(self(), :stop_move_up)
    else
      IO.inspect("Moving up to #{new_state.position_alt |> Float.floor(2)} rad")
      Process.send_after(self(), :continue_move, @time_interval)
    end

    {:noreply, new_state}
  end

  def handle_cast(:move_up, %Telescope{moving: :no} = state) do
    {:noreply, state}
  end

The handle_info(:continue_move) callback just casts a :move_up message again, if applicable. Note that I’m starting to think that all directional callbacks could be unified, so code moves in that direction.

  def handle_info(:continue_move, %Telescope{moving: :no} = state) do
    {:noreply, state}
  end

  def handle_info(:continue_move, %Telescope{moving: dir} = state) do
    msg =
      case dir do
        :up -> :move_up
        :down -> :move_down
        :right -> :move_right
        :left -> :move_left
        _ -> nil
      end

    if !is_nil(msg) do
      GenServer.cast(self(), msg)
    end

    {:noreply, state}
  end

Let’s implement handle_cast(:stop_move_up) to be able to reflect that we released the “up” button on our remote control :

  def handle_cast(:stop_move_up, %Telescope{} = state) do
    {:noreply,
     %Telescope{
       state
       | moving: :no
     }}
  end

We just break the loop by setting moving: :no. Again, this points to a generalization of the code to move. Let’s try our virtual telescope in IEX :

iex(4)> {:ok, scope} = GenServer.start_link(Telescope, "fooscope")
{:ok, #PID<0.510.0>}
iex(5)> scope |> Tele
Telemetry    Telescope
iex(5)> scope |> Telescope.home
:ok
iex(6)> scope |> Telescope.show
:ok
%Scope.Telescope{
  name: "fooscope",
  position_alt: 0,
  position_az: 0,
  position_focus: 0,
  moving: :no,
  home_az: true,
  lower_alt_stop: true,
  upper_alt_stop: false,
  lower_focus_stop: true,
  upper_focus_stop: false
}
iex(7)> scope |> Telescope.start_move_up
:ok
"Moving up to 0.0 rad"
"Moving up to 0.0 rad"
"Moving up to 0.0 rad"
"Moving up to 0.0 rad"
"Moving up to 0.0 rad"
"Moving up to 0.01 rad"
"Moving up to 0.01 rad"
...
iex(8)> scope |> Telescope.stop_move_up
:ok
iex(9)> scope |> Telescope.show
:ok
%Scope.Telescope{
  name: "fooscope",
  position_alt: 0.5253441048502938,
  position_az: 0,
  position_focus: 0,
  moving: :no,
  home_az: true,
  lower_alt_stop: true,
  upper_alt_stop: false,
  lower_focus_stop: true,
  upper_focus_stop: false
}

We can drive it up, and stop doing that. If I let it run for 15 seconds :

"Moving up to 1.56 rad"
"Moving up to 1.56 rad"
"Moving up to 1.56 rad"
"Moving up to 1.56 rad"
"Moving up to 1.56 rad"
"Moving up to 1.57 rad"
"Hit upper alt endstop."

It stops as it runs into the endstop. Nice !

The code until this point is available here (specific commit pinned.) github.com/lucassifoni/oiseaux

Alt/az moves and refactoring

The code is now separated in two modules : a public Scope.TelescopeApi and the associated Scope.Telescope implementing the GenServer behaviour.

defmodule Scope.TelescopeApi do
  def create(name), do: GenServer.start_link(Scope.Telescope, name)
  def home(pid), do: GenServer.call(pid, :home, 10000)
  def show(pid), do: GenServer.cast(pid, :show)
  def up(pid), do: GenServer.cast(pid, :start_move_up)
  def down(pid), do: GenServer.cast(pid, :start_move_down)
  def left(pid), do: GenServer.cast(pid, :start_move_left)
  def right(pid), do: GenServer.cast(pid, :start_move_right)
  def stop(pid), do: GenServer.cast(pid, :stop_move)
end

The API is quite concise for now. On the GenServer side, I compacted things a bit :

  def handle_call(:home, _, state), do: do_home(state)
  def handle_info(:continue_move, %Telescope{moving: :no} = state), do: {:noreply, state}
  def handle_info(:continue_move, %Telescope{moving: _dir} = state), do: continue_move(state)

  def handle_cast(:show, state), do: {:noreply, IO.inspect(state)}

  def handle_cast(:start_move_down, state), do: start_move(:down, state)
  def handle_cast(:start_move_left, state), do: start_move(:left, state)
  def handle_cast(:start_move_up, state), do: start_move(:up, state)
  def handle_cast(:start_move_right, state), do: start_move(:right, state)

  def handle_cast(:move_up, state), do: do_move(:up, state)
  def handle_cast(:move_down, state), do: do_move(:down, state)
  def handle_cast(:move_left, state), do: do_move(:right, state)
  def handle_cast(:move_right, state), do: do_move(:left, state)

  def handle_cast(:stop_move, %Telescope{} = state), do: stop_move(state)

Skipping do_home and stop_move, here are generalized start_move and do_move : We check preconditions with function clauses, and then proceed to cast the message triggering do_move.

  def valid_move_preconditions?(_, %{position_alt: -1}), do: false
  def valid_move_preconditions?(:down, %{lower_alt_stop: true}), do: false
  def valid_move_preconditions?(:up, %{upper_alt_stop: true}), do: false
  def valid_move_preconditions?(_, _), do: true

  def dir_to_msg(dir) do
    case dir do
      :up -> :move_up
      :down -> :move_down
      :right -> :move_right
      :left -> :move_left
      _ -> nil
    end
  end
  
  def start_move(dir, state) do
    msg = dir_to_msg(dir)

    if valid_move_preconditions?(dir, state) and !is_nil(msg) do
      GenServer.cast(self(), msg)
      {:noreply, %{state | moving: dir}}
    else
      {:noreply, state}
    end
  end

do_move is quite simple too, with its logic for endstops and value normalization being split in do_move_az and do_move_alt :

  def do_move(_dir, %Telescope{moving: :no} = state), do: {:noreply, state}
  def do_move(dir, %Telescope{} = state) do
    case dir do
      :left -> do_move_az(-1 * @az_increment, state)
      :right -> do_move_az(@az_increment, state)
      :up -> do_move_alt(@alt_increment, state)
      :down -> do_move_alt(-1 * @alt_increment, state)
      _ -> {:noreply, state}
    end
  end

  def do_move_az(inc, %Telescope{} = state) do
    pos = state.position_az + inc
    turns = trunc(pos / :math.pi())
    normalized = pos - turns * :math.pi()
    home_az = normalized == 0
    Process.send_after(self(), :continue_move, @az_time_interval)
    {:noreply, %{state | home_az: home_az, position_az: normalized}}
  end

  def do_move_alt(inc, %Telescope{} = state) do
    pos = state.position_alt + inc
    mmax = :math.pi() / 2
    lower_stop = pos <= 0
    upper_stop = pos >= mmax
    normalized = min(mmax, max(pos, 0))
    if lower_stop or upper_stop do
      GenServer.cast(self(), :stop_move)
    else
      Process.send_after(self(), :continue_move, @alt_time_interval)
    end
    {:noreply, %{state |
      lower_alt_stop: lower_stop,
      upper_alt_stop: upper_stop,
      position_alt: normalized
    }}
  end

For the moment, we can move our virtual telescope both in altitude and azimuth in iEx.

iex(4)> alias Scope.TelescopeApi, as: Api
Scope.TelescopeApi
iex(5)> {:ok, pid} = Api.create("kermit")
{:ok, #PID<0.516.0>}
iex(6)> pid |> Api.home()
:ok
iex(7)> pid |> Api.show()
:ok
%Scope.Telescope{
  name: "kermit",
  position_alt: 0,
  position_az: 0,
  position_focus: 0,
  moving: :no,
  home_az: true,
  lower_alt_stop: true,
  upper_alt_stop: false,
  lower_focus_stop: true,
  upper_focus_stop: false
}
iex(8)> pid |> Api.left
:ok
iex(9)> pid |> Api.up
:ok
iex(10)> pid |> Api.stop
:ok
iex(11)> pid |> Api.show
:ok
%Scope.Telescope{
  name: "kermit",
  position_alt: 0.7086036763096987,
  position_az: 0.3647738136668153,
  position_focus: 0,
  moving: :no,
  home_az: false,
  lower_alt_stop: false,
  upper_alt_stop: false,
  lower_focus_stop: true,
  upper_focus_stop: false
}

Code until now is at this specific commit on GitHub.

Next post will focus on some physical properties of the system, before moving to a basic remote control interface and scope state visualizer. This is important to refine both the digital and physical remotes UI/UX.


Previous post : Planning an hardware interface when you're from the software side
Next post : The Elixir Telescope -- Part 2 : Primary mirror design & calculation