marsdate 1.1.7 → 2.0.1

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.
data/bin/marsdate CHANGED
@@ -8,10 +8,10 @@ def usage
8
8
 
9
9
  marsdate # usage (this message)
10
10
  marsdate version # current version number
11
- marsdate today # today's Martian date
12
- marsdate now # today's Martian date/time
11
+ marsdate today # current Martian sol at 00:00
12
+ marsdate now # current instant
13
13
 
14
- marsdate calendar yyyy # Martian calendar for yyyy (HTML) **FIXME**
14
+ marsdate calendar yyyy # Martian calendar for yyyy (HTML)
15
15
  marsdate calendar # this year's Martian calendar (HTML)
16
16
 
17
17
  marsdate e2m yyyy-mm-dd hh:mm:ss # Convert Earth date to Mars date,
@@ -32,7 +32,7 @@ def command(cmd)
32
32
  when "now"
33
33
  puts MarsDateTime.now
34
34
  when "calendar"
35
- calendar # fix later
35
+ calendar
36
36
  else
37
37
  abort "Unknown command '#{cmd}'"
38
38
  end
@@ -48,9 +48,8 @@ end
48
48
  def invoke(cmd, *args)
49
49
  send(cmd, *args)
50
50
  rescue => err
51
- puts "Error in calling: #{cmd}(#{args.inspect[1..-2]})"
52
- puts err.backtrace
53
- exit
51
+ warn "Error in calling #{cmd}: #{err.message}"
52
+ exit 1
54
53
  end
55
54
 
56
55
  def e2m(ymd, hms)
@@ -63,15 +62,15 @@ end
63
62
 
64
63
  def m2e(ymd, hms)
65
64
  yyyy, mm, dd, hh, mi, ss = *get_data(ymd, hms)
66
- mdate = MarsDateTime.new(yyyy,mm,dd)
65
+ mdate = MarsDateTime.new(yyyy, mm, dd, hh, mi, ss) # MXT
67
66
  date = mdate.earth_date
68
67
  puts date.strftime("%x %X")
69
68
  end
70
69
 
71
70
  class CalendarHTML
72
71
  def month_header(n)
73
- @out.puts "<tr><td colspan=7"
74
- @out.puts "<b><center><font size=4 face=arial color=red>#{MarsDateTime::Months[n]}</font><center></b>"
72
+ @out.puts "<tr><td colspan=7>"
73
+ @out.puts "<b><center><font size=4 face=arial color=red>#{MarsDateTime::Months[n]}</font></center></b>"
75
74
  @out.puts "</td></tr>"
76
75
  @out.puts "<tr bgcolor=#FFA833>"
77
76
  @out.puts "<td width=3%><font size=3 face='arial'>Sun</font></td>"
@@ -81,7 +80,6 @@ class CalendarHTML
81
80
  @out.puts "<td width=3%><font size=3 face='arial'>Thu</font></td>"
82
81
  @out.puts "<td width=3%><font size=3 face='arial'>Fri</font></td>"
83
82
  @out.puts "<td width=3%><font size=3 face='arial'>Sat</font></td>"
84
- @out.puts "</font>"
85
83
  @out.puts "</tr>"
86
84
  end
87
85
 
@@ -90,7 +88,7 @@ class CalendarHTML
90
88
  ndays = 28
91
89
  ndays = MarsDateTime.sols_in_month(n, year) if n == 24
92
90
  mtoday = MarsDateTime.today
93
- @curmonth = mtoday.month == n
91
+ @curmonth = mtoday.year == year && mtoday.month == n
94
92
  list = [""]*dow + (1..ndays).to_a
95
93
  l2 = []
96
94
  loop do
@@ -161,8 +159,8 @@ class CalendarHTML
161
159
  end
162
160
 
163
161
 
164
- def calendar
165
- year ||= MarsDateTime.today.year
162
+ def calendar(year = nil, _extra = nil)
163
+ year = (year || MarsDateTime.today.year).to_i
166
164
  CalendarHTML.new(year)
167
165
  end
168
166
 
