Astronomical computing – Ep 3 - Angles in Ruby
Rémy Hannequin
Posted on April 21, 2022
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
Math::PI
comes from the standard library and returns a Float
.
(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.
0.1 + 0.2
# => 0.30000000000000004
1.1 * 0.1
# => 0.11000000000000001
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
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
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
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
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
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
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:
require "astronoby"
Astronoby::Angle
.as_degrees(180)
.to_radians
.value == BigMath.PI(10)
# => true
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
Posted on April 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.