Astronomical computing – Ep 3 - Angles in Ruby
Tags: ruby, astronomy, programming
Published: 2022-04-21 11:55:12

In the [previous episode](https://dev.to/rhannequin/astronomical-computing-ep-2-4c08) we discovered multiple new notions such as celestial coordinates and angles.

As a first step towards the Ruby library, we are going to start by manipulating angles and units.

I am going to pass on the gem creation and skeleton which is made using [Bundler](https://bundler.io/v2.3/guides/creating_gem.html) and its command `$ bundle gem astronoby`.

## Object type

The book tells us the following:

> To convert an angle from degrees to radians, multiply it by π/180.

In Ruby, it would be written as:

```rb
Math::PI / 180
```

[`Math::PI`](https://ruby-doc.org/core-3.1.0/Math.html#PI) comes from the standard library and returns a `Float`.

```rb
(Math::PI / 180).class
# => Float

Math::PI / 180
# => 0.017453292519943295
```

A `Float` is easy to manipulate. It also looks like the available precision is quite interesting with 18 digits after the zero.

But this is not enough to me. Angles are going to be one of the most important and used data for this whole library. They will be greatly multiplied, divided. By definition a `Float` won't be completely accurate when manipulating π.

There will probably be some moments we will be okay to lose a bit of precision, but I want it to be by choice, not by design.
Also, without even caring about π, `Float` suffers from imprecision and should not be used when caring about accuracy.

```rb
0.1 + 0.2
# => 0.30000000000000004

1.1 * 0.1
# => 0.11000000000000001
```

Therefore we need to use [`BigDecimal`](https://ruby-doc.org/stdlib-3.1.0/libdoc/bigdecimal/rdoc/BigDecimal.html) numeric values, especially [`BigMath.PI`](https://ruby-doc.org/stdlib-3.1.0/libdoc/bigdecimal/rdoc/BigMath.html#method-i-PI).

```rb
require "bigdecimal/math"

BigMath.PI(10).class
# => BigDecimal

BigMath.PI(10)
# 0.3141592653589793238462643388813853786957412e1

BigMath.PI(10) / 180
# 0.17453292519943295769236907715632521038652289e-1
```

## First classes

We are starting basic with 3 classes:
- `Angle` that will be the parent class of any angle, any unit
- `Degree` for manipulating angles initialized in degrees
- `Radian` for manipulating angles initialized in radians

### `Angle`

`Angle` is going to implement some basic stuff, but more importantly it is going to allow angles to be converted in different units. We want to have `#to_degrees` and `#to_radians` available on angles to convert themselves in a new instance of `Angle` with a different unit.

Let's write the associated spec:

```rb
RSpec.describe Astronoby::Angle do
  describe "::as_degrees" do
    subject { described_class.as_degrees(180) }

    it "returns an Angle object" do
      expect(subject).to be_a(described_class)
    end

    it "returns an Angle in Degree" do
      expect(subject).to be_a(Astronoby::Degree)
    end
  end

  describe "::as_radians" do
    subject { described_class.as_radians(BigMath.PI(10)) }

    it "returns an Angle object" do
      expect(subject).to be_a(described_class)
    end

    it "returns an Angle in Radian" do
      expect(subject).to be_a(Astronoby::Radian)
    end
  end
end
```

`Angle` doesn't manage the unit conversion's calculation, which will be handled by the children classes.

We are going to store the actual angle's value into a `@value` attribute and a `unit` parameter will be used to specify the value's unit.

A first implementation of the parent `Angle` class could look like this:

```rb
module Astronoby
  class Angle
    UNITS = [
      DEGREES = :degrees,
      RADIANS = :radians
    ].freeze

    def self.as_degrees(angle)
      Astronoby::Degree.new(angle)
    end

    def self.as_radians(angle)
      Astronoby::Radian.new(angle)
    end

    def initialize(angle, unit:)
      @angle = BigDecimal(angle)
      @unit = unit
    end

    def value
      @angle
    end

    def to_degrees
      raise NotImplementedError, "#{self.class} must implement #to_degrees method."
    end

    def to_radians
      raise NotImplementedError, "#{self.class} must implement #to_radians method."
    end
  end
end
```

Note that we are automatically converting the angle's value into `BigDecimal`, in order to keep precision when manipulating or converting the angle.

### `Degree`

The `Degree` class aims to handle specific logic around angles in degrees.

Right now, the only logic we need to implement is conversion from degrees to radians. This is where we are writing the rule "multiply by π/180".

First, let's start with the tests to define how this new class is supposed to behave. We want to ensure `#to_degrees` will keep the angle as it is, and `#to_radians` will return a new instance in the proper class and convert its value.

```rb
RSpec.describe Astronoby::Degree do
  let(:instance) { described_class.new(180) }

  describe "#value" do
    subject { instance.value }

    it "returns the angle's numeric value in the current unit" do
      expect(subject).to eq(180)
    end
  end

  describe "#to_degrees" do
    subject { instance.to_degrees }

    it "returns itself" do
      expect(subject).to eq(instance)
    end
  end

  describe "#to_radians" do
    subject { instance.to_radians }

    it "returns a new Radian instance" do
      expect(subject).to be_a(Astronoby::Radian)
    end

    it "converted the degrees value into radians" do
      expect(subject.value).to eq(BigMath.PI(10))
    end
  end
end
```

`Degree`'s implementation is therefore quite simple:

```rb
module Astronoby
  class Degree < Angle
    def initialize(angle)
      super(angle, unit: DEGREES)
    end

    def to_degrees
      self
    end

    def to_radians
      self.class.as_radians(@angle / 180 * PI)
    end
  end
end
```

Finally, the conversion we have been talking about for a while.

## `Radian`

On the same structure, `Radian` will contain the logic to convert an angle in radians into degrees.

```rb
RSpec.describe Astronoby::Radian do
  let(:instance) { described_class.new(BigMath.PI(10)) }

  describe "#value" do
    subject { instance.value }

    it "returns the angle's numeric value in the current unit" do
      expect(subject).to eq(BigMath.PI(10))
    end
  end

  describe "#to_degrees" do
    subject { instance.to_degrees }

    it "returns a new Degree instance" do
      expect(subject).to be_a(Astronoby::Degree)
    end

    it "converted the degrees value into degrees" do
      expect(subject.value).to eq(180)
    end
  end

  describe "#to_radians" do
    subject { instance.to_radians }

    it "returns itself" do
      expect(subject).to eq(instance)
    end
  end
end
```

```rb
module Astronoby
  class Radian < Angle
    def initialize(angle)
      super(angle, unit: RADIANS)
    end

    def to_degrees
      self.class.as_degrees(@angle * 180 / PI)
    end

    def to_radians
      self
    end
  end
end
```

## Conclusion

We have our first step, a working gem called Astronoby that manipulates angles and can convert degrees into radians.

Here is an example of use of the gem:

```rb
require "astronoby"

Astronoby::Angle
  .as_degrees(180)
  .to_radians
  .value == BigMath.PI(10)

# => true
```

The source code is now available on [Github](https://github.com/rhannequin/astronoby), with a gem downloadable from [RubyGems](https://rubygems.org/gems/astronoby) and a brand new [version](https://github.com/rhannequin/astronoby/releases/tag/v0.0.1) released.

As a next step, we will have a look at another primary topic, which is time and especially calendars and time zones.


_Happy hacking_

---

### Sources

- [Bundler gem](https://bundler.io)
- [Astronoby gem on Github](https://github.com/rhannequin/astronoby)
- [Astronoby gem on RubyGems](https://rubygems.org/gems/astronoby)