@@ -232,5 +230,9 @@ case
232
230
  command(cmd)
233
231
  when [2,3].include?(args)
234
232
  invoke(ARGV[0], ARGV[1], ARGV[2])
233
+ else
234
+ warn "Wrong number of arguments (given #{args}, expected 0..3)"
235
+ usage
236
+ exit 1
235
237
  end
236
238
 
@@ -0,0 +1,128 @@
1
+ # MCE civil calendar on the official Mars timeline.
2
+ #
3
+ # integer MSD = EPOCH_MSD + sol_index(year, month, sol)
4
+ #
5
+ # EPOCH_MSD is the integer Mars Sol Date of 1/1/1 00:00 MTC: the
6
+ # Airy-0 midnight of the sol that contains the year-1 northern
7
+ # vernal equinox (Allison Ls=0). Provisional: Allison is fitted
8
+ # ~1874–2127; year 1 is an extrapolation. See TimeScale.epoch_report.
9
+
10
+ class MarsDateTime
11
+ module Calendar
12
+ EPOCH_MSD = -665_773
13
+ SOLS_COMMON = 668
14
+ SOLS_LEAP = 669
15
+ MONTHS_OF_28 = 23
16
+ MEAN_YEAR = 668.591
17
+
18
+ module_function
19
+
20
+ # Same intercalation as MarsDateTime.leap? (MCE: /100 except /1000).
21
+ def leap?(year)
22
+ return (year % 2 == 1) if (year % 10 != 0)
23
+ return true if (year % 1000 == 0)
24
+ return false if (year % 100 == 0)
25
+ true
26
+ end
27
+
28
+ def sols_in_year(year)
29
+ leap?(year) ? SOLS_LEAP : SOLS_COMMON
30
+ end
31
+
32
+ def sols_in_month(month, year)
33
+ return 28 if month < 24
34
+ leap?(year) ? 25 : 24
35
+ end
36
+
37
+ # Leap years in 1..n inclusive. Closed form of the MCE rule.
38
+ def leaps_through(n)
39
+ return 0 if n <= 0
40
+ odds = (n + 1) / 2
41
+ tens = n / 10
42
+ centuries = n / 100
43
+ millennia = n / 1000
44
+ odds + tens - centuries + millennia
45
+ end
46
+
47
+ def leaps_before(year)
48
+ year <= 1 ? 0 : leaps_through(year - 1)
49
+ end
50
+
51
+ # 0-based sol index of 1/1 of +year+ (1/1/1 → 0).
52
+ def sols_before_year(year)
53
+ (year - 1) * SOLS_COMMON + leaps_before(year)
54
+ end
55
+
56
+ def check_ymd!(year, month, sol)
57
+ text = +""
58
+ text << "year #{year} is not a positive integer\n" unless year.is_a?(Integer) && year >= 1
59
+ text << "month #{month} is out of range\n" unless month.is_a?(Integer) && (1..24).include?(month)
60
+ text << "sol #{sol} is out of range\n" unless sol.is_a?(Integer) && (1..28).include?(sol)
61
+ if year.is_a?(Integer) && month == 24 && sol.is_a?(Integer)
62
+ max = sols_in_month(24, year)
63
+ text << "sol #{sol} is invalid in Taurus of year #{year}\n" if sol > max
64
+ end
65
+ raise ArgumentError, text unless text.empty?
66
+ end
67
+
68
+ # 0-based sols since 1/1/1.
69
+ def sol_index(year, month, sol)
70
+ check_ymd!(year, month, sol)
71
+ sols_before_year(year) + (month - 1) * 28 + (sol - 1)
72
+ end
73
+
74
+ # Integer MSD of Airy-0 midnight of that MCE date.
75
+ def msd_midnight(year, month, sol)
76
+ EPOCH_MSD + sol_index(year, month, sol)
77
+ end
78
+
79
+ # Instant MSD: date + fraction of a sol in [0, 1).
80
+ def msd_at(year, month, sol, frac = 0.0)
81
+ unless frac.is_a?(Numeric) && frac >= 0.0 && frac < 1.0
82
+ raise ArgumentError, "sol fraction must be in [0, 1) (got #{frac.inspect})"
83
+ end
84
+ msd_midnight(year, month, sol) + frac.to_f
85
+ end
86
+
87
+ def ymd(index)
88
+ unless index.is_a?(Integer) && index >= 0
89
+ raise ArgumentError,
90
+ "sol index must be a non-negative integer (got #{index.inspect})"
91
+ end
92
+ year = (index / MEAN_YEAR).floor + 1
93
+ year = 1 if year < 1
94
+ 16.times do
95
+ start = sols_before_year(year)
96
+ stop = start + sols_in_year(year)
97
+ if index >= start && index < stop
98
+ return ymd_in_year(year, index - start)
99
+ end
100
+ year += 1 if index >= stop
101
+ year -= 1 if index < start
102
+ year = 1 if year < 1
103
+ end
104
+ raise "year search failed for sol index #{index}"
105
+ end
106
+
107
+ def ymd_from_msd(msd)
108
+ ymd(msd.to_f.floor - EPOCH_MSD)
109
+ end
110
+
111
+ # Sunday = 0, same as MarsDateTime#dow on 1/1/1.
112
+ def dow(year, month, sol)
113
+ sol_index(year, month, sol) % 7
114
+ end
115
+
116
+ def ymd_in_year(year, offset)
117
+ if offset < MONTHS_OF_28 * 28
118
+ month = offset / 28 + 1
119
+ sol = offset % 28 + 1
120
+ else
121
+ month = 24
122
+ sol = offset - MONTHS_OF_28 * 28 + 1
123
+ end
124
+ [year, month, sol]
125
+ end
126
+ private_class_method :ymd_in_year
127
+ end
128
+ end
@@ -0,0 +1,314 @@
1
+ # Official Mars time scales (NASA/Allison / Mars24).
2
+ #
3
+ # Earth instant → JD (UT) → JD (TT) → MSD → MTC / MXT
4
+ #
5
+ # MTC = Coordinated Mars Time (24 stretched hours, Airy-0 mean midnight)
6
+ # MXT = Mars eXtended Time (SI hours, same midnight, day to ~24:39)
7
+
8
+ require 'date'
9
+
10
+ class MarsDateTime
11
+ module TimeScale
12
+ SOL_DAYS = 1.0274912517
13
+ SECONDS_PER_DAY = 86_400.0
14
+ SOL_SI_SECONDS = SOL_DAYS * SECONDS_PER_DAY # ~88775.244147
15
+ TT_MINUS_TAI = 32.184
16
+
17
+ # Mars24 C-2
18
+ MSD_JDTT_OFFSET = 2_451_549.5
19
+ MSD_EPOCH_SHIFT = 44_796.0
20
+ MSD_FINE = 0.0009626
21
+
22
+ J2000_JDTT = 2_451_545.0
23
+
24
+ # IERS Leap_Second.dat (Bulletin 72, July 2026). Each row is the
25
+ # UTC calendar date when TAI−UTC becomes that many seconds.
26
+ TAI_UTC_STEPS = [
27
+ [Date.new(1972, 1, 1), 10],
28
+ [Date.new(1972, 7, 1), 11],
29
+ [Date.new(1973, 1, 1), 12],
30
+ [Date.new(1974, 1, 1), 13],
31
+ [Date.new(1975, 1, 1), 14],
32
+ [Date.new(1976, 1, 1), 15],
33
+ [Date.new(1977, 1, 1), 16],
34
+ [Date.new(1978, 1, 1), 17],
35
+ [Date.new(1979, 1, 1), 18],
36
+ [Date.new(1980, 1, 1), 19],
37
+ [Date.new(1981, 7, 1), 20],
38
+ [Date.new(1982, 7, 1), 21],
39
+ [Date.new(1983, 7, 1), 22],
40
+ [Date.new(1985, 7, 1), 23],
41
+ [Date.new(1988, 1, 1), 24],
42
+ [Date.new(1990, 1, 1), 25],
43
+ [Date.new(1991, 1, 1), 26],
44
+ [Date.new(1992, 7, 1), 27],
45
+ [Date.new(1993, 7, 1), 28],
46
+ [Date.new(1994, 7, 1), 29],
47
+ [Date.new(1996, 1, 1), 30],
48
+ [Date.new(1997, 7, 1), 31],
49
+ [Date.new(1999, 1, 1), 32],
50
+ [Date.new(2006, 1, 1), 33],
51
+ [Date.new(2009, 1, 1), 34],
52
+ [Date.new(2012, 7, 1), 35],
53
+ [Date.new(2015, 7, 1), 36],
54
+ [Date.new(2017, 1, 1), 37]
55
+ ].freeze
56
+
57
+ module_function
58
+
59
+ # Astronomical Julian Date of the instant (offset-independent).
60
+ def jd_ut(earth)
61
+ earth.to_datetime.ajd.to_f
62
+ end
63
+
64
+ # TAI−UTC in seconds. Leap table from 1972-01-01; Mars24 polynomial
65
+ # for 1900-01-01...1972-01-01. Earlier dates raise (no UTC).
66
+ def tai_utc(earth)
67
+ dt = earth.to_datetime
68
+ date = dt.to_date
69
+ if date >= TAI_UTC_STEPS.first[0]
70
+ tai_utc_from_table(date)
71
+ elsif date >= Date.new(1900, 1, 1)
72
+ tt_utc_polynomial(jd_ut(dt)) - TT_MINUS_TAI
73
+ else
74
+ raise ArgumentError,
75
+ "TAI−UTC is undefined before 1900-01-01 (got #{date}); " \
76
+ "pass jd_tt: for antiquity"
77
+ end
78
+ end
79
+
80
+ def tt_utc(earth)
81
+ tai_utc(earth) + TT_MINUS_TAI
82
+ end
83
+
84
+ def jd_tt(earth, jd_tt: nil)
85
+ return jd_tt.to_f if jd_tt
86
+ jd_ut(earth) + tt_minus_ut(earth) / SECONDS_PER_DAY
87
+ end
88
+
89
+ # TT−UT in seconds. Leap table / polynomial from 1900; before
90
+ # that, 0 (treat the civil digits as TT). This is not a
91
+ # historical ΔT model. Year-1 Earth times are captions only.
92
+ def tt_minus_ut(earth)
93
+ dt = earth.to_datetime
94
+ return 0.0 if dt.to_date < Date.new(1900, 1, 1)
95
+ tt_utc(dt)
96
+ end
97
+
98
+ # Mars Sol Date (Mars24 C-2).
99
+ def msd(earth = nil, jd_tt: nil)
100
+ j = jd_tt(earth, jd_tt: jd_tt)
101
+ (j - MSD_JDTT_OFFSET) / SOL_DAYS + MSD_EPOCH_SHIFT - MSD_FINE
102
+ end
103
+
104
+ # Fractional sol in [0, 1).
105
+ def sol_fraction(earth = nil, jd_tt: nil)
106
+ frac = msd(earth, jd_tt: jd_tt) % 1.0
107
+ frac += 1.0 if frac < 0
108
+ frac
109
+ end
110
+
111
+ # MTC as hours in [0, 24).
112
+ def mtc_hours(earth = nil, jd_tt: nil)
113
+ sol_fraction(earth, jd_tt: jd_tt) * 24.0
114
+ end
115
+
116
+ def mtc_hms(earth = nil, jd_tt: nil)
117
+ hours_to_hms(mtc_hours(earth, jd_tt: jd_tt), limit: 24)
118
+ end
119
+
120
+ # SI seconds since Airy-0 mean midnight (0 ... SOL_SI_SECONDS).
121
+ def mxt_si_seconds(earth = nil, jd_tt: nil)
122
+ sol_fraction(earth, jd_tt: jd_tt) * SOL_SI_SECONDS
123
+ end
124
+
125
+ def mxt_hms(earth = nil, jd_tt: nil)
126
+ hours_to_hms(mxt_si_seconds(earth, jd_tt: jd_tt) / 3600.0)
127
+ end
128
+
129
+ def format_hms(h, m, s)
130
+ format('%02d:%02d:%06.3f', h, m, s)
131
+ end
132
+
133
+ # Areocentric solar longitude Ls (degrees), Mars24 B-1…B-5.
134
+ def ls_deg(jd_tt)
135
+ dt = jd_tt.to_f - J2000_JDTT
136
+ mean_anom = 19.3871 + 0.52402073 * dt
137
+ alpha_fms = 270.3871 + 0.524038496 * dt
138
+ pbs = mars_pbs(dt)
139
+ nu_m = (10.691 + 3.0e-7 * dt) * sind(mean_anom) +
140
+ 0.623 * sind(2 * mean_anom) +
141
+ 0.050 * sind(3 * mean_anom) +
142
+ 0.005 * sind(4 * mean_anom) +
143
+ 0.0005 * sind(5 * mean_anom) + pbs
144
+ wrap360(alpha_fms + nu_m)
145
+ end
146
+
147
+ # JD_TT of the northern vernal equinox (Ls = 0) nearest +around_jd_tt+.
148
+ def vernal_equinox_jd_tt(around_jd_tt)
149
+ year_days = 686.98 # Earth days per Mars tropical year, rough
150
+ lo = around_jd_tt - year_days * 0.6
151
+ hi = around_jd_tt + year_days * 0.6
152
+ # Bracket a rising-through-0 crossing of Ls.
153
+ step = 2.0
154
+ t = lo
155
+ prev = wrap180(ls_deg(t))
156
+ pair = nil
157
+ while t <= hi
158
+ t += step
159
+ cur = wrap180(ls_deg(t))
160
+ if prev < 0 && cur >= 0
161
+ pair = [t - step, t]
162
+ break
163
+ end
164
+ prev = cur
165
+ end
166
+ raise "no Ls=0 crossing near JD_TT #{around_jd_tt}" unless pair
167
+ a, b = pair
168
+ 60.times do
169
+ mid = (a + b) / 2.0
170
+ if wrap180(ls_deg(mid)) < 0
171
+ a = mid
172
+ else
173
+ b = mid
174
+ end
175
+ end
176
+ (a + b) / 2.0
177
+ end
178
+
179
+ # Civil DateTime corresponding to an astronomical JD, treated as UT.
180
+ def datetime_from_ajd(ajd)
181
+ DateTime.jd(ajd + 0.5)
182
+ end
183
+
184
+ def epoch_report
185
+ # Allison Ls is fitted for ~1874–2127; year 1 is an extrapolation.
186
+ # Seed the search at the article’s “late January of Year 1.”
187
+ # Calendar::EPOCH_MSD is the frozen floor of this search.
188
+ #
189
+ # Earth fields treat JD_TT as UT (ΔT = 0). A real 1 CE ΔT is a
190
+ # few hours and would only slide these captions, not EPOCH_MSD.
191
+ hint = DateTime.new(1, 1, 22, 12, 0, 0)
192
+ around = hint.ajd.to_f
193
+ eq_tt = vernal_equinox_jd_tt(around)
194
+ eq_msd = msd(jd_tt: eq_tt)
195
+ epoch_msd = eq_msd.floor.to_i
196
+ midnight_tt = jd_tt_from_msd(epoch_msd)
197
+ frac = eq_msd - epoch_msd
198
+ {
199
+ caveat: 'Allison Ls extrapolated to 1 CE; Earth times treat JD_TT as UT (no year-1 ΔT)',
200
+ ls0_jd_tt: eq_tt,
201
+ ls0_msd: eq_msd,
202
+ ls0_mtc: format_hms(*hours_to_hms(frac * 24.0)),
203
+ ls0_earth_if_tt_were_ut: datetime_from_ajd(eq_tt),
204
+ epoch_msd: epoch_msd,
205
+ epoch_jd_tt: midnight_tt,
206
+ epoch_earth_if_tt_were_ut: datetime_from_ajd(midnight_tt)
207
+ }
208
+ end
209
+
210
+ def jd_tt_from_msd(msd)
211
+ # invert Mars24 C-2
212
+ ((msd - MSD_EPOCH_SHIFT + MSD_FINE) * SOL_DAYS) + MSD_JDTT_OFFSET
213
+ end
214
+
215
+ # Invert jd_tt: TT → UT. Before 1900, treat TT as UT.
216
+ def jd_ut_from_jd_tt(jd_tt)
217
+ j = jd_tt.to_f
218
+ guess = j - 69.184 / SECONDS_PER_DAY
219
+ 8.times do
220
+ earth = datetime_from_ajd(guess)
221
+ if earth.to_date < Date.new(1900, 1, 1)
222
+ return j
223
+ end
224
+ nxt = j - tt_utc(earth) / SECONDS_PER_DAY
225
+ return nxt if (nxt - guess).abs < 1e-14
226
+ guess = nxt
227
+ end
228
+ guess
229
+ end
230
+
231
+ def earth_from_msd(msd)
232
+ datetime_from_ajd(jd_ut_from_jd_tt(jd_tt_from_msd(msd)))
233
+ end
234
+
235
+ def sol_fraction_of(msd)
236
+ frac = msd.to_f % 1.0
237
+ frac += 1.0 if frac < 0
238
+ frac = 0.0 if frac >= 1.0
239
+ frac
240
+ end
241
+
242
+ def tai_utc_from_table(date)
243
+ value = TAI_UTC_STEPS.first[1]
244
+ TAI_UTC_STEPS.each do |d, v|
245
+ break if date < d
246
+ value = v
247
+ end
248
+ value.to_f
249
+ end
250
+
251
+ # Mars24 A-4, dates before 1972 (used here only back to 1900).
252
+ def tt_utc_polynomial(jd_ut)
253
+ t = (jd_ut - J2000_JDTT) / 36_525.0
254
+ 64.184 + 59.0 * t - 51.2 * t**2 - 67.1 * t**3 - 16.4 * t**4
255
+ end
256
+
257
+ def mars_pbs(dt)
258
+ terms = [
259
+ [0.0071, 2.2353, 49.409],
260
+ [0.0057, 2.7543, 168.173],
261
+ [0.0039, 1.1177, 191.837],
262
+ [0.0037, 15.7866, 21.736],
263
+ [0.0021, 2.1354, 15.704],
264
+ [0.0020, 2.4694, 95.528],
265
+ [0.0018, 32.8493, 49.095]
266
+ ]
267
+ terms.sum do |amp, tau, phase|
268
+ amp * cosd(0.985626 * dt / tau + phase)
269
+ end
270
+ end
271
+
272
+ def hours_to_hms(hours, limit: nil)
273
+ h = hours.floor
274
+ minf = (hours - h) * 60.0
275
+ m = minf.floor
276
+ s = ((minf - m) * 60.0).round(4)
277
+ if s >= 59.9995
278
+ if limit && h == limit - 1 && m == 59
279
+ # The instant is still inside the half-open clock interval.
280
+ # Keep its rounded display below the following midnight.
281
+ s = 59.9994
282
+ else
283
+ s = 0.0
284
+ m += 1
285
+ end
286
+ end
287
+ if m >= 60
288
+ m = 0
289
+ h += 1
290
+ end
291
+ [h, m, s]
292
+ end
293
+
294
+ def sind(deg)
295
+ Math.sin(deg * Math::PI / 180.0)
296
+ end
297
+
298
+ def cosd(deg)
299
+ Math.cos(deg * Math::PI / 180.0)
300
+ end
301
+
302
+ def wrap360(deg)
303
+ d = deg % 360.0
304
+ d += 360.0 if d < 0
305
+ d
306
+ end
307
+
308
+ def wrap180(deg)
309
+ d = wrap360(deg)
310
+ d -= 360.0 if d > 180.0
311
+ d
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,3 @@
1
+ class MarsDateTime
2
+ VERSION = '2.0.1'
3
+ end