tcd 1.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.
data/lib/tcd/header.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCD
4
+ # Parser for TCD ASCII header section.
5
+ # The header contains [KEY] = VALUE pairs defining all encoding parameters.
6
+ class Header
7
+ REQUIRED_KEYS = %i[
8
+ header_size number_of_records constituents
9
+ start_year number_of_years
10
+ ].freeze
11
+
12
+ attr_reader :params
13
+
14
+ def initialize(io)
15
+ @params = {}
16
+ parse(io)
17
+ validate!
18
+ end
19
+
20
+ # Access header parameters by symbol key
21
+ def [](key)
22
+ @params[key]
23
+ end
24
+
25
+ # Check if key exists
26
+ def key?(key)
27
+ @params.key?(key)
28
+ end
29
+
30
+ # All parameter keys
31
+ def keys
32
+ @params.keys
33
+ end
34
+
35
+ # Convenience accessors for commonly used parameters
36
+ def header_size; @params[:header_size]; end
37
+ def number_of_records; @params[:number_of_records]; end
38
+ def constituents; @params[:constituents]; end
39
+ def start_year; @params[:start_year]; end
40
+ def number_of_years; @params[:number_of_years]; end
41
+ def version; @params[:version]; end
42
+ def major_rev; @params[:major_rev]; end
43
+ def minor_rev; @params[:minor_rev]; end
44
+ def last_modified; @params[:last_modified]; end
45
+ def end_of_file; @params[:end_of_file]; end
46
+
47
+ # Bit field parameters
48
+ def speed_bits; @params[:speed_bits]; end
49
+ def speed_scale; @params[:speed_scale]; end
50
+ def speed_offset; @params[:speed_offset]; end
51
+ def equilibrium_bits; @params[:equilibrium_bits]; end
52
+ def equilibrium_scale; @params[:equilibrium_scale]; end
53
+ def equilibrium_offset; @params[:equilibrium_offset]; end
54
+ def node_bits; @params[:node_bits]; end
55
+ def node_scale; @params[:node_scale]; end
56
+ def node_offset; @params[:node_offset]; end
57
+ def amplitude_bits; @params[:amplitude_bits]; end
58
+ def amplitude_scale; @params[:amplitude_scale]; end
59
+ def epoch_bits; @params[:epoch_bits]; end
60
+ def epoch_scale; @params[:epoch_scale]; end
61
+
62
+ # Record field parameters
63
+ def record_type_bits; @params[:record_type_bits]; end
64
+ def latitude_bits; @params[:latitude_bits]; end
65
+ def latitude_scale; @params[:latitude_scale]; end
66
+ def longitude_bits; @params[:longitude_bits]; end
67
+ def longitude_scale; @params[:longitude_scale]; end
68
+ def record_size_bits; @params[:record_size_bits]; end
69
+ def station_bits; @params[:station_bits]; end
70
+ def datum_offset_bits; @params[:datum_offset_bits]; end
71
+ def datum_offset_scale; @params[:datum_offset_scale]; end
72
+ def date_bits; @params[:date_bits]; end
73
+ def months_on_station_bits; @params[:months_on_station_bits]; end
74
+ def confidence_value_bits; @params[:confidence_value_bits]; end
75
+ def time_bits; @params[:time_bits]; end
76
+ def level_add_bits; @params[:level_add_bits]; end
77
+ def level_add_scale; @params[:level_add_scale]; end
78
+ def level_multiply_bits; @params[:level_multiply_bits]; end
79
+ def level_multiply_scale; @params[:level_multiply_scale]; end
80
+ def direction_bits; @params[:direction_bits]; end
81
+
82
+ # Lookup table parameters
83
+ def level_unit_bits; @params[:level_unit_bits]; end
84
+ def level_unit_types; @params[:level_unit_types]; end
85
+ def level_unit_size; @params[:level_unit_size]; end
86
+ def direction_unit_bits; @params[:direction_unit_bits]; end
87
+ def direction_unit_types; @params[:direction_unit_types]; end
88
+ def direction_unit_size; @params[:direction_unit_size]; end
89
+ def restriction_bits; @params[:restriction_bits]; end
90
+ def restriction_types; @params[:restriction_types]; end
91
+ def restriction_size; @params[:restriction_size]; end
92
+ def datum_bits; @params[:datum_bits]; end
93
+ def datum_types; @params[:datum_types]; end
94
+ def datum_size; @params[:datum_size]; end
95
+ def legalese_bits; @params[:legalese_bits]; end
96
+ def legalese_types; @params[:legalese_types]; end
97
+ def legalese_size; @params[:legalese_size]; end
98
+ def constituent_bits; @params[:constituent_bits]; end
99
+ def constituent_size; @params[:constituent_size]; end
100
+ def tzfile_bits; @params[:tzfile_bits]; end
101
+ def tzfiles; @params[:tzfiles]; end
102
+ def tzfile_size; @params[:tzfile_size]; end
103
+ def country_bits; @params[:country_bits]; end
104
+ def countries; @params[:countries]; end
105
+ def country_size; @params[:country_size]; end
106
+
107
+ private
108
+
109
+ def parse(io)
110
+ io.rewind
111
+ io.each_line do |line|
112
+ line = line.strip
113
+ break if line == "[END OF ASCII HEADER DATA]"
114
+ next if line.empty?
115
+
116
+ if line =~ /^\[(.+?)\]\s*=\s*(.+)$/
117
+ key = normalize_key($1)
118
+ value = parse_value($2)
119
+ @params[key] = value
120
+ end
121
+ end
122
+ end
123
+
124
+ def normalize_key(key)
125
+ key.downcase.gsub(/\s+/, "_").to_sym
126
+ end
127
+
128
+ def parse_value(value)
129
+ value = value.strip
130
+ case value
131
+ when /^-?\d+$/
132
+ value.to_i
133
+ when /^-?\d+\.\d+$/
134
+ value.to_f
135
+ else
136
+ value
137
+ end
138
+ end
139
+
140
+ def validate!
141
+ missing = REQUIRED_KEYS.reject { |k| @params.key?(k) }
142
+ unless missing.empty?
143
+ raise FormatError, "missing required header keys: #{missing.join(', ')}"
144
+ end
145
+ end
146
+ end
147
+
148
+ class FormatError < StandardError; end
149
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCD
4
+ # Computes inferred constituents when M2, S2, K1, and O1 are given.
5
+ # This fills remaining unfilled constituents based on article 230 of
6
+ # "Manual of Harmonic Analysis and Prediction of Tides",
7
+ # Paul Schureman, C & GS special publication no. 98, October 1971.
8
+ #
9
+ # This is useful for stations with short observation periods that only
10
+ # have a few major constituents developed. The inferred constituents
11
+ # can improve tide predictions.
12
+ module Inference
13
+ # Semi-diurnal constituents that can be inferred from M2 and S2
14
+ INFERRED_SEMI_DIURNAL = %w[N2 NU2 MU2 2N2 LDA2 T2 R2 L2 K2 KJ2].freeze
15
+
16
+ # Coefficients for semi-diurnal inference (relative to M2)
17
+ SEMI_DIURNAL_COEFF = [
18
+ 0.1759, # N2
19
+ 0.0341, # NU2
20
+ 0.0219, # MU2
21
+ 0.0235, # 2N2
22
+ 0.0066, # LDA2
23
+ 0.0248, # T2
24
+ 0.0035, # R2
25
+ 0.0251, # L2
26
+ 0.1151, # K2
27
+ 0.0064 # KJ2
28
+ ].freeze
29
+
30
+ # Diurnal constituents that can be inferred from K1 and O1
31
+ INFERRED_DIURNAL = %w[OO1 M1 J1 RHO1 Q1 2Q1 P1 PI1 PHI1 PSI1].freeze
32
+
33
+ # Coefficients for diurnal inference (relative to O1)
34
+ DIURNAL_COEFF = [
35
+ 0.0163, # OO1
36
+ 0.0209, # M1
37
+ 0.0297, # J1
38
+ 0.0142, # RHO1
39
+ 0.0730, # Q1
40
+ 0.0097, # 2Q1
41
+ 0.1755, # P1
42
+ 0.0103, # PI1
43
+ 0.0076, # PHI1
44
+ 0.0042 # PSI1
45
+ ].freeze
46
+
47
+ # Reference coefficients for M2 and O1
48
+ M2_COEFF = 0.9085
49
+ O1_COEFF = 0.3771
50
+
51
+ # Infer missing constituents for a reference station.
52
+ #
53
+ # Requires the station to have non-zero values for M2, S2, K1, and O1.
54
+ # Modifies the station's amplitudes and epochs arrays in place.
55
+ #
56
+ # @param station [Station] A reference station with amplitudes/epochs arrays
57
+ # @param constituent_data [ConstituentData] The constituent data from the reader
58
+ # @return [Boolean] true if inference was performed, false if not enough data
59
+ def self.infer_constituents(station, constituent_data)
60
+ return false unless station.reference?
61
+ return false unless station.amplitudes && station.epochs
62
+
63
+ # Find the required constituents
64
+ m2 = constituent_data.find("M2")
65
+ s2 = constituent_data.find("S2")
66
+ k1 = constituent_data.find("K1")
67
+ o1 = constituent_data.find("O1")
68
+
69
+ return false unless m2 && s2 && k1 && o1
70
+
71
+ m2_idx = m2.index
72
+ s2_idx = s2.index
73
+ k1_idx = k1.index
74
+ o1_idx = o1.index
75
+
76
+ # Check that all four required constituents have non-zero values
77
+ return false if station.amplitudes[m2_idx] == 0.0
78
+ return false if station.amplitudes[s2_idx] == 0.0
79
+ return false if station.amplitudes[k1_idx] == 0.0
80
+ return false if station.amplitudes[o1_idx] == 0.0
81
+
82
+ # Get the epochs, handling wrap-around
83
+ epoch_m2 = station.epochs[m2_idx]
84
+ epoch_s2 = station.epochs[s2_idx]
85
+ epoch_k1 = station.epochs[k1_idx]
86
+ epoch_o1 = station.epochs[o1_idx]
87
+
88
+ # Build lookup from constituent name to index
89
+ constituent_lookup = {}
90
+ constituent_data.each_with_index do |c, idx|
91
+ constituent_lookup[c.name] = idx
92
+ end
93
+
94
+ # Infer semi-diurnal constituents
95
+ INFERRED_SEMI_DIURNAL.each_with_index do |name, j|
96
+ idx = constituent_lookup[name]
97
+ next unless idx
98
+ next unless station.amplitudes[idx] == 0.0 && station.epochs[idx] == 0.0
99
+
100
+ constituent = constituent_data[idx]
101
+ next unless constituent
102
+
103
+ # Compute amplitude
104
+ station.amplitudes[idx] = (SEMI_DIURNAL_COEFF[j] / M2_COEFF) *
105
+ station.amplitudes[m2_idx]
106
+
107
+ # Compute epoch with wrap-around handling
108
+ e_m2 = epoch_m2
109
+ e_s2 = epoch_s2
110
+ if (e_s2 - e_m2).abs > 180.0
111
+ if e_s2 < e_m2
112
+ e_s2 += 360.0
113
+ else
114
+ e_m2 += 360.0
115
+ end
116
+ end
117
+
118
+ speed_diff_ratio = (constituent.speed - m2.speed) / (s2.speed - m2.speed)
119
+ station.epochs[idx] = e_m2 + speed_diff_ratio * (e_s2 - e_m2)
120
+ end
121
+
122
+ # Infer diurnal constituents
123
+ INFERRED_DIURNAL.each_with_index do |name, j|
124
+ idx = constituent_lookup[name]
125
+ next unless idx
126
+ next unless station.amplitudes[idx] == 0.0 && station.epochs[idx] == 0.0
127
+
128
+ constituent = constituent_data[idx]
129
+ next unless constituent
130
+
131
+ # Compute amplitude
132
+ station.amplitudes[idx] = (DIURNAL_COEFF[j] / O1_COEFF) *
133
+ station.amplitudes[o1_idx]
134
+
135
+ # Compute epoch with wrap-around handling
136
+ e_k1 = epoch_k1
137
+ e_o1 = epoch_o1
138
+ if (e_k1 - e_o1).abs > 180.0
139
+ if e_k1 < e_o1
140
+ e_k1 += 360.0
141
+ else
142
+ e_o1 += 360.0
143
+ end
144
+ end
145
+
146
+ speed_diff_ratio = (constituent.speed - o1.speed) / (k1.speed - o1.speed)
147
+ station.epochs[idx] = e_o1 + speed_diff_ratio * (e_k1 - e_o1)
148
+ end
149
+
150
+ true
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCD
4
+ # Reader for TCD lookup tables (fixed-size string arrays).
5
+ #
6
+ # TCD v2 stores string tables in this order after the 4-byte checksum:
7
+ # 1. Level units (level_unit_types × level_unit_size) - exact count
8
+ # 2. Direction units (dir_unit_types × dir_unit_size) - exact count
9
+ # 3. Restrictions (max_restriction_types × restriction_size) - reads until "__END__"
10
+ # 4. [v1 only: Pedigrees - skipped in v2]
11
+ # 5. Timezones (max_tzfiles × tzfile_size) - reads until "__END__"
12
+ # 6. Countries (max_countries × country_size) - reads until "__END__"
13
+ # 7. Datums (max_datum_types × datum_size) - reads until "__END__"
14
+ # 8. [v2 only: Legalese (max_legaleses × legalese_size) - reads until "__END__"]
15
+ # 9. Constituent names (constituents × constituent_size) - exact count
16
+ # 10. Constituent speeds (bit-packed)
17
+ # 11. Equilibrium arguments (bit-packed)
18
+ # 12. Node factors (bit-packed)
19
+ # 13. Station records (bit-packed)
20
+ #
21
+ class LookupTables
22
+ attr_reader :level_units, :direction_units, :restrictions
23
+ attr_reader :legalese, :datums, :constituents
24
+ attr_reader :timezones, :countries
25
+
26
+ # Positions tracked for the reader
27
+ attr_reader :constituent_data_offset # Where bit-packed constituent data starts
28
+ attr_reader :station_records_offset # Where station records start
29
+
30
+ def initialize(io, header)
31
+ @io = io
32
+ @header = header
33
+ load_tables
34
+ end
35
+
36
+ # Lookup by index with bounds checking
37
+ def level_unit(idx); safe_lookup(@level_units, idx); end
38
+ def direction_unit(idx); safe_lookup(@direction_units, idx); end
39
+ def restriction(idx); safe_lookup(@restrictions, idx); end
40
+ def legalese_text(idx); safe_lookup(@legalese, idx); end
41
+ def datum(idx); safe_lookup(@datums, idx); end
42
+ def constituent(idx); safe_lookup(@constituents, idx); end
43
+ def country(idx); safe_lookup(@countries, idx); end
44
+
45
+ # Timezone strings in TCD files have a leading colon (e.g., ":America/New_York")
46
+ # Strip it to return standard IANA timezone names
47
+ def timezone(idx)
48
+ tz = safe_lookup(@timezones, idx)
49
+ tz&.sub(/^:/, '')
50
+ end
51
+
52
+ private
53
+
54
+ def load_tables
55
+ # Seek to start of binary section (right after ASCII header)
56
+ @io.seek(@header.header_size)
57
+
58
+ # Skip 4-byte CRC/checksum at start of binary section
59
+ @io.read(4)
60
+
61
+ # Tables are stored in fixed order per libtcd.
62
+ # Some tables use exact count, others allocate max space based on bits.
63
+
64
+ # 1. Level units - exact count
65
+ @level_units = read_table_exact(@header.level_unit_types, @header.level_unit_size)
66
+
67
+ # 2. Direction units - exact count
68
+ @direction_units = read_table_exact(@header.direction_unit_types, @header.direction_unit_size)
69
+
70
+ # 3. Restrictions - max entries based on bits, reads until "__END__"
71
+ max_restrictions = 2**@header.restriction_bits
72
+ @restrictions = read_table_with_end(max_restrictions, @header.restriction_size)
73
+
74
+ # 4. Pedigrees - skipped in v2 (major_rev >= 2)
75
+ # In v1, space was allocated: pedigree_size × 2^pedigree_bits
76
+ # But in v2, we skip this entirely
77
+ if @header.major_rev && @header.major_rev < 2 && @header[:pedigree_bits] && @header[:pedigree_size]
78
+ pedigree_max = 2**@header[:pedigree_bits]
79
+ @io.seek(@io.pos + pedigree_max * @header[:pedigree_size])
80
+ end
81
+
82
+ # 5. Timezones - max entries based on bits, reads until "__END__"
83
+ max_tzfiles = 2**@header.tzfile_bits
84
+ @timezones = read_table_with_end(max_tzfiles, @header.tzfile_size)
85
+
86
+ # 6. Countries - max entries based on bits, reads until "__END__"
87
+ max_countries = 2**@header.country_bits
88
+ @countries = read_table_with_end(max_countries, @header.country_size)
89
+
90
+ # 7. Datums - max entries based on bits, reads until "__END__"
91
+ max_datums = 2**@header.datum_bits
92
+ @datums = read_table_with_end(max_datums, @header.datum_size)
93
+
94
+ # 8. Legalese - v2 only (major_rev >= 2), max based on bits
95
+ if @header.major_rev && @header.major_rev >= 2 && @header.legalese_bits && @header.legalese_size
96
+ max_legaleses = 2**@header.legalese_bits
97
+ @legalese = read_table_with_end(max_legaleses, @header.legalese_size)
98
+ else
99
+ @legalese = ["NULL"]
100
+ end
101
+
102
+ # 9. Constituent names - exact count
103
+ @constituents = read_table_exact(@header.constituents, @header.constituent_size)
104
+
105
+ # After constituent names, constituent binary data begins (speeds, equilibriums, node factors)
106
+ @constituent_data_offset = @io.pos
107
+
108
+ # Calculate size of constituent binary data in bytes
109
+ constituent_bytes = calculate_constituent_data_bytes
110
+ @station_records_offset = @constituent_data_offset + constituent_bytes
111
+ end
112
+
113
+ # Read a table with exact count (no __END__ marker)
114
+ def read_table_exact(count, entry_size)
115
+ entries = []
116
+ count.times do
117
+ bytes = @io.read(entry_size)
118
+ break if bytes.nil? || bytes.empty?
119
+ entries << decode_string(bytes)
120
+ end
121
+ entries
122
+ end
123
+
124
+ # Read a table that uses __END__ as terminator but allocates max space
125
+ def read_table_with_end(max_entries, entry_size)
126
+ start_pos = @io.pos
127
+ entries = []
128
+
129
+ max_entries.times do
130
+ bytes = @io.read(entry_size)
131
+ break if bytes.nil? || bytes.empty?
132
+
133
+ str = decode_string(bytes)
134
+ break if str == "__END__"
135
+
136
+ entries << str
137
+ end
138
+
139
+ # Seek past the full allocated space regardless of where __END__ was found
140
+ @io.seek(start_pos + max_entries * entry_size)
141
+ entries
142
+ end
143
+
144
+ def decode_string(bytes)
145
+ # Handle encoding: TCD uses ISO-8859-1
146
+ bytes.force_encoding("ISO-8859-1")
147
+
148
+ # Find null terminator and truncate
149
+ null_pos = bytes.index("\x00")
150
+ str = null_pos ? bytes[0, null_pos] : bytes
151
+
152
+ # Convert to UTF-8 for Ruby compatibility
153
+ str.encode("UTF-8", invalid: :replace, undef: :replace)
154
+ end
155
+
156
+ def calculate_constituent_data_bytes
157
+ num_c = @header.constituents
158
+ num_y = @header.number_of_years
159
+
160
+ # In v2, we use bits2bytes which is (bits + 7) / 8
161
+ # In v1, there was a "wasted byte bug": (bits / 8) + 1
162
+
163
+ is_v1 = @header.major_rev && @header.major_rev < 2
164
+
165
+ # Speeds: one per constituent
166
+ speed_bits = num_c * @header.speed_bits
167
+ speed_bytes = is_v1 ? (speed_bits / 8) + 1 : (speed_bits + 7) / 8
168
+
169
+ # Equilibrium arguments: constituents × years matrix
170
+ eq_bits = num_c * num_y * @header.equilibrium_bits
171
+ eq_bytes = is_v1 ? (eq_bits / 8) + 1 : (eq_bits + 7) / 8
172
+
173
+ # Node factors: constituents × years matrix
174
+ node_bits = num_c * num_y * @header.node_bits
175
+ node_bytes = is_v1 ? (node_bits / 8) + 1 : (node_bits + 7) / 8
176
+
177
+ speed_bytes + eq_bytes + node_bytes
178
+ end
179
+
180
+ def safe_lookup(table, idx)
181
+ return nil if idx.nil? || idx < 0 || idx >= table.size
182
+ table[idx]
183
+ end
184
+ end
185
+ end