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
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 [](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
|