Astronomical computing – Ep 3 - Angles in Ruby

rhannequin

Rémy Hannequin

Posted on April 21, 2022

Astronomical computing – Ep 3 - Angles in Ruby

In the previous episode 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 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:

Math::PI / 180
Enter fullscreen mode Exit fullscreen mode

Math::PI comes from the standard library and returns a Float.

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

Math::PI / 180
# => 0.017453292519943295
Enter fullscreen mode Exit fullscreen mode

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.

0.1 + 0.2
# => 0.30000000000000004

1.1 * 0.1
# => 0.11000000000000001
Enter fullscreen mode Exit fullscreen mode

Therefore we need to use BigDecimal numeric values, especially BigMath.PI.

require "bigdecimal/math"

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

BigMath.PI(10)
# 0.3141592653589793238462643388813853786957412e1

BigMath.PI(10) / 180
# 0.17453292519943295769236907715632521038652289e-1
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

Degree's implementation is therefore quite simple:

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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:

require "astronoby"

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

# => true
Enter fullscreen mode Exit fullscreen mode

The source code is now available on Github, with a gem downloadable from RubyGems and a brand new version 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

💖 💪 🙅 🚩
rhannequin
Rémy Hannequin

Posted on April 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related