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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +125 -8
- data/Rakefile +16 -3
- data/lib/horologium/configuration.rb +100 -0
- data/lib/horologium/duration.rb +96 -0
- data/lib/horologium/error.rb +37 -0
- data/lib/horologium/instant.rb +157 -0
- data/lib/horologium/numeric/exact.rb +130 -0
- data/lib/horologium/numeric/precision.rb +89 -0
- data/lib/horologium/numeric/two_part_float.rb +314 -0
- data/lib/horologium/precise_value.rb +85 -0
- data/lib/horologium/version.rb +1 -1
- data/lib/horologium.rb +11 -2
- data/sig/horologium/configuration.rbs +9 -0
- data/sig/horologium/duration.rbs +19 -0
- data/sig/horologium/instant.rbs +22 -0
- data/sig/horologium/numeric/exact.rbs +25 -0
- data/sig/horologium/numeric/precision.rbs +17 -0
- data/sig/horologium/numeric/two_part_float.rbs +53 -0
- data/sig/horologium/numeric.rbs +5 -0
- data/sig/horologium/precise_value.rbs +25 -0
- data/sig/horologium.rbs +24 -0
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ea577f62fb959e825d069499184cafa4b1b15d4702700a1588d3ac4b00b7c2b
|
|
4
|
+
data.tar.gz: 71b854eb59c20acaa9cd1adf77f17e97b2c0c03c9f62168dfba952f50f273dda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/`.
|
|
29
|
-
|
|
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
|