horologium 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7acd6f601c9ea6b6023d91c7b6891d82bede556ce4a744ab821b722615bb4f1c
4
- data.tar.gz: f968a52ed82141bdadaef004483cfe56f48511e05d4c2c6db1b27c6557b53166
3
+ metadata.gz: 8ea577f62fb959e825d069499184cafa4b1b15d4702700a1588d3ac4b00b7c2b
4
+ data.tar.gz: 71b854eb59c20acaa9cd1adf77f17e97b2c0c03c9f62168dfba952f50f273dda
5
5
  SHA512:
6
- metadata.gz: f6e0f093c7f13b748beed1a6c8a7f53dd18fdcddce28b3bd33caa8b06cd2ffd1a1042ca77569dd6766ca3572fc6e3cc108cb6defbbfd34c9bc389963c4cfda0f
7
- data.tar.gz: c3f4bd6142298f451311c76bbbb210023ba04002f6677488d8b22ee850d58f4f127d671bd8d9b425034930ba27d8c00fe4fab6865e32d6b3b46bd80cb5d6ed14
6
+ metadata.gz: 8a5144d687290a581ef5ec2861f58383d35e7c416032bfbf351bc6033ab9f5889c57a3cb48bd812e52d4d19ee0b0688e929fe1e05e0d44231ac6b41f7d8e9251
7
+ data.tar.gz: a5070ecf423cf7d49623d547a133e9fb0907935843397f8336e989bc4e7816cbcc662af2acfed63913c852bdf5620ef03fd7ba0e660c50c9463f3d7aa22d6624
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.2 - 2026-07-14
4
+
5
+ The first functional release. It ships the numeric core and the two value
6
+ objects the rest of the library is built on: `Instant` and `Duration`. The
7
+ scale conversions are not here yet, so an instant is built directly from a
8
+ TAI Julian Date for now.
9
+
10
+ ### Features
11
+
12
+ - Add `Numeric::TwoPartFloat`, a number kept as a high and a low `Float` for
13
+ about twice the precision of one, with Shewchuk error-free arithmetic
14
+ - Add `Numeric::Exact`, a value kept as an exact `Rational`, with no rounding
15
+ - Add the precision contract: every value carries a precision, `:standard` or
16
+ `:exact`, set when it is built and never changed. Mixing the two promotes
17
+ the result to `:exact` instead of dropping to `:standard`
18
+ - Add `Horologium.configure` for the set-once default precision, and
19
+ `Horologium.with_precision` for a scoped, per-fiber override
20
+ - Add `Instant`, a frozen point on the TAI timeline, built with
21
+ `Instant.from_tai_julian_date`
22
+ - Add `Duration`, a frozen span in SI seconds, built with `Duration.seconds`,
23
+ `Duration.days`, and `Duration.nanoseconds`
24
+ - Add instant and duration arithmetic: shift an instant by a duration, and
25
+ subtract two instants to measure the duration between them
26
+ - Add `Instant#equal_within?` for comparison inside a tolerance
27
+ - Guard against meaningless operations: adding two instants raises
28
+ `DimensionalError`
29
+
30
+ **Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.1...v0.0.2
31
+
3
32
  ## 0.0.1 - 2026-07-06
4
33
 
5
34
  - Gem creation
data/README.md CHANGED
@@ -7,26 +7,140 @@ Horologium is a Ruby library dedicated to **scientific time**: the time scales
7
7
  intervals, and rigorous conversions between scales that astronomy and physics
8
8
  require.
9
9
 
