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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 52207edb47be792acadf50b6e0c2217572de0301aa38620fc258f7485bcce803
4
+ data.tar.gz: a3d9fca3dfba730fa8da2d52a49c9624fd59c6b2f2d79730e17b1d394b3d76a0
5
+ SHA512:
6
+ metadata.gz: 482e904705050a9bf467b149d744d3e440777db12af520fc5d8ed7883b5753cdab4c47c1b6a321ae666c8aefd973b4984da1809f2717507a01c9e98edbf7a672
7
+ data.tar.gz: '0971d1ed74eec9cdff65c09fe62795bfeec8c7bb20de02eb3f6d6f1aa0ac86479889b6b1f9344868eae3edfa5b624052303defdc692850d5ee1552b17dcf87ea'
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.2] - 2026-01-20
9
+
10
+ ### Fixed
11
+
12
+ - Strip leading colon from timezone strings returned by `Station#tzfile`
13
+ - TCD files store timezones with a leading colon (e.g., `:America/Los_Angeles`)
14
+ - Now returns standard IANA timezone names (e.g., `America/Los_Angeles`)
15
+ - Fixes compatibility with Ruby timezone libraries that don't recognize the colon prefix
16
+
17
+ ## [1.0.1] - 2026-01-20
18
+
19
+ ### Fixed
20
+
21
+ - Fixed tide/current classification for subordinate stations with asymmetric corrections
22
+ - Previously, subordinate stations with different high/low time offsets or level multipliers were incorrectly classified as current stations
23
+ - Now correctly classifies stations based on presence of current-specific indicators (flood/ebb times, direction data)
24
+ - Subordinate tide stations can have different corrections for high vs low tides and are now properly identified as tide stations
25
+
26
+ ## [1.0.0] - 2026-01-20
27
+
28
+ ### Added
29
+
30
+ - Initial release
31
+ - Pure Ruby TCD file reader (no C extensions)
32
+ - Support for TCD v2 format
33
+ - Read reference and subordinate station records
34
+ - Read constituent data (speeds, equilibrium arguments, node factors)
35
+ - Constituent inference from M2, S2, K1, O1 (Schureman method)
36
+ - Geospatial queries: `nearest_station`, `stations_near`
37
+ - Station type detection: `tide?`, `current?`, `simple?`
38
+ - Station search by name substring
39
+ - `tcd-info` command-line tool
40
+ - Comprehensive test suite
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordan Ritter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # TCD - Tidal Constituent Database Reader [![Tests](https://github.com/jpr5/tcd/actions/workflows/test.yml/badge.svg)](https://github.com/jpr5/tcd/actions/workflows/test.yml)
2
+
3
+ A pure Ruby gem for reading TCD (Tidal Constituent Database) files used by [XTide](https://flaterco.com/xtide/) for tide predictions.
4
+
5
+ ## Features
6
+
7
+ - **Pure Ruby** - No C extensions, FFI, or external dependencies
8
+ - **Complete TCD v2 support** - Reads all station types and constituent data
9
+ - **Lazy loading** - Stations can be iterated without loading all into memory
10
+ - **Constituent inference** - Compute missing constituents from major ones (M2, S2, K1, O1)
11
+ - **Geospatial queries** - Find nearest stations or stations within a radius
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'tcd'
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```bash
24
+ gem install tcd
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Basic Usage
30
+
31
+ ```ruby
32
+ require 'tcd'
33
+
34
+ TCD.open("harmonics.tcd") do |db|
35
+ puts "Stations: #{db.station_count}"
36
+ puts "Constituents: #{db.constituent_count}"
37
+ puts "Year Range: #{db.year_range}"
38
+
39
+ # Search for stations
40
+ db.find_stations("San Francisco").each do |station|
41
+ puts "#{station.name}: #{station.latitude}, #{station.longitude}"
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### Station Types
47
+
48
+ TCD files contain two types of stations:
49
+
50
+ - **Reference stations** - Have full harmonic constituent data (amplitudes & epochs)
51
+ - **Subordinate stations** - Have time/height offsets relative to a reference station
52
+
53
+ ```ruby
54
+ TCD.open("harmonics.tcd") do |db|
55
+ # Get only reference stations
56
+ db.reference_stations.each do |s|
57
+ puts "#{s.name}: #{s.active_constituents} constituents"
58
+ end
59
+
60
+ # Get only subordinate stations
61
+ db.subordinate_stations.each do |s|
62
+ puts "#{s.name}: offset from station ##{s.reference_station}"
63
+ end
64
+ end
65
+ ```
66
+
67
+ ### Geospatial Queries
68
+
69
+ ```ruby
70
+ TCD.open("harmonics.tcd") do |db|
71
+ # Find nearest station to coordinates
72
+ nearest = db.nearest_station(37.8, -122.4)
73
+ puts "Nearest: #{nearest.name}"
74
+
75
+ # Find nearest reference station only
76
+ nearest_ref = db.nearest_station(37.8, -122.4, type: :reference)
77
+
78
+ # Find all stations within 1 degree (~111 km at equator)
79
+ nearby = db.stations_near(37.8, -122.4, radius: 1.0)
80
+ puts "Found #{nearby.size} stations within 1 degree"
81
+ end
82
+ ```
83
+
84
+ ### Constituent Data
85
+
86
+ ```ruby
87
+ TCD.open("harmonics.tcd") do |db|
88
+ # Access constituent information
89
+ m2 = db.constituent("M2")
90
+ puts "M2 speed: #{m2.speed} degrees/hour"
91
+
92
+ # Get equilibrium argument for a specific year
93
+ eq = m2.equilibrium_for_year(2025, db.header.start_year)
94
+
95
+ # Get node factor for a specific year
96
+ nf = m2.node_factor_for_year(2025, db.header.start_year)
97
+ end
98
+ ```
99
+
100
+ ### Constituent Inference
101
+
102
+ For stations with limited observation data, you can infer missing constituents:
103
+
104
+ ```ruby
105
+ TCD.open("harmonics.tcd") do |db|
106
+ station = db.station_by_name("Some Station")
107
+
108
+ before = station.active_constituents
109
+ if db.infer_constituents(station)
110
+ after = station.active_constituents
111
+ puts "Inferred #{after - before} additional constituents"
112
+ end
113
+ end
114
+ ```
115
+
116
+ ### Station Type Detection
117
+
118
+ ```ruby
119
+ TCD.open("harmonics.tcd") do |db|
120
+ db.stations.each do |s|
121
+ if s.tide?
122
+ puts "#{s.name} is a TIDE station"
123
+ elsif s.current?
124
+ puts "#{s.name} is a CURRENT station"
125
+ end
126
+ end
127
+ end
128
+ ```
129
+
130
+ ### Command Line Tool
131
+
132
+ The gem includes a `tcd-info` command for exploring TCD files:
133
+
134
+ ```bash
135
+ # Show database summary
136
+ tcd-info harmonics.tcd
137
+
138
+ # List stations
139
+ tcd-info harmonics.tcd --stations 20
140
+
141
+ # Search for stations
142
+ tcd-info harmonics.tcd --search "Boston"
143
+
144
+ # List constituents
145
+ tcd-info harmonics.tcd --constituents
146
+ ```
147
+
148
+ ## TCD File Format
149
+
150
+ TCD files are binary databases containing:
151
+
152
+ - **Header** - ASCII key-value pairs defining encoding parameters
153
+ - **Lookup tables** - Countries, timezones, datums, level units, etc.
154
+ - **Constituent data** - Speeds, equilibrium arguments, and node factors
155
+ - **Station records** - Bit-packed binary records for each station
156
+
157
+ This gem implements a complete reader for the TCD v2 format as documented in the [libtcd](https://flaterco.com/xtide/libtcd.html) library.
158
+
159
+ ## Obtaining TCD Files
160
+
161
+ Harmonics data files are available from the [XTide website](https://flaterco.com/xtide/files.html). The "harmonics-dwf" files are free and contain data for thousands of stations worldwide.
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ # Run tests
167
+ rake test
168
+
169
+ # Run tests with a specific TCD file
170
+ TCD_TEST_FILE=/path/to/harmonics.tcd rake test
171
+
172
+ # Run example program
173
+ TCD_FILE=/path/to/harmonics.tcd rake example
174
+
175
+ # Open console
176
+ rake console
177
+ ```
178
+
179
+ ## License
180
+
181
+ MIT License. See [LICENSE](LICENSE) for details.
182
+
183
+ ## Acknowledgments
184
+
185
+ - [XTide](https://flaterco.com/xtide/) by David Flater
186
+ - [libtcd](https://flaterco.com/xtide/libtcd.html) by Jan Depner and David Flater
187
+ - Tidal analysis methods from "Manual of Harmonic Analysis and Prediction of Tides" by Paul Schureman
data/bin/tcd-info ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/tcd"
5
+
6
+ def format_number(n)
7
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
8
+ end
9
+
10
+ def format_time_offset(minutes)
11
+ return "N/A" if minutes.nil?
12
+ sign = minutes < 0 ? "-" : "+"
13
+ minutes = minutes.abs
14
+ hours = minutes / 60
15
+ mins = minutes % 60
16
+ "#{sign}#{hours}:%02d" % mins
17
+ end
18
+
19
+ if ARGV.empty?
20
+ puts "Usage: tcd-info <file.tcd>"
21
+ puts " tcd-info <file.tcd> --stations [limit]"
22
+ puts " tcd-info <file.tcd> --search <query>"
23
+ puts " tcd-info <file.tcd> --constituents"
24
+ exit 1
25
+ end
26
+
27
+ path = ARGV[0]
28
+
29
+ unless File.exist?(path)
30
+ puts "Error: File not found: #{path}"
31
+ exit 1
32
+ end
33
+
34
+ TCD.open(path) do |db|
35
+ filename = File.basename(path)
36
+
37
+ puts "TCD Database: #{filename}"
38
+ puts "=" * (14 + filename.length)
39
+ puts
40
+ puts "Version: #{db.version}"
41
+ puts "Last Modified: #{db.last_modified}"
42
+ puts "Format: v#{db.header.major_rev}.#{db.header.minor_rev}"
43
+ puts
44
+
45
+ stats = db.stats
46
+
47
+ puts "Database Statistics:"
48
+ puts " Total Stations: #{format_number(stats[:total_stations])}"
49
+ puts " Reference Stations: #{format_number(stats[:reference_stations])}"
50
+ puts " Subordinate Stations: #{format_number(stats[:subordinate_stations])}"
51
+ puts " Constituents: #{stats[:constituents]}"
52
+ puts " Year Range: #{stats[:year_range].first}-#{stats[:year_range].last} (#{stats[:year_range].size} years)"
53
+ puts " Countries: #{stats[:countries]}"
54
+ puts " Timezones: #{stats[:timezones]}"
55
+ puts " Datums: #{stats[:datums]}"
56
+ puts " File Size: #{format_number(stats[:file_size])} bytes"
57
+ puts
58
+
59
+ # Handle command-line options
60
+ if ARGV.include?("--constituents")
61
+ puts "Constituents (by speed):"
62
+ puts "-" * 50
63
+
64
+ # Sort by speed descending
65
+ sorted = db.constituents.sort_by { |c| -c.speed }
66
+ sorted.first(20).each do |c|
67
+ puts " %-10s %12.7f °/hr" % [c.name, c.speed]
68
+ end
69
+ if sorted.size > 20
70
+ puts " ... and #{sorted.size - 20} more"
71
+ end
72
+ puts
73
+
74
+ elsif ARGV.include?("--search")
75
+ idx = ARGV.index("--search")
76
+ query = ARGV[idx + 1]
77
+ if query.nil?
78
+ puts "Error: --search requires a query string"
79
+ exit 1
80
+ end
81
+
82
+ puts "Searching for: #{query}"
83
+ puts "-" * 50
84
+
85
+ results = db.find_stations(query)
86
+ if results.empty?
87
+ puts " No stations found matching '#{query}'"
88
+ else
89
+ results.first(20).each_with_index do |s, i|
90
+ puts
91
+ puts " #{i + 1}. #{s.name}"
92
+ type_str = s.reference? ? "Reference" : "Subordinate"
93
+ puts " Type: #{type_str} | Lat: #{format('%.5f', s.latitude)} | Lon: #{format('%.5f', s.longitude)}"
94
+ puts " Timezone: #{s.tzfile} | Country: #{s.country}"
95
+ if s.reference?
96
+ puts " Datum: #{s.datum} | Active Constituents: #{s.active_constituents}"
97
+ else
98
+ puts " Time Offsets: High #{format_time_offset(s.max_time_add)}, Low #{format_time_offset(s.min_time_add)}"
99
+ puts " Level Multiply: High ×#{format('%.3f', s.max_level_multiply)}, Low ×#{format('%.3f', s.min_level_multiply)}"
100
+ end
101
+ end
102
+ if results.size > 20
103
+ puts
104
+ puts " ... and #{results.size - 20} more results"
105
+ end
106
+ end
107
+ puts
108
+
109
+ elsif ARGV.include?("--stations")
110
+ idx = ARGV.index("--stations")
111
+ limit = (ARGV[idx + 1] || "10").to_i
112
+
113
+ puts "Sample Reference Stations:"
114
+ puts "-" * 50
115
+
116
+ ref_stations = db.reference_stations.first(limit)
117
+ ref_stations.each_with_index do |s, i|
118
+ puts
119
+ puts " #{i + 1}. #{s.name}"
120
+ puts " Lat: #{format('%.5f', s.latitude)} | Lon: #{format('%.5f', s.longitude)}"
121
+ puts " Timezone: #{s.tzfile} | Country: #{s.country}"
122
+ puts " Datum: #{s.datum} | Offset: #{format('%.4f', s.datum_offset)} #{s.level_units}"
123
+ puts " Active Constituents: #{s.active_constituents} of #{db.constituent_count}"
124
+ end
125
+
126
+ puts
127
+ puts "Sample Subordinate Stations:"
128
+ puts "-" * 50
129
+
130
+ sub_stations = db.subordinate_stations.first(limit)
131
+ sub_stations.each_with_index do |s, i|
132
+ puts
133
+ puts " #{i + 1}. #{s.name}"
134
+ puts " Lat: #{format('%.5f', s.latitude)} | Lon: #{format('%.5f', s.longitude)}"
135
+ puts " Reference: Station ##{s.reference_station}"
136
+ puts " Time Offsets: High #{format_time_offset(s.max_time_add)}, Low #{format_time_offset(s.min_time_add)}"
137
+ puts " Level Add: High #{format('%+.3f', s.max_level_add)}, Low #{format('%+.3f', s.min_level_add)}"
138
+ puts " Level Multiply: High ×#{format('%.3f', s.max_level_multiply)}, Low ×#{format('%.3f', s.min_level_multiply)}"
139
+ end
140
+ puts
141
+
142
+ else
143
+ # Default: show summary with a few sample stations
144
+ puts "Sample Reference Stations:"
145
+ puts "-" * 50
146
+
147
+ db.reference_stations.first(3).each_with_index do |s, i|
148
+ puts
149
+ puts " #{i + 1}. #{s.name}"
150
+ puts " Type: Reference | Lat: #{format('%.5f', s.latitude)} | Lon: #{format('%.5f', s.longitude)}"
151
+ puts " Timezone: #{s.tzfile} | Country: #{s.country}"
152
+ puts " Datum: #{s.datum} | Active Constituents: #{s.active_constituents}"
153
+ end
154
+
155
+ puts
156
+ puts "Sample Subordinate Stations:"
157
+ puts "-" * 50
158
+
159
+ db.subordinate_stations.first(3).each_with_index do |s, i|
160
+ puts
161
+ puts " #{i + 1}. #{s.name}"
162
+ puts " Type: Subordinate | Lat: #{format('%.5f', s.latitude)} | Lon: #{format('%.5f', s.longitude)}"
163
+ puts " Reference: Station ##{s.reference_station}"
164
+ puts " Time Offsets: High #{format_time_offset(s.max_time_add)}, Low #{format_time_offset(s.min_time_add)}"
165
+ end
166
+
167
+ puts
168
+ puts "Top Constituents by Speed:"
169
+ puts "-" * 50
170
+
171
+ db.constituents.sort_by { |c| -c.speed }.first(5).each do |c|
172
+ puts " %-6s %12.7f °/hr" % [c.name, c.speed]
173
+ end
174
+
175
+ puts
176
+ puts "Use --stations, --constituents, or --search <query> for more details."
177
+ end
178
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCD
4
+ # Pure Ruby bit-level I/O for reading arbitrary bit-width integers from a binary stream.
5
+ # TCD files use bit-packing where fields don't align to byte boundaries.
6
+ class BitBuffer
7
+ def initialize(io)
8
+ @io = io
9
+ @buffer = 0 # Accumulated bits (MSB first)
10
+ @bits_available = 0 # Number of valid bits in buffer
11
+ end
12
+
13
+ # Read n bits as unsigned integer (1-32 bits supported)
14
+ def read_uint(n)
15
+ raise ArgumentError, "bit count must be 1-32, got #{n}" if n < 1 || n > 32
16
+
17
+ # Fill buffer until we have enough bits
18
+ while @bits_available < n
19
+ byte = @io.readbyte
20
+ @buffer = (@buffer << 8) | byte
21
+ @bits_available += 8
22
+ end
23
+
24
+ # Extract top n bits
25
+ shift = @bits_available - n
26
+ value = (@buffer >> shift) & ((1 << n) - 1)
27
+
28
+ # Remove extracted bits from buffer
29
+ @bits_available = shift
30
+ @buffer &= (1 << shift) - 1
31
+
32
+ value
33
+ end
34
+
35
+ # Read n bits as signed integer (two's complement)
36
+ def read_int(n)
37
+ value = read_uint(n)
38
+ msb = 1 << (n - 1)
39
+ value >= msb ? value - (1 << n) : value
40
+ end
41
+
42
+ # Read n bits and apply scale factor: value / scale
43
+ def read_scaled(n, scale, signed: false)
44
+ raw = signed ? read_int(n) : read_uint(n)
45
+ raw.to_f / scale
46
+ end
47
+
48
+ # Read n bits with offset and scale: (raw + offset) / scale
49
+ # Used for constituent speeds where offset shifts the range
50
+ def read_offset_scaled(n, offset, scale)
51
+ raw = read_uint(n)
52
+ (raw.to_f + offset) / scale
53
+ end
54
+
55
+ # Discard any partial byte and align to next byte boundary
56
+ def align
57
+ @buffer = 0
58
+ @bits_available = 0
59
+ end
60
+
61
+ # Read a null-terminated string
62
+ # Strings in TCD are NOT byte-aligned - they start at the current bit position
63
+ # and are read 8 bits at a time until a null byte is found.
64
+ def read_cstring
65
+ chars = []
66
+ loop do
67
+ byte = read_uint(8)
68
+ break if byte == 0
69
+ chars << byte
70
+ end
71
+ chars.pack("C*").force_encoding("ISO-8859-1").encode("UTF-8")
72
+ end
73
+
74
+ # Read fixed-size string, stripping null padding
75
+ def read_fixed_string(size)
76
+ align
77
+ bytes = @io.read(size)
78
+ return "" if bytes.nil? || bytes.empty?
79
+ # Find first null and truncate, handle encoding
80
+ bytes.force_encoding("ISO-8859-1")
81
+ null_pos = bytes.index("\x00")
82
+ str = null_pos ? bytes[0, null_pos] : bytes
83
+ str.encode("UTF-8", invalid: :replace, undef: :replace)
84
+ end
85
+
86
+ # Current position in underlying IO
87
+ def pos
88
+ @io.pos
89
+ end
90
+
91
+ # Seek to absolute position (clears bit buffer)
92
+ def seek(offset)
93
+ @io.seek(offset)
94
+ align
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCD
4
+ # Data structure for tidal constituent information.
5
+ # Each constituent has a speed and year-indexed equilibrium/node factor arrays.
6
+ Constituent = Struct.new(
7
+ :index, # Index in the constituent table (0-based)
8
+ :name, # Constituent name (e.g., "M2", "S2", "K1")
9
+ :speed, # Angular speed in degrees per hour
10
+ :equilibrium, # Array of equilibrium arguments by year (degrees)
11
+ :node_factors, # Array of node factors by year (dimensionless, ~1.0)
12
+ keyword_init: true
13
+ ) do
14
+ # Get equilibrium argument for a specific year
15
+ def equilibrium_for_year(year, start_year)
16
+ idx = year - start_year
17
+ return nil if idx < 0 || idx >= equilibrium.size
18
+ equilibrium[idx]
19
+ end
20
+
21
+ # Get node factor for a specific year
22
+ def node_factor_for_year(year, start_year)
23
+ idx = year - start_year
24
+ return nil if idx < 0 || idx >= node_factors.size
25
+ node_factors[idx]
26
+ end
27
+
28
+ def to_s
29
+ "#{name}: #{format('%.7f', speed)}°/hr"
30
+ end
31
+ end
32
+
33
+ # Reader for constituent data (speeds, equilibrium args, node factors).
34
+ # Data is stored as bit-packed arrays after the lookup tables.
35
+ class ConstituentData
36
+ attr_reader :constituents
37
+
38
+ def initialize(bit_buffer, header, lookup_tables)
39
+ @bit = bit_buffer
40
+ @header = header
41
+ @lookup = lookup_tables
42
+ @constituents = []
43
+ load_data
44
+ end
45
+
46
+ # Find constituent by name
47
+ def find(name)
48
+ @constituents.find { |c| c.name == name }
49
+ end
50
+
51
+ # Find constituent by index
52
+ def [](idx)
53
+ @constituents[idx]
54
+ end
55
+
56
+ # Number of constituents
57
+ def size
58
+ @constituents.size
59
+ end
60
+
61
+ # Iterate over constituents
62
+ def each(&block)
63
+ @constituents.each(&block)
64
+ end
65
+ include Enumerable
66
+
67
+ private
68
+
69
+ def load_data
70
+ num_constituents = @header.constituents
71
+ num_years = @header.number_of_years
72
+
73
+ # Read speeds for all constituents
74
+ speeds = read_speeds(num_constituents)
75
+
76
+ # Read equilibrium arguments: constituents × years matrix
77
+ equilibriums = read_equilibriums(num_constituents, num_years)
78
+
79
+ # Read node factors: constituents × years matrix
80
+ node_factors = read_node_factors(num_constituents, num_years)
81
+
82
+ # Build Constituent structs
83
+ num_constituents.times do |i|
84
+ @constituents << Constituent.new(
85
+ index: i,
86
+ name: @lookup.constituent(i) || "C#{i}",
87
+ speed: speeds[i],
88
+ equilibrium: equilibriums[i],
89
+ node_factors: node_factors[i]
90
+ )
91
+ end
92
+ end
93
+
94
+ def read_speeds(count)
95
+ bits = @header.speed_bits
96
+ scale = @header.speed_scale
97
+ offset = @header.speed_offset || 0
98
+
99
+ count.times.map do
100
+ raw = @bit.read_uint(bits)
101
+ (raw.to_f + offset) / scale
102
+ end
103
+ end
104
+
105
+ def read_equilibriums(num_constituents, num_years)
106
+ bits = @header.equilibrium_bits
107
+ scale = @header.equilibrium_scale
108
+ offset = @header.equilibrium_offset || 0
109
+
110
+ num_constituents.times.map do
111
+ num_years.times.map do
112
+ raw = @bit.read_uint(bits)
113
+ (raw.to_f + offset) / scale
114
+ end
115
+ end
116
+ end
117
+
118
+ def read_node_factors(num_constituents, num_years)
119
+ bits = @header.node_bits
120
+ scale = @header.node_scale
121
+ offset = @header.node_offset || 0
122
+
123
+ num_constituents.times.map do
124
+ num_years.times.map do
125
+ raw = @bit.read_uint(bits)
126
+ (raw.to_f + offset) / scale
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end