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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +187 -0
- data/bin/tcd-info +178 -0
- data/lib/tcd/bit_buffer.rb +97 -0
- data/lib/tcd/constituent.rb +131 -0
- data/lib/tcd/header.rb +149 -0
- data/lib/tcd/inference.rb +153 -0
- data/lib/tcd/lookup_tables.rb +185 -0
- data/lib/tcd/reader.rb +224 -0
- data/lib/tcd/station.rb +333 -0
- data/lib/tcd/version.rb +5 -0
- data/lib/tcd.rb +23 -0
- metadata +90 -0
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
|