10
+ Ruby already has `Time`, `Date`, `DateTime`, and `ActiveSupport` for civil time:
11
+ time zones, calendars, human formatting. None of them knows the difference
12
+ between UTC and a continuous scale, the TAI, TT, and TDB scales an ephemeris
13
+ needs, or a Julian Date kept precise to the nanosecond. That is the gap
14
+ Horologium fills.
15
+
16
+ ## Content
17
+
18
+ - [Installation](#installation)
19
+ - [Usage](#usage)
20
+ - [Precision](#precision)
21
+ - [Status](#status)
22
+ - [Development](#development)
23
+ - [Contributing](#contributing)
24
+ - [License](#license)
25
+ - [Code of Conduct](#code-of-conduct)
26
+
10
27
  ## Installation
11
28
 
12
- Install the gem and add it to the application's Gemfile by running:
29
+ Install the gem and add it to the application's Gemfile by executing:
30
+
31
+ $ bundle add horologium
32
+
33
+ If [Bundler] is not being used to manage dependencies, install the gem by
34
+ executing:
35
+
36
+ $ gem install horologium
37
+
38
+ ## Usage
39
+
40
+ An `Instant` is a single point on the timeline, kept internally as a TAI Julian
41
+ Date. A `Duration` is an amount of time in SI seconds, with no date and no scale
42
+ attached. You shift an instant by a duration, and you subtract two instants to
43
+ get the duration between them.
44
+
45
+ ```rb
46
+ require "horologium"
47
+
48
+ instant = Horologium::Instant.from_tai_julian_date(2_460_000.5)
13
49
 
14
- ```sh
15
- bundle add horologium
50
+ later = instant + Horologium::Duration.days(1)
51
+ later == Horologium::Instant.from_tai_julian_date(2_460_001.5) # => true
52
+ instant < later # => true
53
+
54
+ b = Horologium::Instant.from_tai_julian_date(2_460_001.5)
55
+ b - instant == Horologium::Duration.days(1) # => true
56
+ ```
57
+
58
+ A `Duration` counts SI seconds, so `Duration.days(1)` is always 86,400 SI
59
+ seconds. Because of leap seconds a civil day can be a second longer or shorter,
60
+ so a duration and a calendar day are different things.
61
+
62
+ ```rb
63
+ Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400) # => true
64
+ Horologium::Duration.nanoseconds(1_000_000_000) ==
65
+ Horologium::Duration.seconds(1) # => true
16
66
  ```
17
67
 
18
- Or install it directly:
68
+ Adding a duration to an instant makes sense, but adding two instants together
69
+ does not, so it raises an error.
70
+
71
+ ```rb
72
+ instant + instant # => raises Horologium::DimensionalError
73
+ ```
74
+
75
+ Exact equality is rarely what scientific code wants, so you can compare within a
76
+ tolerance:
77
+
78
+ ```rb
79
+ a = Horologium::Instant.from_tai_julian_date(2_460_000.5)
80
+ near = a + Horologium::Duration.nanoseconds(1)
19
81
 
20
- ```sh
21
- gem install horologium
82
+ a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
22
83
  ```
23
84
 
85
+ ## Precision
86
+
87
+ A modern Julian Date is around 2.46 million. A single `Float` spends most of its
88
+ digits on that large number and has only tens of microseconds left for the
89
+ fraction of a day. That is too coarse for scientific time. Horologium stores an
90
+ instant across two `Float`s whose sum is the Julian Date, so the second one
91
+ starts where the first runs out of digits. This is the representation [ERFA]
92
+ uses, it keeps the precision below a nanosecond for any date, and it does so
93
+ with ordinary floating-point arithmetic.
94
+
95
+ Every value carries one of two precisions, fixed when it is built:
96
+
97
+ - `:standard`, the default, keeps the value as a two-part float. It is fast and
98
+ stays within a few nanoseconds of the true value.
99
+ - `:exact` keeps the value as a `Rational`, with no rounding. The test suite
100
+ uses it to check that `:standard` stays within its stated precision.
101
+
102
+ Set the default once at boot:
103
+
104
+ ```rb
105
+ Horologium.configure do |c|
106
+ c.default_precision = :exact
107
+ end
108
+ ```
109
+
110
+ Choose it for a single value, or for a scoped block:
111
+
112
+ ```rb
113
+ Horologium::Instant.from_tai_julian_date(2_460_000.5, precision: :exact)
114
+
115
+ Horologium.with_precision(:exact) do
116
+ # instants and durations built here default to :exact
117
+ end
118
+ ```
119
+
120
+ Exactness is contagious. An operation between two `:standard` values stays
121
+ `:standard`. Mixing a `:standard` and an `:exact` value gives an `:exact`
122
+ result, so precision is not quietly lost. `:exact` guarantees the arithmetic
123
+ Horologium performs. It cannot bring back precision that an input already lost
124
+ when it was built.
125
+
126
+ ## Status
127
+
128
+ This library is in early development, before its first public release. The
129
+ public API is not stable, so new versions will probably introduce breaking
130
+ changes until a 1.0 release. Changes are documented in the [CHANGELOG].
131
+
24
132
  ## Development
25
133
 
26
134
  After checking out the repo, run `bin/setup` to install dependencies. Then, run
27
135
  `rake` to run the tests and RuboCop, or `rake steep` to type-check the
28
- signatures in `sig/`. You can also run `bin/console` for an interactive prompt
29
- that will allow you to experiment.
136
+ signatures in `sig/`. Run `COVERAGE=true rake test` to measure test coverage,
137
+ which is enforced at a 95% line minimum in CI. You can also run `bin/console`
138
+ for an interactive prompt that will allow you to experiment.
139
+
140
+ Run `bin/ci` to run every check that GitHub Actions runs (RuboCop, Steep, YARD
141
+ documentation coverage, and the tests with coverage) in a single pass. It runs
142
+ each check even when an earlier one fails, so you see everything that needs
143
+ fixing at once.
30
144
 
31
145
  To install this gem onto your local machine, run `bundle exec rake install`. To
32
146
  release a new version, update the version number in `version.rb`, and then run
@@ -47,6 +161,9 @@ The gem is available as open source under the terms of the [MIT License].
47
161
  Everyone interacting in the Horologium project's codebases, issue trackers, chat
48
162
  rooms and mailing lists is expected to follow the [code of conduct].
49
163
 
164
+ [Bundler]: https://bundler.io
165
+ [ERFA]: https://github.com/liberfa/erfa
166
+ [CHANGELOG]: https://github.com/rhannequin/horologium/blob/main/CHANGELOG.md
50
167
  [rubygems.org]: https://rubygems.org
51
168
  [MIT License]: https://opensource.org/licenses/MIT
52
169
  [code of conduct]: https://github.com/rhannequin/horologium/blob/main/CODE_OF_CONDUCT.md
data/Rakefile CHANGED
@@ -2,16 +2,29 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
-
6
- Minitest::TestTask.create
7
-
8
5
  require "rubocop/rake_task"
6
+ require "yard"
7
+
8
+ Minitest::TestTask.create do |t|
9
+ t.framework = %(require "test_helper")
10
+ end
9
11
 
10
12
  RuboCop::RakeTask.new
13
+ YARD::Rake::YardocTask.new
11
14
 
12
15
  desc "Type check with Steep"
13
16
  task :steep do
14
17
  sh "steep check"
15
18
  end
16
19
 
20
+ desc "Verify YARD documentation coverage is 100%"
21
+ task :yard_coverage do
22
+ require "open3"
23
+ output, status = Open3.capture2e("yard", "stats", "--list-undoc")
24
+ puts output
25
+ unless status.success? && output.include?("100.00% documented")
26
+ abort "YARD documentation coverage is below 100%"
27
+ end
28
+ end
29
+
17
30
  task default: %i[test rubocop]
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Horologium
4
+ # Holds the library's settings. Today that is a single value, the default
5
+ # precision new instants and durations take when none is asked for. It is set
6
+ # once, inside {Horologium.configure}, and frozen afterwards, so behaviour
7
+ # does not depend on when in the process' life an object is read.
8
+ class Configuration
9
+ # @return [Symbol] the default precision, +:standard+ until configured
10
+ attr_reader :default_precision
11
+
12
+ def initialize
13
+ @default_precision = :standard
14
+ end
15
+
16
+ # Sets the default precision.
17
+ #
18
+ # @param precision [Symbol] +:standard+ or +:exact+
19
+ # @return [Symbol] the precision that was set
20
+ # @raise [ConfigurationError] once the configuration is frozen
21
+ # @raise [UnknownPrecisionError] when the precision is not recognised
22
+ def default_precision=(precision)
23
+ if frozen?
24
+ raise ConfigurationError, "the configuration is already frozen"
25
+ end
26
+
27
+ @default_precision = Numeric::Precision.validate!(precision)
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # Configures the library. The yielded configuration is frozen when the
33
+ # block returns, so it can be set once at boot and not changed again.
34
+ #
35
+ # @yieldparam config [Configuration] the configuration to set
36
+ # @return [Configuration] the frozen configuration
37
+ # @example
38
+ # Horologium.configure do |c|
39
+ # c.default_precision = :exact
40
+ # end
41
+ def configure
42
+ config = configuration
43
+ yield config if block_given?
44
+ config.freeze
45
+ end
46
+
47
+ # @return [Configuration] the current configuration, built with defaults if
48
+ # the library has not been configured yet
49
+ def configuration
50
+ @configuration ||= Configuration.new
51
+ end
52
+
53
+ # @return [Symbol] the configured default precision
54
+ def default_precision
55
+ configuration.default_precision
56
+ end
57
+
58
+ # The precision in effect right now: the one set by {with_precision} if a
59
+ # scope is open, otherwise the default. This is what a constructor consults
60
+ # when it is not given a precision of its own.
61
+ #
62
+ # @return [Symbol] +:standard+ or +:exact+
63
+ def current_precision
64
+ Thread.current[:horologium_current_precision] || default_precision
65
+ end
66
+
67
+ # Runs the block with a chosen precision in effect, then restores whatever
68
+ # was in effect before. The scope is per-fiber, so it is safe to use in a
69
+ # threaded or fibered context and cannot leak into other work. It does not
70
+ # touch the frozen default.
71
+ #
72
+ # @param precision [Symbol] +:standard+ or +:exact+
73
+ # @return [Object] the block's return value
74
+ # @raise [UnknownPrecisionError] when the precision is not recognised
75
+ # @example
76
+ # Horologium.with_precision(:exact) do
77
+ # # instants built here default to :exact
78
+ # end
79
+ def with_precision(precision)
80
+ Numeric::Precision.validate!(precision)
81
+ previous = Thread.current[:horologium_current_precision]
82
+ Thread.current[:horologium_current_precision] = precision
83
+ begin
84
+ yield
85
+ ensure
86
+ Thread.current[:horologium_current_precision] = previous
87
+ end
88
+ end
89
+
90
+ # Clears the configuration and any open precision scope. Meant for test
91
+ # isolation, so one test's configuration does not carry into another.
92
+ #
93
+ # @api private
94
+ # @return [void]
95
+ def reset_configuration!
96
+ @configuration = nil
97
+ Thread.current[:horologium_current_precision] = nil
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Horologium
4
+ # An amount of time in SI seconds, with no date and no scale attached.
5
+ # +Duration.days(1)+ is always 86,400 SI seconds. Because of leap seconds a
6
+ # civil day can be a second longer or shorter, so a Duration and a calendar
7
+ # day are different things.
8
+ #
9
+ # A Duration is frozen. Its precision is set when it is built, from the
10
+ # precision in effect unless you pass one. At +:standard+ it holds the
11
+ # seconds as a {Numeric::TwoPartFloat}, at +:exact+ as a {Numeric::Exact}.
12
+ #
13
+ # @example A day is a fixed number of SI seconds
14
+ # Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400)
15
+ # # => true
16
+ class Duration
17
+ include PreciseValue
18
+
19
+ # The number of SI seconds in a day.
20
+ SECONDS_PER_DAY = 86_400
21
+
22
+ # The number of nanoseconds in a second.
23
+ NANOSECONDS_PER_SECOND = 1_000_000_000
24
+
25
+ class << self
26
+ # A duration of +count+ SI seconds.
27
+ #
28
+ # @param count [Numeric] the number of seconds
29
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
30
+ # precision in effect when omitted
31
+ # @return [Horologium::Duration]
32
+ # @example
33
+ # Horologium::Duration.seconds(3600)
34
+ def seconds(count, precision: Horologium.current_precision)
35
+ from_seconds(count, precision)
36
+ end
37
+
38
+ # A duration of +count+ days, each of {SECONDS_PER_DAY} SI seconds. This
39
+ # counts time and is not tied to the calendar.
40
+ #
41
+ # @param count [Numeric] the number of days
42
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
43
+ # precision in effect when omitted
44
+ # @return [Horologium::Duration]
45
+ # @example
46
+ # Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400)
47
+ # # => true
48
+ def days(count, precision: Horologium.current_precision)
49
+ from_seconds(count * SECONDS_PER_DAY, precision)
50
+ end
51
+
52
+ # A duration of +count+ nanoseconds.
53
+ #
54
+ # @param count [Numeric] the number of nanoseconds
55
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
56
+ # precision in effect when omitted
57
+ # @return [Horologium::Duration]
58
+ # @example
59
+ # Horologium::Duration.nanoseconds(1)
60
+ def nanoseconds(count, precision: Horologium.current_precision)
61
+ from_seconds(Rational(count) / NANOSECONDS_PER_SECOND, precision)
62
+ end
63
+
64
+ private
65
+
66
+ # Builds a duration of +seconds+ SI seconds at the given precision. At
67
+ # +:exact+ the seconds stay a Rational; at +:standard+ they become a
68
+ # two-part float. Unit scaling happens on the plain input, before this.
69
+ #
70
+ # @param seconds [Numeric] the number of SI seconds
71
+ # @param precision [Symbol] the precision to build
72
+ # @return [Horologium::Duration]
73
+ def from_seconds(seconds, precision)
74
+ value =
75
+ case Numeric::Precision.validate!(precision)
76
+ when :exact
77
+ Numeric::Exact.new(seconds)
78
+ else
79
+ Numeric::TwoPartFloat.from_real(seconds)
80
+ end
81
+ new(value, precision)
82
+ end
83
+ end
84
+
85
+ # The same length, never negative.
86
+ #
87
+ # @return [Horologium::Duration]
88
+ # @example
89
+ # Horologium::Duration.seconds(-3).abs ==
90
+ # Horologium::Duration.seconds(3)
91
+ # # => true
92
+ def abs
93
+ rational.negative? ? self.class.new(value * -1, precision) : self
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Horologium
4
+ # Base class for all errors raised by Horologium. Every error the library
5
+ # raises descends from it, so a caller can rescue Horologium as a unit.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when the configuration is changed after it has been frozen. The
9
+ # global default is set once, inside {Horologium.configure}, and locked
10
+ # afterwards.
11
+ class ConfigurationError < Error; end
12
+
13
+ # Raised when an operation mixes quantities that do not combine, such as
14
+ # adding two instants. Only a point plus or minus a duration, and the
15
+ # difference of two points, are meaningful.
16
+ class DimensionalError < Error; end
17
+
18
+ # Raised when a precision the library does not recognise is given, to the
19
+ # configuration or when building a value. It carries the known precisions so
20
+ # the caller can see the valid choices.
21
+ class UnknownPrecisionError < Error
22
+ # The precisions the library recognises.
23
+ #
24
+ # @return [Array<Symbol>]
25
+ attr_reader :known_precisions
26
+
27
+ # @param precision [Object] the unknown precision that was given
28
+ # @param known_precisions [Array<Symbol>] the recognised precisions
29
+ def initialize(precision, known_precisions)
30
+ @known_precisions = known_precisions.dup.freeze
31
+ super(
32
+ "unknown precision #{precision.inspect}, " \
33
+ "expected one of #{known_precisions.map(&:inspect).join(", ")}"
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Horologium
4
+ # A single point on the timeline, independent of any scale. It is stored as
5
+ # a TAI Julian Date, in days, at a fixed precision.
6
+ #
7
+ # An Instant is frozen. Its precision is set when it is built, from the
8
+ # precision in effect unless you pass one. At +:standard+ the Julian Date is
9
+ # a {Numeric::TwoPartFloat}, at +:exact+ a {Numeric::Exact}.
10
+ #
11
+ # You can add or subtract a Duration, and subtract another Instant to get
12
+ # the Duration between them. Adding two instants raises {DimensionalError}.
13
+ # Mixing a +:standard+ and an +:exact+ operand gives an +:exact+ result.
14
+ #
15
+ # @example Shift an instant, then measure back to it
16
+ # instant = Horologium::Instant.from_tai_julian_date(2_460_000.5)
17
+ # later = instant + Horologium::Duration.seconds(3600)
18
+ # (later - instant) == Horologium::Duration.seconds(3600)
19
+ # # => true
20
+ class Instant
21
+ include PreciseValue
22
+
23
+ # Builds an instant from a TAI Julian Date, split into a high and a low
24
+ # part in days. At +:exact+ the two parts are kept as a Rational, with no
25
+ # loss. At +:standard+ they are normalized so the high part sits on the
26
+ # integer-day grid and the low part holds the fraction, in [-0.5, 0.5].
27
+ #
28
+ # @param high [Float] the high part of the Julian Date, in days
29
+ # @param low [Float] the low part, in days
30
+ # @param precision [Symbol] +:standard+ or +:exact+, taken from the
31
+ # precision in effect when omitted
32
+ # @return [Horologium::Instant]
33
+ # @example
34
+ # Horologium::Instant.from_tai_julian_date(2_443_144.5, 0.000_372_5)
35
+ def self.from_tai_julian_date(
36
+ high,
37
+ low = 0.0,
38
+ precision: Horologium.current_precision
39
+ )
40
+ value =
41
+ case Numeric::Precision.validate!(precision)
42
+ when :exact
43
+ Numeric::Exact.new(Numeric::TwoPartFloat.new(high, low))
44
+ else
45
+ Numeric::TwoPartFloat.normalize(high, low)
46
+ end
47
+ new(value, precision)
48
+ end
49
+
50
+ # Adds a duration and returns a later instant.
51
+ #
52
+ # @param duration [Horologium::Duration] the amount to move forward
53
+ # @return [Horologium::Instant]
54
+ # @raise [DimensionalError] when given anything but a Duration
55
+ # @example
56
+ # Horologium::Instant.from_tai_julian_date(2_460_000.5) +
57
+ # Horologium::Duration.days(1)
58
+ def +(duration) # rubocop:disable Naming/BinaryOperatorParameterName
59
+ unless duration.is_a?(Duration)
60
+ raise DimensionalError,
61
+ "cannot add a #{duration.class} to an Instant; " \
62
+ "only a Duration shifts an Instant"
63
+ end
64
+
65
+ precision = Numeric::Precision.resolve(self.precision, duration.precision)
66
+ days = seconds_to_days(duration, precision)
67
+ self.class.new(add(value, days), precision)
68
+ end
69
+
70
+ # Subtracts a duration to get an earlier instant, or another instant to
71
+ # get the Duration between them.
72
+ #
73
+ # @param other [Horologium::Duration, Horologium::Instant]
74
+ # @return [Horologium::Instant, Horologium::Duration]
75
+ # @raise [DimensionalError] when given anything else
76
+ # @example An earlier instant
77
+ # Horologium::Instant.from_tai_julian_date(2_460_000.5) -
78
+ # Horologium::Duration.days(1)
79
+ # @example The Duration between two instants
80
+ # a = Horologium::Instant.from_tai_julian_date(2_460_000.5)
81
+ # b = Horologium::Instant.from_tai_julian_date(2_460_001.5)
82
+ # b - a == Horologium::Duration.days(1) # => true
83
+ def -(other)
84
+ case other
85
+ when Duration
86
+ precision = Numeric::Precision.resolve(self.precision, other.precision)
87
+ days = seconds_to_days(other, precision)
88
+ self.class.new(subtract(value, days), precision)
89
+ when Instant
90
+ precision = Numeric::Precision.resolve(self.precision, other.precision)
91
+ gap = subtract(value, other.value)
92
+ Duration.new(gap * Duration::SECONDS_PER_DAY, precision)
93
+ else
94
+ raise DimensionalError,
95
+ "cannot subtract a #{other.class} from an Instant; " \
96
+ "subtract a Duration or another Instant"
97
+ end
98
+ end
99
+
100
+ # Whether two instants fall within a tolerance of each other. Use this
101
+ # rather than +==+ in scientific code.
102
+ #
103
+ # @param other [Horologium::Instant] the instant to compare with
104
+ # @param tolerance [Horologium::Duration] the largest gap counted as equal
105
+ # @return [Boolean]
106
+ # @example
107
+ # a = Horologium::Instant.from_tai_julian_date(2_460_000.5)
108
+ # b = a + Horologium::Duration.nanoseconds(1)
109
+ # a.equal_within?(b, Horologium::Duration.nanoseconds(2)) # => true
110
+ def equal_within?(other, tolerance)
111
+ unless other.is_a?(Instant)
112
+ raise DimensionalError,
113
+ "cannot compare an Instant with a #{other.class}"
114
+ end
115
+ unless tolerance.is_a?(Duration)
116
+ raise DimensionalError,
117
+ "a tolerance must be a Duration, got a #{tolerance.class}"
118
+ end
119
+
120
+ (self - other).abs <= tolerance
121
+ end
122
+
123
+ private
124
+
125
+ # The duration's seconds counted in days, at the given precision. A Julian
126
+ # Date counts days, so a duration is scaled before it is added.
127
+ #
128
+ # @param duration [Horologium::Duration]
129
+ # @param precision [Symbol]
130
+ # @return [Horologium::Numeric::TwoPartFloat, Horologium::Numeric::Exact]
131
+ def seconds_to_days(duration, precision)
132
+ Numeric::Precision.coerce(duration.value, to: precision) /
133
+ Duration::SECONDS_PER_DAY
134
+ end
135
+
136
+ # Adds two values. Two standard values add as two-part floats; if either
137
+ # is exact, both are promoted to exact Rationals first.
138
+ def add(left, right)
139
+ if left.is_a?(Numeric::TwoPartFloat) && right.is_a?(Numeric::TwoPartFloat)
140
+ left + right
141
+ else
142
+ Numeric::Precision.coerce(left, to: :exact) +
143
+ Numeric::Precision.coerce(right, to: :exact)
144
+ end
145
+ end
146
+
147
+ # Subtracts two values, promoting to exact the same way {add} does.
148
+ def subtract(left, right)
149
+ if left.is_a?(Numeric::TwoPartFloat) && right.is_a?(Numeric::TwoPartFloat)
150
+ left - right
151
+ else
152
+ Numeric::Precision.coerce(left, to: :exact) -
153
+ Numeric::Precision.coerce(right, to: :exact)
154
+ end
155
+ end
156
+ end
157
+ end