tcd 1.0.2 → 1.1.0
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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +22 -0
- data/lib/tcd/constituent.rb +7 -0
- data/lib/tcd/header.rb +33 -0
- data/lib/tcd/reader.rb +12 -1
- data/lib/tcd/version.rb +1 -1
- data/lib/tcd/writer.rb +884 -0
- data/lib/tcd.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93a24ba8b3b951d7d11b827c8acee75b8ea720d70d3c6760e89d8f650dc95a51
|
|
4
|
+
data.tar.gz: 33b7fa3cbef20c4a8f6290a8b5d440eb22675ec0ad491bd16820f933ff1594db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3430db5852e39475756d615656e03f0c7baba56cbf77bbfef349e4583fcc465636b0e46acdd105e6e988111f62dd87f7985f7b37f4d8b37d360f9ca4b282dae
|
|
7
|
+
data.tar.gz: c7ddc979c61f115e74caeb29bef5b448105fc9cfef7c7dad763ff7cd1d16ebfcce83475223ec34a7f952989cbe50637c6db155e72f8b121e86b963fde93c9563
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-10-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `TCD::Writer`: writes TCD files that libtcd and XTide read (`Writer.from_reader(reader)` or plain data)
|
|
13
|
+
- Verify the header CRC-32 on open, as libtcd does; a mismatch raises `TCD::ChecksumError` (`Reader.new(path, verify_checksum: false)` skips it)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Read the equilibrium and node-factor tables of a TCD whose constituent count is not a multiple of 8 (each table starts on a byte boundary, as libtcd writes it)
|
|
18
|
+
|
|
8
19
|
## [1.0.2] - 2026-01-20
|
|
9
20
|
|
|
10
21
|
### Fixed
|
data/README.md
CHANGED
|
@@ -127,6 +127,28 @@ TCD.open("harmonics.tcd") do |db|
|
|
|
127
127
|
end
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
### Writing TCD Files
|
|
131
|
+
|
|
132
|
+
`TCD::Writer` writes a file that libtcd and XTide read. Copy a file you read:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
TCD.open("harmonics.tcd") { |db| TCD::Writer.from_reader(db).write("copy.tcd") }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Or build one from plain data (units as the reader returns them; time offsets in minutes):
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
writer = TCD::Writer.new(
|
|
142
|
+
constituents: [{ name: "M2", speed: 28.9841042, equilibrium: [10.5, 200.25], node_factors: [0.98, 1.02] }],
|
|
143
|
+
start_year: 2025
|
|
144
|
+
)
|
|
145
|
+
writer.add_station(name: "Example", latitude: 59.9, longitude: 10.75, tzfile: "Europe/Oslo",
|
|
146
|
+
level_units: "meters", amplitudes: [0.5], epochs: [120.0])
|
|
147
|
+
writer.write("example.tcd")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Values that do not fit their field (for example a name over 89 bytes in ISO-8859-1) raise `ArgumentError`. `zone_offset` is a signed HHMM integer (`-500` for UTC-5), not minutes; epochs must be 0–360 degrees; dates are 0 or YYYYMMDD.
|
|
151
|
+
|
|
130
152
|
### Command Line Tool
|
|
131
153
|
|
|
132
154
|
The gem includes a `tcd-info` command for exploring TCD files:
|
data/lib/tcd/constituent.rb
CHANGED
|
@@ -73,9 +73,16 @@ module TCD
|
|
|
73
73
|
# Read speeds for all constituents
|
|
74
74
|
speeds = read_speeds(num_constituents)
|
|
75
75
|
|
|
76
|
+
# libtcd writes each table into its own whole number of bytes, so skip the
|
|
77
|
+
# padding bits that end the previous table (none when the bit count is a
|
|
78
|
+
# multiple of 8, e.g. 176 constituents; some for 178).
|
|
79
|
+
@bit.align
|
|
80
|
+
|
|
76
81
|
# Read equilibrium arguments: constituents × years matrix
|
|
77
82
|
equilibriums = read_equilibriums(num_constituents, num_years)
|
|
78
83
|
|
|
84
|
+
@bit.align
|
|
85
|
+
|
|
79
86
|
# Read node factors: constituents × years matrix
|
|
80
87
|
node_factors = read_node_factors(num_constituents, num_years)
|
|
81
88
|
|
data/lib/tcd/header.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
3
5
|
module TCD
|
|
4
6
|
# Parser for TCD ASCII header section.
|
|
5
7
|
# The header contains [KEY] = VALUE pairs defining all encoding parameters.
|
|
@@ -104,6 +106,36 @@ module TCD
|
|
|
104
106
|
def countries; @params[:countries]; end
|
|
105
107
|
def country_size; @params[:country_size]; end
|
|
106
108
|
|
|
109
|
+
# CRC-32 of the ASCII header padded to header_size bytes, as libtcd's
|
|
110
|
+
# header_checksum() computes it.
|
|
111
|
+
def compute_checksum(io)
|
|
112
|
+
io.seek(0)
|
|
113
|
+
data = io.read(header_size)
|
|
114
|
+
if data.nil? || data.bytesize < header_size
|
|
115
|
+
raise FormatError, "file is shorter than its #{header_size}-byte header"
|
|
116
|
+
end
|
|
117
|
+
Zlib.crc32(data)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The CRC-32 stored in the 4 bytes after the header (big-endian).
|
|
121
|
+
def stored_checksum(io)
|
|
122
|
+
io.seek(header_size)
|
|
123
|
+
bytes = io.read(4)
|
|
124
|
+
raise FormatError, "file ends before the header checksum" if bytes.nil? || bytes.bytesize < 4
|
|
125
|
+
bytes.unpack1("N")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Raises ChecksumError unless the stored checksum matches the header.
|
|
129
|
+
# libtcd refuses to open such a file.
|
|
130
|
+
def verify_checksum!(io)
|
|
131
|
+
stored = stored_checksum(io)
|
|
132
|
+
computed = compute_checksum(io)
|
|
133
|
+
return if stored == computed
|
|
134
|
+
|
|
135
|
+
raise ChecksumError, format("header checksum mismatch: stored 0x%08x, computed 0x%08x " \
|
|
136
|
+
"(the ASCII header was modified or the file is corrupt)", stored, computed)
|
|
137
|
+
end
|
|
138
|
+
|
|
107
139
|
private
|
|
108
140
|
|
|
109
141
|
def parse(io)
|
|
@@ -146,4 +178,5 @@ module TCD
|
|
|
146
178
|
end
|
|
147
179
|
|
|
148
180
|
class FormatError < StandardError; end
|
|
181
|
+
class ChecksumError < FormatError; end
|
|
149
182
|
end
|
data/lib/tcd/reader.rb
CHANGED
|
@@ -12,8 +12,11 @@ module TCD
|
|
|
12
12
|
class Reader
|
|
13
13
|
attr_reader :path, :header, :lookup_tables, :constituent_data
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# verify_checksum: false skips the header CRC-32 check (on by default, as
|
|
16
|
+
# in libtcd). Only for files known to carry no valid checksum.
|
|
17
|
+
def initialize(path, verify_checksum: true)
|
|
16
18
|
@path = path
|
|
19
|
+
@verify_checksum = verify_checksum
|
|
17
20
|
@file = File.open(path, "rb")
|
|
18
21
|
@stations_loaded = false
|
|
19
22
|
@stations = []
|
|
@@ -193,6 +196,7 @@ module TCD
|
|
|
193
196
|
def load_metadata
|
|
194
197
|
# Parse ASCII header
|
|
195
198
|
@header = Header.new(@file)
|
|
199
|
+
verify_checksum if @verify_checksum
|
|
196
200
|
|
|
197
201
|
# Load lookup tables (handles interleaved string tables and binary data)
|
|
198
202
|
# This also identifies the constituent data offset and station records offset
|
|
@@ -207,6 +211,13 @@ module TCD
|
|
|
207
211
|
@stations_offset = @lookup_tables.station_records_offset
|
|
208
212
|
end
|
|
209
213
|
|
|
214
|
+
def verify_checksum
|
|
215
|
+
@header.verify_checksum!(@file)
|
|
216
|
+
rescue FormatError
|
|
217
|
+
close
|
|
218
|
+
raise
|
|
219
|
+
end
|
|
220
|
+
|
|
210
221
|
def load_stations
|
|
211
222
|
return if @stations_loaded
|
|
212
223
|
|
data/lib/tcd/version.rb
CHANGED
data/lib/tcd/writer.rb
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module TCD
|
|
6
|
+
# Writes a TCD (Tidal Constituent Database) file in the layout libtcd 2.2.7 writes
|
|
7
|
+
# and reads (tide_db.c write_tide_db_header and pack_tide_record):
|
|
8
|
+
#
|
|
9
|
+
# 1. ASCII header ([KEY] = VALUE lines), padded with NULs to [HEADER SIZE]
|
|
10
|
+
# 2. CRC-32 of the padded header, 4 bytes big-endian
|
|
11
|
+
# 3. String tables: level units, direction units, restrictions, time zones,
|
|
12
|
+
# countries, datums, legalese (the last five padded to 2^bits entries and
|
|
13
|
+
# ended by "__END__"), constituent names
|
|
14
|
+
# 4. Speeds, equilibrium arguments, node factors (bit-packed, each table
|
|
15
|
+
# starting on a byte boundary)
|
|
16
|
+
# 5. Station records (bit-packed, each a whole number of bytes)
|
|
17
|
+
#
|
|
18
|
+
# Values use the same units as the reader: degrees, degrees per hour, time
|
|
19
|
+
# offsets in minutes, nil for "no direction" and "no slack time", station time
|
|
20
|
+
# zones with or without the leading ':' (a new table entry always gets it).
|
|
21
|
+
# Strings are stored as ISO-8859-1. A value that does not fit its field raises
|
|
22
|
+
# ArgumentError; nothing is truncated.
|
|
23
|
+
#
|
|
24
|
+
# Checks follow libtcd's check_tide_record, so libtcd and XTide read the file
|
|
25
|
+
# as written:
|
|
26
|
+
# - zone_offset is a signed HHMM integer (-500 is UTC-5, 530 is UTC+5:30),
|
|
27
|
+
# not minutes; its minutes part must be under 60.
|
|
28
|
+
# - Epochs must be 0..360 degrees; dates must be 0 or YYYYMMDD.
|
|
29
|
+
# - Integer fields take an Integer or a whole Float (10.0), never a fraction.
|
|
30
|
+
# - Strings may not contain NUL; table entries may not be "__END__"; header
|
|
31
|
+
# text (version, last_modified) may not hold control characters or brackets.
|
|
32
|
+
# - Level units, direction units, restrictions and legalese hold at most 256
|
|
33
|
+
# entries; time zones, countries and datums at most 32,768. The direction
|
|
34
|
+
# units table must hold "degrees true" after its first entry.
|
|
35
|
+
# - Every bit width is at most 31. When the speeds span too much for libtcd's
|
|
36
|
+
# speed scale of 10^7, the writer uses the largest power of ten that fits.
|
|
37
|
+
# - A subordinate's reference_station must index a reference record.
|
|
38
|
+
#
|
|
39
|
+
# writer = TCD::Writer.new(constituents: [...], start_year: 2020, number_of_years: 3)
|
|
40
|
+
# writer.add_station(station) # a TCD::Station or a Hash with the same keys
|
|
41
|
+
# writer.write("out.tcd")
|
|
42
|
+
#
|
|
43
|
+
# TCD::Writer.from_reader(reader).write("copy.tcd")
|
|
44
|
+
class Writer
|
|
45
|
+
LIBTCD_VERSION = "PFM Software - libtcd v2.2.7 - 2015-08-09"
|
|
46
|
+
MAJOR_REV = 2
|
|
47
|
+
MINOR_REV = 2
|
|
48
|
+
|
|
49
|
+
ONELINER_LENGTH = 90 # bytes, including the NUL
|
|
50
|
+
MONOLOGUE_LENGTH = 10_000 # bytes, including the NUL
|
|
51
|
+
MAX_CONSTITUENTS = 255
|
|
52
|
+
AMPLITUDE_EPSILON = 0.00005
|
|
53
|
+
NULL_SLACK_OFFSET = 0xA00
|
|
54
|
+
NULL_DIRECTION = 361
|
|
55
|
+
END_MARKER = "__END__"
|
|
56
|
+
INT32_MIN = -(2**31)
|
|
57
|
+
INT32_MAX = 2**31 - 1
|
|
58
|
+
MAX_BITS = 31 # libtcd packs every field from a signed 32-bit int
|
|
59
|
+
|
|
60
|
+
# Most table entries libtcd can index (tcd.h TIDE_RECORD): level units,
|
|
61
|
+
# direction units, restrictions and legalese are NV_U_BYTE; time zones,
|
|
62
|
+
# countries and datums are NV_INT16, and libtcd asserts tzfile >= 0.
|
|
63
|
+
TABLE_LIMITS = {
|
|
64
|
+
level_unit: 256, direction_unit: 256, restriction: 256, legalese: 256,
|
|
65
|
+
tzfile: 32_768, country: 32_768, datum: 32_768
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# libtcd tide_db_default.h
|
|
69
|
+
DEFAULTS = {
|
|
70
|
+
header_size: 4096,
|
|
71
|
+
speed_scale: 10_000_000, equilibrium_scale: 100, node_scale: 10_000,
|
|
72
|
+
amplitude_bits: 19, amplitude_scale: 10_000,
|
|
73
|
+
epoch_bits: 16, epoch_scale: 100,
|
|
74
|
+
record_type_bits: 4,
|
|
75
|
+
latitude_bits: 25, latitude_scale: 100_000,
|
|
76
|
+
longitude_bits: 26, longitude_scale: 100_000,
|
|
77
|
+
record_size_bits: 16,
|
|
78
|
+
station_bits: 18,
|
|
79
|
+
datum_offset_bits: 28, datum_offset_scale: 10_000,
|
|
80
|
+
date_bits: 27,
|
|
81
|
+
months_on_station_bits: 10,
|
|
82
|
+
confidence_value_bits: 4,
|
|
83
|
+
time_bits: 13,
|
|
84
|
+
level_add_bits: 17, level_add_scale: 1000,
|
|
85
|
+
level_multiply_bits: 16, level_multiply_scale: 1000,
|
|
86
|
+
direction_bits: 9,
|
|
87
|
+
constituent_size: 10,
|
|
88
|
+
level_unit_size: 15, direction_unit_size: 15,
|
|
89
|
+
restriction_bits: 4, restriction_size: 30,
|
|
90
|
+
tzfile_bits: 10, tzfile_size: 30,
|
|
91
|
+
country_bits: 9, country_size: 50,
|
|
92
|
+
datum_bits: 7, datum_size: 70,
|
|
93
|
+
legalese_bits: 4, legalese_size: 70
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
# Header parameters a caller (or from_reader) may set. Counts and the
|
|
97
|
+
# bit widths that follow from counts are always derived from the data.
|
|
98
|
+
PARAMETER_KEYS = (DEFAULTS.keys + %i[
|
|
99
|
+
speed_bits speed_offset equilibrium_bits equilibrium_offset node_bits node_offset
|
|
100
|
+
constituent_bits level_unit_bits direction_unit_bits
|
|
101
|
+
]).freeze
|
|
102
|
+
|
|
103
|
+
DEFAULT_LEVEL_UNITS = ["Unknown", "feet", "meters", "knots", "knots^2"].freeze
|
|
104
|
+
DEFAULT_DIRECTION_UNITS = ["Unknown", "degrees true", "degrees"].freeze
|
|
105
|
+
DEFAULT_RESTRICTIONS = ["Public Domain", "DoD/DoD Contractors Only"].freeze
|
|
106
|
+
DEFAULT_LEGALESES = ["NULL"].freeze
|
|
107
|
+
|
|
108
|
+
attr_reader :stations, :constituents, :params
|
|
109
|
+
|
|
110
|
+
# Builds a writer holding everything a Reader read: header parameters,
|
|
111
|
+
# tables, constituents and stations, in the same order.
|
|
112
|
+
def self.from_reader(reader)
|
|
113
|
+
lt = reader.lookup_tables
|
|
114
|
+
h = reader.header
|
|
115
|
+
writer = new(
|
|
116
|
+
constituents: reader.constituents.to_a,
|
|
117
|
+
start_year: h.start_year,
|
|
118
|
+
number_of_years: h.number_of_years,
|
|
119
|
+
version: h.version,
|
|
120
|
+
last_modified: h.last_modified,
|
|
121
|
+
params: h.params.select { |k, _| PARAMETER_KEYS.include?(k) },
|
|
122
|
+
level_units: lt.level_units,
|
|
123
|
+
direction_units: lt.direction_units,
|
|
124
|
+
restrictions: lt.restrictions,
|
|
125
|
+
timezones: lt.timezones,
|
|
126
|
+
countries: lt.countries,
|
|
127
|
+
datums: lt.datums,
|
|
128
|
+
legaleses: lt.legalese
|
|
129
|
+
)
|
|
130
|
+
offset = lt.station_records_offset
|
|
131
|
+
File.open(reader.path, "rb") do |io|
|
|
132
|
+
reader.stations.each do |station|
|
|
133
|
+
writer.add_station(writer.send(:restore_null_multipliers, station, io, offset))
|
|
134
|
+
offset += station.record_size
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
writer
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# constituents: objects or Hashes with name, speed, equilibrium (one per
|
|
141
|
+
# year) and node_factors (one per year), e.g. TCD::Constituent.
|
|
142
|
+
# params: header parameters (bit widths, scales, offsets, sizes); missing
|
|
143
|
+
# ones come from the libtcd defaults or, for speeds, equilibrium
|
|
144
|
+
# arguments and node factors, from the data range as libtcd computes them.
|
|
145
|
+
# level_units ... legaleses: the string tables. Stations may also name
|
|
146
|
+
# strings that are not in them; those are appended.
|
|
147
|
+
def initialize(constituents:, start_year:, number_of_years: nil, version: nil, last_modified: nil,
|
|
148
|
+
params: {}, level_units: DEFAULT_LEVEL_UNITS, direction_units: DEFAULT_DIRECTION_UNITS,
|
|
149
|
+
restrictions: DEFAULT_RESTRICTIONS, timezones: [], countries: ["Unknown"],
|
|
150
|
+
datums: ["Unknown"], legaleses: DEFAULT_LEGALESES)
|
|
151
|
+
@constituents = constituents.map { |c| normalize_constituent(c) }
|
|
152
|
+
raise ArgumentError, "at least one constituent is required" if @constituents.empty?
|
|
153
|
+
if @constituents.size > MAX_CONSTITUENTS
|
|
154
|
+
raise ArgumentError, "#{@constituents.size} constituents; libtcd allows at most #{MAX_CONSTITUENTS}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@start_year = int_value(start_year, "start_year")
|
|
158
|
+
unless @start_year.between?(INT32_MIN, INT32_MAX)
|
|
159
|
+
raise ArgumentError, "start_year #{@start_year} is outside libtcd's 32-bit range"
|
|
160
|
+
end
|
|
161
|
+
@number_of_years = int_value(number_of_years || @constituents.first[:equilibrium].size, "number_of_years")
|
|
162
|
+
unless @number_of_years.between?(1, INT32_MAX)
|
|
163
|
+
raise ArgumentError, "number_of_years must be at least 1, got #{@number_of_years}"
|
|
164
|
+
end
|
|
165
|
+
@constituents.each do |c|
|
|
166
|
+
[:equilibrium, :node_factors].each do |key|
|
|
167
|
+
next if c[key].size == @number_of_years
|
|
168
|
+
raise ArgumentError, "constituent #{c[:name]}: #{c[key].size} #{key} values for #{@number_of_years} years"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
@version = header_text(version || LIBTCD_VERSION, "version")
|
|
172
|
+
@last_modified = last_modified && header_text(last_modified, "last_modified")
|
|
173
|
+
@given_params = params.transform_keys(&:to_sym)
|
|
174
|
+
unknown = @given_params.keys - PARAMETER_KEYS
|
|
175
|
+
raise ArgumentError, "unknown header parameters: #{unknown.join(', ')}" unless unknown.empty?
|
|
176
|
+
@multiply_scale = int_value(@given_params.fetch(:level_multiply_scale, DEFAULTS[:level_multiply_scale]),
|
|
177
|
+
"header parameter level_multiply_scale")
|
|
178
|
+
@tables = {
|
|
179
|
+
level_unit: level_units.dup,
|
|
180
|
+
direction_unit: direction_units.dup,
|
|
181
|
+
restriction: restrictions.dup,
|
|
182
|
+
tzfile: timezones.dup,
|
|
183
|
+
country: countries.dup,
|
|
184
|
+
datum: datums.dup,
|
|
185
|
+
legalese: legaleses.dup
|
|
186
|
+
}
|
|
187
|
+
@stations = []
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Adds a station record (TCD::Station or a Hash with the Station keys).
|
|
191
|
+
# Subordinate stations name their reference by record index
|
|
192
|
+
# (reference_station), so add them in the order they will be numbered.
|
|
193
|
+
def add_station(station)
|
|
194
|
+
@stations << station
|
|
195
|
+
self
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Writes the file and returns its size in bytes.
|
|
199
|
+
def write(path)
|
|
200
|
+
bytes = to_bytes
|
|
201
|
+
File.binwrite(path, bytes)
|
|
202
|
+
bytes.bytesize
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The whole file as a binary String.
|
|
206
|
+
def to_bytes
|
|
207
|
+
records = @stations.map { |s| index_strings(s) }
|
|
208
|
+
@params = compute_params
|
|
209
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
210
|
+
body << tables_bytes
|
|
211
|
+
body << constituent_bytes
|
|
212
|
+
records.each_with_index do |r, i|
|
|
213
|
+
if r[:record_type] == 2
|
|
214
|
+
ref = r[:reference_station]
|
|
215
|
+
unless ref.between?(0, records.size - 1)
|
|
216
|
+
raise ArgumentError, "#{r[:name]}: reference_station #{ref} is not a record index (0..#{records.size - 1})"
|
|
217
|
+
end
|
|
218
|
+
# XTide asserts when a subordinate's reference is not a reference record
|
|
219
|
+
unless records[ref][:record_type] == 1
|
|
220
|
+
raise ArgumentError, "#{r[:name]}: reference_station #{ref} is not a reference (type 1) record"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
body << pack_station(r, i).first
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
end_of_file = @params[:header_size] + 4 + body.bytesize
|
|
227
|
+
raise ArgumentError, "file would be #{end_of_file} bytes, over libtcd's 32-bit limit" if end_of_file > INT32_MAX
|
|
228
|
+
|
|
229
|
+
header = header_bytes(end_of_file)
|
|
230
|
+
header + [Zlib.crc32(header)].pack("N") + body
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------
|
|
236
|
+
# Header
|
|
237
|
+
|
|
238
|
+
def header_bytes(end_of_file)
|
|
239
|
+
p = @params
|
|
240
|
+
n = @tables
|
|
241
|
+
lines = [
|
|
242
|
+
["VERSION", @version],
|
|
243
|
+
["MAJOR REV", MAJOR_REV],
|
|
244
|
+
["MINOR REV", MINOR_REV],
|
|
245
|
+
["LAST MODIFIED", @last_modified || Time.now.strftime("%Y-%m-%d %H:%M %Z")],
|
|
246
|
+
["HEADER SIZE", p[:header_size]],
|
|
247
|
+
["NUMBER OF RECORDS", @stations.size],
|
|
248
|
+
["START YEAR", @start_year],
|
|
249
|
+
["NUMBER OF YEARS", @number_of_years],
|
|
250
|
+
["SPEED BITS", p[:speed_bits]],
|
|
251
|
+
["SPEED SCALE", p[:speed_scale]],
|
|
252
|
+
["SPEED OFFSET", p[:speed_offset]],
|
|
253
|
+
["EQUILIBRIUM BITS", p[:equilibrium_bits]],
|
|
254
|
+
["EQUILIBRIUM SCALE", p[:equilibrium_scale]],
|
|
255
|
+
["EQUILIBRIUM OFFSET", p[:equilibrium_offset]],
|
|
256
|
+
["NODE BITS", p[:node_bits]],
|
|
257
|
+
["NODE SCALE", p[:node_scale]],
|
|
258
|
+
["NODE OFFSET", p[:node_offset]],
|
|
259
|
+
["AMPLITUDE BITS", p[:amplitude_bits]],
|
|
260
|
+
["AMPLITUDE SCALE", p[:amplitude_scale]],
|
|
261
|
+
["EPOCH BITS", p[:epoch_bits]],
|
|
262
|
+
["EPOCH SCALE", p[:epoch_scale]],
|
|
263
|
+
["RECORD TYPE BITS", p[:record_type_bits]],
|
|
264
|
+
["LATITUDE BITS", p[:latitude_bits]],
|
|
265
|
+
["LATITUDE SCALE", p[:latitude_scale]],
|
|
266
|
+
["LONGITUDE BITS", p[:longitude_bits]],
|
|
267
|
+
["LONGITUDE SCALE", p[:longitude_scale]],
|
|
268
|
+
["RECORD SIZE BITS", p[:record_size_bits]],
|
|
269
|
+
["STATION BITS", p[:station_bits]],
|
|
270
|
+
["DATUM OFFSET BITS", p[:datum_offset_bits]],
|
|
271
|
+
["DATUM OFFSET SCALE", p[:datum_offset_scale]],
|
|
272
|
+
["DATE BITS", p[:date_bits]],
|
|
273
|
+
["MONTHS ON STATION BITS", p[:months_on_station_bits]],
|
|
274
|
+
["CONFIDENCE VALUE BITS", p[:confidence_value_bits]],
|
|
275
|
+
["TIME BITS", p[:time_bits]],
|
|
276
|
+
["LEVEL ADD BITS", p[:level_add_bits]],
|
|
277
|
+
["LEVEL ADD SCALE", p[:level_add_scale]],
|
|
278
|
+
["LEVEL MULTIPLY BITS", p[:level_multiply_bits]],
|
|
279
|
+
["LEVEL MULTIPLY SCALE", p[:level_multiply_scale]],
|
|
280
|
+
["DIRECTION BITS", p[:direction_bits]],
|
|
281
|
+
["LEVEL UNIT BITS", p[:level_unit_bits]],
|
|
282
|
+
["LEVEL UNIT TYPES", n[:level_unit].size],
|
|
283
|
+
["LEVEL UNIT SIZE", p[:level_unit_size]],
|
|
284
|
+
["DIRECTION UNIT BITS", p[:direction_unit_bits]],
|
|
285
|
+
["DIRECTION UNIT TYPES", n[:direction_unit].size],
|
|
286
|
+
["DIRECTION UNIT SIZE", p[:direction_unit_size]],
|
|
287
|
+
["RESTRICTION BITS", p[:restriction_bits]],
|
|
288
|
+
["RESTRICTION TYPES", n[:restriction].size],
|
|
289
|
+
["RESTRICTION SIZE", p[:restriction_size]],
|
|
290
|
+
["DATUM BITS", p[:datum_bits]],
|
|
291
|
+
["DATUM TYPES", n[:datum].size],
|
|
292
|
+
["DATUM SIZE", p[:datum_size]],
|
|
293
|
+
["LEGALESE BITS", p[:legalese_bits]],
|
|
294
|
+
["LEGALESE TYPES", n[:legalese].size],
|
|
295
|
+
["LEGALESE SIZE", p[:legalese_size]],
|
|
296
|
+
["CONSTITUENT BITS", p[:constituent_bits]],
|
|
297
|
+
["CONSTITUENTS", @constituents.size],
|
|
298
|
+
["CONSTITUENT SIZE", p[:constituent_size]],
|
|
299
|
+
["TZFILE BITS", p[:tzfile_bits]],
|
|
300
|
+
["TZFILES", n[:tzfile].size],
|
|
301
|
+
["TZFILE SIZE", p[:tzfile_size]],
|
|
302
|
+
["COUNTRY BITS", p[:country_bits]],
|
|
303
|
+
["COUNTRIES", n[:country].size],
|
|
304
|
+
["COUNTRY SIZE", p[:country_size]],
|
|
305
|
+
["END OF FILE", end_of_file]
|
|
306
|
+
]
|
|
307
|
+
text = String.new(encoding: Encoding::BINARY)
|
|
308
|
+
lines.each do |key, value|
|
|
309
|
+
line = latin1("[#{key}] = #{value}\n", key)
|
|
310
|
+
# libtcd reads header lines into a ONELINER_LENGTH buffer
|
|
311
|
+
if line.bytesize > ONELINER_LENGTH - 2
|
|
312
|
+
raise ArgumentError, "header line too long for libtcd: #{line.strip}"
|
|
313
|
+
end
|
|
314
|
+
text << line
|
|
315
|
+
end
|
|
316
|
+
text << "[END OF ASCII HEADER DATA]\n"
|
|
317
|
+
if text.bytesize > @params[:header_size]
|
|
318
|
+
raise ArgumentError, "ASCII header is #{text.bytesize} bytes, over header_size #{@params[:header_size]}"
|
|
319
|
+
end
|
|
320
|
+
text + ("\0" * (@params[:header_size] - text.bytesize))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Header parameters: given ones, else derived from the data as libtcd's
|
|
324
|
+
# create_tide_db does, else the libtcd defaults.
|
|
325
|
+
def compute_params
|
|
326
|
+
given = @given_params.to_h { |k, v| [k, int_value(v, "header parameter #{k}")] }
|
|
327
|
+
given.each do |k, v|
|
|
328
|
+
ok = case k.to_s
|
|
329
|
+
when /_bits\z/ then v.between?(1, MAX_BITS)
|
|
330
|
+
when /_offset\z/ then v.between?(0, INT32_MAX)
|
|
331
|
+
else v.between?(1, INT32_MAX) # scales, sizes, header_size
|
|
332
|
+
end
|
|
333
|
+
next if ok
|
|
334
|
+
|
|
335
|
+
raise ArgumentError, "header parameter #{k} = #{v} is out of range " \
|
|
336
|
+
"(bit widths 1..#{MAX_BITS}, scales and sizes >= 1, offsets >= 0)"
|
|
337
|
+
end
|
|
338
|
+
p = DEFAULTS.merge(given)
|
|
339
|
+
|
|
340
|
+
speeds = @constituents.map { |c| c[:speed] }
|
|
341
|
+
if speeds.any?(&:negative?)
|
|
342
|
+
raise ArgumentError, "negative constituent speed"
|
|
343
|
+
end
|
|
344
|
+
if @constituents.any? { |c| c[:equilibrium].any?(&:negative?) }
|
|
345
|
+
raise ArgumentError, "equilibrium arguments must be >= 0 (libtcd reads a negative offset as a " \
|
|
346
|
+
"corrupt pre-1.11 file)"
|
|
347
|
+
end
|
|
348
|
+
if @constituents.any? { |c| c[:node_factors].any? { |f| f <= 0 } }
|
|
349
|
+
raise ArgumentError, "node factors must be positive"
|
|
350
|
+
end
|
|
351
|
+
# libtcd's create_tide_db scale is 10^7, asserting the width stays under
|
|
352
|
+
# 32 bits; when the speed range is too wide for that, use the largest
|
|
353
|
+
# power of ten that fits.
|
|
354
|
+
unless given.key?(:speed_scale)
|
|
355
|
+
p[:speed_scale] /= 10 while p[:speed_scale] > 1 &&
|
|
356
|
+
range_bits(speeds, p[:speed_scale], given[:speed_offset]) > MAX_BITS
|
|
357
|
+
end
|
|
358
|
+
derive_range(p, :speed, speeds)
|
|
359
|
+
derive_range(p, :equilibrium, @constituents.flat_map { |c| c[:equilibrium] })
|
|
360
|
+
derive_range(p, :node, @constituents.flat_map { |c| c[:node_factors] })
|
|
361
|
+
|
|
362
|
+
check_table_limits
|
|
363
|
+
# A reference record stores the count of its constituents in
|
|
364
|
+
# constituent_bits, so the count itself must fit.
|
|
365
|
+
p[:constituent_bits] = [p[:constituent_bits] || 0, calculate_bits(@constituents.size)].max
|
|
366
|
+
# libtcd gives a one-entry unit table a 0-bit field, which the gem's
|
|
367
|
+
# reader cannot read; at least 1 bit is still valid for libtcd.
|
|
368
|
+
p[:level_unit_bits] = [p[:level_unit_bits] || 0, calculate_bits(@tables[:level_unit].size - 1), 1].max
|
|
369
|
+
p[:direction_unit_bits] = [p[:direction_unit_bits] || 0,
|
|
370
|
+
calculate_bits(@tables[:direction_unit].size - 1), 1].max
|
|
371
|
+
# The padded tables need room for "__END__" after the last entry.
|
|
372
|
+
%i[restriction tzfile country datum legalese].each do |t|
|
|
373
|
+
key = :"#{t}_bits"
|
|
374
|
+
p[key] = [p[key], calculate_bits(@tables[t].size), 1].max
|
|
375
|
+
end
|
|
376
|
+
# reference_station is signed and holds record indexes
|
|
377
|
+
p[:station_bits] = [p[:station_bits], calculate_bits(@stations.size) + 1].max
|
|
378
|
+
p.each do |k, v|
|
|
379
|
+
next unless k.to_s.end_with?("_bits") && v > MAX_BITS
|
|
380
|
+
|
|
381
|
+
raise ArgumentError, "#{k} would be #{v}; libtcd allows at most #{MAX_BITS}"
|
|
382
|
+
end
|
|
383
|
+
p
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Bits needed for values at scale (offset from the minimum unless given).
|
|
387
|
+
def range_bits(values, scale, offset = nil)
|
|
388
|
+
offset ||= nint(values.min * scale)
|
|
389
|
+
calculate_bits([nint(values.max * scale) - offset, 0].max)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Offset and width for one bit-packed constituent table, from the data
|
|
393
|
+
# range unless given.
|
|
394
|
+
def derive_range(p, name, values)
|
|
395
|
+
scale = p[:"#{name}_scale"]
|
|
396
|
+
p[:"#{name}_offset"] ||= nint(values.min * scale)
|
|
397
|
+
if p[:"#{name}_offset"].negative? || p[:"#{name}_offset"] > INT32_MAX
|
|
398
|
+
raise ArgumentError, "#{name} offset #{p[:"#{name}_offset"]} is outside 0..#{INT32_MAX}"
|
|
399
|
+
end
|
|
400
|
+
bits = p[:"#{name}_bits"] ||= [range_bits(values, scale, p[:"#{name}_offset"]), 1].max
|
|
401
|
+
return if bits <= MAX_BITS
|
|
402
|
+
|
|
403
|
+
raise ArgumentError, "#{name} values #{values.min}..#{values.max} need #{bits} bits at scale #{scale}; " \
|
|
404
|
+
"libtcd allows at most #{MAX_BITS}"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# libtcd's per-table limits, and the "degrees true" entry it looks up
|
|
408
|
+
# (unpack_tide_record asserts find_dir_units("degrees true") > 0).
|
|
409
|
+
def check_table_limits
|
|
410
|
+
TABLE_LIMITS.each do |t, max|
|
|
411
|
+
next if @tables[t].size <= max
|
|
412
|
+
|
|
413
|
+
raise ArgumentError, "#{@tables[t].size} #{t} table entries; libtcd allows at most #{max}"
|
|
414
|
+
end
|
|
415
|
+
idx = @tables[:direction_unit].index("degrees true")
|
|
416
|
+
return if idx&.positive?
|
|
417
|
+
|
|
418
|
+
raise ArgumentError, "the direction units table must contain \"degrees true\" after its first entry " \
|
|
419
|
+
"(libtcd asserts on it); got #{@tables[:direction_unit].inspect}"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------
|
|
423
|
+
# Tables
|
|
424
|
+
|
|
425
|
+
def tables_bytes
|
|
426
|
+
p = @params
|
|
427
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
428
|
+
out << exact_table(@tables[:level_unit], p[:level_unit_size], "level unit")
|
|
429
|
+
out << exact_table(@tables[:direction_unit], p[:direction_unit_size], "direction unit")
|
|
430
|
+
out << end_table(@tables[:restriction], p[:restriction_bits], p[:restriction_size], "restriction")
|
|
431
|
+
out << end_table(@tables[:tzfile], p[:tzfile_bits], p[:tzfile_size], "time zone")
|
|
432
|
+
out << end_table(@tables[:country], p[:country_bits], p[:country_size], "country")
|
|
433
|
+
out << end_table(@tables[:datum], p[:datum_bits], p[:datum_size], "datum")
|
|
434
|
+
out << end_table(@tables[:legalese], p[:legalese_bits], p[:legalese_size], "legalese")
|
|
435
|
+
out << exact_table(@constituents.map { |c| c[:name] }, p[:constituent_size], "constituent name")
|
|
436
|
+
out
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def exact_table(entries, size, what)
|
|
440
|
+
entries.map { |e| table_entry(e, size, what) }.join
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def end_table(entries, bits, size, what)
|
|
444
|
+
max = 1 << bits
|
|
445
|
+
if entries.size >= max
|
|
446
|
+
raise ArgumentError, "#{entries.size} #{what} entries do not fit #{bits} bits with the end marker"
|
|
447
|
+
end
|
|
448
|
+
out = entries.map { |e| table_entry(e, size, what) }.join
|
|
449
|
+
out << table_entry(END_MARKER, size, :end_marker)
|
|
450
|
+
out << ("\0" * ((max - entries.size - 1) * size))
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def table_entry(value, size, what)
|
|
454
|
+
if value == END_MARKER && what != :end_marker
|
|
455
|
+
raise ArgumentError, "#{what} entry #{END_MARKER.inspect} would end the table early in libtcd and the reader"
|
|
456
|
+
end
|
|
457
|
+
bytes = latin1(value, what)
|
|
458
|
+
if bytes.bytesize + 1 > size
|
|
459
|
+
raise ArgumentError, "#{what} #{value.inspect} is #{bytes.bytesize} bytes; the table holds #{size - 1}"
|
|
460
|
+
end
|
|
461
|
+
bytes + ("\0" * (size - bytes.bytesize))
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# ---------------------------------------------------------------
|
|
465
|
+
# Constituent data
|
|
466
|
+
|
|
467
|
+
def constituent_bytes
|
|
468
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
469
|
+
out << pack_table(@constituents.map { |c| c[:speed] }, :speed)
|
|
470
|
+
out << pack_table(@constituents.flat_map { |c| c[:equilibrium] }, :equilibrium)
|
|
471
|
+
out << pack_table(@constituents.flat_map { |c| c[:node_factors] }, :node)
|
|
472
|
+
out
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def pack_table(values, name)
|
|
476
|
+
p = @params
|
|
477
|
+
bits = BitPacker.new
|
|
478
|
+
values.each do |v|
|
|
479
|
+
bits.write_uint(nint(v * p[:"#{name}_scale"]) - p[:"#{name}_offset"], p[:"#{name}_bits"], name)
|
|
480
|
+
end
|
|
481
|
+
bits.bytes
|
|
482
|
+
rescue RangeError => e
|
|
483
|
+
raise ArgumentError, "#{name} table: #{e.message} (scale #{p[:"#{name}_scale"]}, " \
|
|
484
|
+
"offset #{p[:"#{name}_offset"]})"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------
|
|
488
|
+
# Station records
|
|
489
|
+
|
|
490
|
+
# Station values with table strings replaced by indexes, time offsets as
|
|
491
|
+
# libtcd's signed HHMM, and nil replaced by libtcd's null values.
|
|
492
|
+
def index_strings(s)
|
|
493
|
+
label = field(s, :name).to_s
|
|
494
|
+
type = int_value(field(s, :record_type) || (field(s, :amplitudes) ? 1 : 2), "#{label}: record_type")
|
|
495
|
+
raise ArgumentError, "#{label}: record_type must be 1 (reference) or 2 (subordinate), got #{type}" unless [1, 2].include?(type)
|
|
496
|
+
|
|
497
|
+
r = {
|
|
498
|
+
record_type: type,
|
|
499
|
+
latitude: float_value(field(s, :latitude), "#{label}: latitude"),
|
|
500
|
+
longitude: float_value(field(s, :longitude), "#{label}: longitude"),
|
|
501
|
+
tzfile: tz_index(field(s, :tzfile)),
|
|
502
|
+
name: clip(field(s, :name)),
|
|
503
|
+
reference_station: type == 1 ? -1 : int_value(field(s, :reference_station), "#{label}: reference_station"),
|
|
504
|
+
country: table_index(:country, field(s, :country) || "Unknown"),
|
|
505
|
+
source: clip(field(s, :source)),
|
|
506
|
+
restriction: table_index(:restriction, field(s, :restriction) || @tables[:restriction].first),
|
|
507
|
+
comments: clip(field(s, :comments)),
|
|
508
|
+
notes: clip(field(s, :notes)),
|
|
509
|
+
legalese: table_index(:legalese, field(s, :legalese) || @tables[:legalese].first),
|
|
510
|
+
station_id_context: clip(field(s, :station_id_context)),
|
|
511
|
+
station_id: clip(field(s, :station_id)),
|
|
512
|
+
date_imported: date_value(field(s, :date_imported), "#{label}: date_imported"),
|
|
513
|
+
xfields: field(s, :xfields).to_s,
|
|
514
|
+
direction_units: table_index(:direction_unit, field(s, :direction_units) || "degrees true"),
|
|
515
|
+
min_direction: direction_value(field(s, :min_direction), "#{label}: min_direction"),
|
|
516
|
+
max_direction: direction_value(field(s, :max_direction), "#{label}: max_direction"),
|
|
517
|
+
level_units: table_index(:level_unit, field(s, :level_units) || "Unknown")
|
|
518
|
+
}
|
|
519
|
+
raise ArgumentError, "station has no name" if r[:name].empty?
|
|
520
|
+
raise ArgumentError, "#{r[:name]}: latitude out of range" unless r[:latitude].between?(-90, 90)
|
|
521
|
+
raise ArgumentError, "#{r[:name]}: longitude out of range" unless r[:longitude].between?(-180, 180)
|
|
522
|
+
check_length(r, :name, ONELINER_LENGTH)
|
|
523
|
+
check_length(r, :source, ONELINER_LENGTH)
|
|
524
|
+
check_length(r, :station_id_context, ONELINER_LENGTH)
|
|
525
|
+
check_length(r, :station_id, ONELINER_LENGTH)
|
|
526
|
+
check_length(r, :comments, MONOLOGUE_LENGTH)
|
|
527
|
+
check_length(r, :notes, MONOLOGUE_LENGTH)
|
|
528
|
+
check_length(r, :xfields, MONOLOGUE_LENGTH)
|
|
529
|
+
|
|
530
|
+
if type == 1
|
|
531
|
+
n = @constituents.size
|
|
532
|
+
amplitudes = field(s, :amplitudes) || []
|
|
533
|
+
epochs = field(s, :epochs) || []
|
|
534
|
+
if amplitudes.size > n || epochs.size > n
|
|
535
|
+
raise ArgumentError, "#{r[:name]}: more amplitudes or epochs than the #{n} constituents"
|
|
536
|
+
end
|
|
537
|
+
r.merge!(
|
|
538
|
+
datum_offset: float_value(field(s, :datum_offset) || 0, "#{label}: datum_offset"),
|
|
539
|
+
datum: table_index(:datum, field(s, :datum) || "Unknown"),
|
|
540
|
+
zone_offset: hhmm_value(field(s, :zone_offset), "#{label}: zone_offset"),
|
|
541
|
+
expiration_date: date_value(field(s, :expiration_date), "#{label}: expiration_date"),
|
|
542
|
+
months_on_station: int_value(field(s, :months_on_station) || 0, "#{label}: months_on_station"),
|
|
543
|
+
last_date_on_station: date_value(field(s, :last_date_on_station), "#{label}: last_date_on_station"),
|
|
544
|
+
confidence: int_value(field(s, :confidence) || 0, "#{label}: confidence"),
|
|
545
|
+
amplitudes: Array.new(n) { |i| float_value(amplitudes[i] || 0, "#{label}: amplitude #{i}") },
|
|
546
|
+
epochs: Array.new(n) { |i| float_value(epochs[i] || 0, "#{label}: epoch #{i}") }
|
|
547
|
+
)
|
|
548
|
+
if r[:amplitudes].any?(&:negative?)
|
|
549
|
+
raise ArgumentError, "#{r[:name]}: negative amplitude"
|
|
550
|
+
end
|
|
551
|
+
# libtcd check_tide_record refuses epochs outside 0..360
|
|
552
|
+
bad = r[:epochs].find { |e| !e.between?(0.0, 360.0) }
|
|
553
|
+
raise ArgumentError, "#{r[:name]}: epoch #{bad} is outside 0..360 degrees" if bad
|
|
554
|
+
else
|
|
555
|
+
r.merge!(
|
|
556
|
+
min_time_add: hhmm(field(s, :min_time_add), "#{label}: min_time_add"),
|
|
557
|
+
min_level_add: float_value(field(s, :min_level_add) || 0, "#{label}: min_level_add"),
|
|
558
|
+
min_level_multiply: multiply_value(field(s, :min_level_multiply), "#{label}: min_level_multiply"),
|
|
559
|
+
max_time_add: hhmm(field(s, :max_time_add), "#{label}: max_time_add"),
|
|
560
|
+
max_level_add: float_value(field(s, :max_level_add) || 0, "#{label}: max_level_add"),
|
|
561
|
+
max_level_multiply: multiply_value(field(s, :max_level_multiply), "#{label}: max_level_multiply"),
|
|
562
|
+
flood_begins: (v = field(s, :flood_begins)).nil? ? NULL_SLACK_OFFSET : hhmm(v, "#{label}: flood_begins"),
|
|
563
|
+
ebb_begins: (v = field(s, :ebb_begins)).nil? ? NULL_SLACK_OFFSET : hhmm(v, "#{label}: ebb_begins")
|
|
564
|
+
)
|
|
565
|
+
end
|
|
566
|
+
r
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Packs one record (libtcd pack_tide_record). Returns the bytes and the
|
|
570
|
+
# bit positions of the two level-multiply fields (subordinate records).
|
|
571
|
+
def pack_station(r, number)
|
|
572
|
+
p = @params
|
|
573
|
+
name = r[:name]
|
|
574
|
+
fields = BitPacker.new
|
|
575
|
+
fields.write_uint(0, p[:record_size_bits], "record size") # set by splice_bits
|
|
576
|
+
fields.write_uint(r[:record_type], p[:record_type_bits], "record type")
|
|
577
|
+
fields.write_int(nint(r[:latitude] * p[:latitude_scale]), p[:latitude_bits], "latitude")
|
|
578
|
+
fields.write_int(nint(r[:longitude] * p[:longitude_scale]), p[:longitude_bits], "longitude")
|
|
579
|
+
fields.write_uint(r[:tzfile], p[:tzfile_bits], "tzfile")
|
|
580
|
+
fields.write_string(latin1(name, "name"))
|
|
581
|
+
fields.write_int(r[:reference_station], p[:station_bits], "reference station")
|
|
582
|
+
fields.write_uint(r[:country], p[:country_bits], "country")
|
|
583
|
+
fields.write_string(latin1(r[:source], "source"))
|
|
584
|
+
fields.write_uint(r[:restriction], p[:restriction_bits], "restriction")
|
|
585
|
+
fields.write_string(latin1(r[:comments], "comments"))
|
|
586
|
+
fields.write_string(latin1(r[:notes], "notes"))
|
|
587
|
+
fields.write_uint(r[:legalese], p[:legalese_bits], "legalese")
|
|
588
|
+
fields.write_string(latin1(r[:station_id_context], "station_id_context"))
|
|
589
|
+
fields.write_string(latin1(r[:station_id], "station_id"))
|
|
590
|
+
fields.write_uint(r[:date_imported], p[:date_bits], "date imported")
|
|
591
|
+
fields.write_string(latin1(r[:xfields], "xfields"))
|
|
592
|
+
fields.write_uint(r[:direction_units], p[:direction_unit_bits], "direction units")
|
|
593
|
+
fields.write_uint(r[:min_direction], p[:direction_bits], "min direction")
|
|
594
|
+
fields.write_uint(r[:max_direction], p[:direction_bits], "max direction")
|
|
595
|
+
fields.write_uint(r[:level_units], p[:level_unit_bits], "level units")
|
|
596
|
+
|
|
597
|
+
multiply_positions = nil
|
|
598
|
+
if r[:record_type] == 1
|
|
599
|
+
fields.write_int(nint(r[:datum_offset] * p[:datum_offset_scale]), p[:datum_offset_bits], "datum offset")
|
|
600
|
+
fields.write_uint(r[:datum], p[:datum_bits], "datum")
|
|
601
|
+
fields.write_int(r[:zone_offset], p[:time_bits], "zone offset")
|
|
602
|
+
fields.write_uint(r[:expiration_date], p[:date_bits], "expiration date")
|
|
603
|
+
fields.write_uint(r[:months_on_station], p[:months_on_station_bits], "months on station")
|
|
604
|
+
fields.write_uint(r[:last_date_on_station], p[:date_bits], "last date on station")
|
|
605
|
+
fields.write_uint(r[:confidence], p[:confidence_value_bits], "confidence")
|
|
606
|
+
used = r[:amplitudes].each_index.select { |i| r[:amplitudes][i] >= AMPLITUDE_EPSILON }
|
|
607
|
+
fields.write_uint(used.size, p[:constituent_bits], "constituent count")
|
|
608
|
+
used.each do |i|
|
|
609
|
+
fields.write_uint(i, p[:constituent_bits], "constituent index")
|
|
610
|
+
fields.write_uint(nint(r[:amplitudes][i] * p[:amplitude_scale]), p[:amplitude_bits], "amplitude")
|
|
611
|
+
fields.write_uint(nint(r[:epochs][i] * p[:epoch_scale]), p[:epoch_bits], "epoch")
|
|
612
|
+
end
|
|
613
|
+
else
|
|
614
|
+
fields.write_int(r[:min_time_add], p[:time_bits], "min time add")
|
|
615
|
+
fields.write_int(nint(r[:min_level_add] * p[:level_add_scale]), p[:level_add_bits], "min level add")
|
|
616
|
+
min_pos = fields.bit_length
|
|
617
|
+
fields.write_uint(nint(r[:min_level_multiply] * p[:level_multiply_scale]), p[:level_multiply_bits],
|
|
618
|
+
"min level multiply")
|
|
619
|
+
fields.write_int(r[:max_time_add], p[:time_bits], "max time add")
|
|
620
|
+
fields.write_int(nint(r[:max_level_add] * p[:level_add_scale]), p[:level_add_bits], "max level add")
|
|
621
|
+
max_pos = fields.bit_length
|
|
622
|
+
fields.write_uint(nint(r[:max_level_multiply] * p[:level_multiply_scale]), p[:level_multiply_bits],
|
|
623
|
+
"max level multiply")
|
|
624
|
+
fields.write_int(r[:flood_begins], p[:time_bits], "flood begins")
|
|
625
|
+
fields.write_int(r[:ebb_begins], p[:time_bits], "ebb begins")
|
|
626
|
+
multiply_positions = [min_pos, max_pos]
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
bytes = fields.bytes
|
|
630
|
+
if bytes.bytesize >= (1 << p[:record_size_bits])
|
|
631
|
+
raise ArgumentError, "#{name}: record #{number} is #{bytes.bytesize} bytes, over record_size_bits"
|
|
632
|
+
end
|
|
633
|
+
[splice_bits(bytes, bytes.bytesize, p[:record_size_bits]), multiply_positions]
|
|
634
|
+
rescue RangeError => e
|
|
635
|
+
raise ArgumentError, "#{r[:name]}: #{e.message}"
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Writes the record size into the first bits of the packed record.
|
|
639
|
+
def splice_bits(bytes, size_value, bits)
|
|
640
|
+
n = bytes.unpack1("B*")
|
|
641
|
+
n[0, bits] = size_value.to_s(2).rjust(bits, "0")
|
|
642
|
+
[n].pack("B*")
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# The reader returns 1.0 for a stored level multiply of 0 (XTide's
|
|
646
|
+
# meaning). To copy such a record exactly, read the stored value back
|
|
647
|
+
# from the source file.
|
|
648
|
+
def restore_null_multipliers(station, io, offset)
|
|
649
|
+
return station unless station.respond_to?(:subordinate?) && station.subordinate?
|
|
650
|
+
return station unless station.min_level_multiply == 1.0 || station.max_level_multiply == 1.0
|
|
651
|
+
|
|
652
|
+
@params ||= compute_params
|
|
653
|
+
r = index_strings(station)
|
|
654
|
+
_bytes, positions = pack_station(r, station.record_number)
|
|
655
|
+
io.seek(offset)
|
|
656
|
+
stored = io.read(station.record_size).unpack1("B*")
|
|
657
|
+
bits = @params[:level_multiply_bits]
|
|
658
|
+
copy = station.dup
|
|
659
|
+
copy.min_level_multiply = 0.0 if stored[positions[0], bits].to_i(2).zero?
|
|
660
|
+
copy.max_level_multiply = 0.0 if stored[positions[1], bits].to_i(2).zero?
|
|
661
|
+
copy
|
|
662
|
+
ensure
|
|
663
|
+
@params = nil
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------
|
|
667
|
+
# Helpers
|
|
668
|
+
|
|
669
|
+
def normalize_constituent(c)
|
|
670
|
+
{
|
|
671
|
+
name: field(c, :name).to_s,
|
|
672
|
+
speed: float_value(field(c, :speed), "constituent #{field(c, :name)}: speed"),
|
|
673
|
+
equilibrium: Array(field(c, :equilibrium)).map { |v| float_value(v, "constituent #{field(c, :name)}: equilibrium") },
|
|
674
|
+
node_factors: Array(field(c, :node_factors)).map { |v| float_value(v, "constituent #{field(c, :name)}: node factor") }
|
|
675
|
+
}
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def field(obj, key)
|
|
679
|
+
if obj.is_a?(Hash)
|
|
680
|
+
obj.key?(key) ? obj[key] : obj[key.to_s]
|
|
681
|
+
elsif obj.respond_to?(key)
|
|
682
|
+
obj.public_send(key)
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Index of a string in a table, appending it when absent.
|
|
687
|
+
def table_index(table, value)
|
|
688
|
+
entries = @tables[table]
|
|
689
|
+
idx = entries.index(value)
|
|
690
|
+
return idx if idx
|
|
691
|
+
|
|
692
|
+
entries << value
|
|
693
|
+
entries.size - 1
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Time zones are stored with a leading ':' (":America/New_York"); the reader
|
|
697
|
+
# strips it. Table entries are kept as given ("Unknown" has none).
|
|
698
|
+
def tz_index(tz)
|
|
699
|
+
raise ArgumentError, "station has no time zone" if tz.nil? || tz.to_s.empty?
|
|
700
|
+
|
|
701
|
+
tz = tz.to_s
|
|
702
|
+
@tables[:tzfile].index(tz) || @tables[:tzfile].index(":#{tz}") ||
|
|
703
|
+
table_index(:tzfile, tz.start_with?(":") ? tz : ":#{tz}")
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# libtcd clip_string: drop leading spaces, trailing spaces, CR and LF
|
|
707
|
+
def clip(value)
|
|
708
|
+
value.to_s.sub(/\A +/, "").sub(/[ \r\n]+\z/, "")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def check_length(r, key, limit)
|
|
712
|
+
bytes = latin1(r[key], key.to_s)
|
|
713
|
+
return if bytes.bytesize < limit
|
|
714
|
+
|
|
715
|
+
raise ArgumentError, "#{r[:name]}: #{key} is #{bytes.bytesize} bytes; libtcd allows #{limit - 1}"
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# libtcd strings are NUL-terminated, so a NUL would cut the string short
|
|
719
|
+
# and shift every field after it.
|
|
720
|
+
def latin1(value, what)
|
|
721
|
+
str = value.to_s
|
|
722
|
+
raise ArgumentError, "#{what} #{str.inspect} contains a NUL byte" if str.b.include?("\0")
|
|
723
|
+
|
|
724
|
+
str.encode(Encoding::ISO_8859_1).force_encoding(Encoding::BINARY)
|
|
725
|
+
rescue EncodingError
|
|
726
|
+
raise ArgumentError, "#{what} #{value.to_s.inspect} has characters outside ISO-8859-1"
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# A header value libtcd reads back unchanged: one line, no control
|
|
730
|
+
# characters, no "[" or "]" (libtcd matches "[KEY]" anywhere in a line),
|
|
731
|
+
# no leading or trailing spaces (libtcd strips them).
|
|
732
|
+
def header_text(value, what)
|
|
733
|
+
str = value.to_s
|
|
734
|
+
if str.match?(/[\x00-\x1f\x7f\[\]]/) || str != str.strip
|
|
735
|
+
raise ArgumentError, "#{what} #{str.inspect} has a control character, a bracket, or " \
|
|
736
|
+
"leading/trailing space; libtcd would read the header differently"
|
|
737
|
+
end
|
|
738
|
+
latin1(str, what)
|
|
739
|
+
str
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# An integer field: an Integer, or a Float with no fractional part.
|
|
743
|
+
def int_value(value, what)
|
|
744
|
+
case value
|
|
745
|
+
when Integer
|
|
746
|
+
value
|
|
747
|
+
when Float
|
|
748
|
+
unless value.finite? && value == value.truncate
|
|
749
|
+
raise ArgumentError, "#{what} must be a whole number, got #{value}"
|
|
750
|
+
end
|
|
751
|
+
value.to_i
|
|
752
|
+
when nil
|
|
753
|
+
raise ArgumentError, "#{what} is missing"
|
|
754
|
+
else
|
|
755
|
+
raise ArgumentError, "#{what} must be an integer, got #{value.inspect}"
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def float_value(value, what)
|
|
760
|
+
raise ArgumentError, "#{what} must be a number, got #{value.inspect}" unless value.is_a?(Numeric)
|
|
761
|
+
|
|
762
|
+
f = value.to_f
|
|
763
|
+
raise ArgumentError, "#{what} must be finite, got #{f}" unless f.finite?
|
|
764
|
+
f
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# libtcd check_date: 0, or YYYYMMDD with month 1..12 and day 1..31
|
|
768
|
+
def date_value(value, what)
|
|
769
|
+
date = int_value(value || 0, what)
|
|
770
|
+
return date if date.zero?
|
|
771
|
+
|
|
772
|
+
md = date % 10_000
|
|
773
|
+
unless date.positive? && (md / 100).between?(1, 12) && (md % 100).between?(1, 31)
|
|
774
|
+
raise ArgumentError, "#{what} #{date} is not 0 or a YYYYMMDD date"
|
|
775
|
+
end
|
|
776
|
+
date
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# 0..360 degrees, nil for none (stored as 361)
|
|
780
|
+
def direction_value(value, what)
|
|
781
|
+
return NULL_DIRECTION if value.nil?
|
|
782
|
+
|
|
783
|
+
dir = int_value(value, what)
|
|
784
|
+
raise ArgumentError, "#{what} #{dir} is outside 0..#{NULL_DIRECTION}" unless dir.between?(0, NULL_DIRECTION)
|
|
785
|
+
dir
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# zone_offset is libtcd's signed HHMM (e.g. -500 for UTC-5, 530 for UTC+5:30), not minutes
|
|
789
|
+
def hhmm_value(value, what)
|
|
790
|
+
v = int_value(value || 0, what)
|
|
791
|
+
unless v.between?(-4096, 4095) && v.abs % 100 < 60
|
|
792
|
+
raise ArgumentError, "#{what} #{v} is not a signed HHMM value (-4096..4095, minutes < 60)"
|
|
793
|
+
end
|
|
794
|
+
v
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# 0 means "none" (XTide treats it as 1.0); a positive value too small
|
|
798
|
+
# to store would silently become that 0.
|
|
799
|
+
def multiply_value(value, what)
|
|
800
|
+
m = float_value(value || 0, what)
|
|
801
|
+
raise ArgumentError, "#{what} #{m} is negative" if m.negative?
|
|
802
|
+
if m.positive? && nint(m * @multiply_scale).zero?
|
|
803
|
+
raise ArgumentError, "#{what} #{m} rounds to 0, which libtcd and XTide read as no multiplier"
|
|
804
|
+
end
|
|
805
|
+
m
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Minutes (the reader's unit) to libtcd's signed HHMM
|
|
809
|
+
def hhmm(minutes, what = "time offset")
|
|
810
|
+
m = int_value(minutes || 0, what)
|
|
811
|
+
sign = m.negative? ? -1 : 1
|
|
812
|
+
m = m.abs
|
|
813
|
+
sign * ((m / 60) * 100 + m % 60)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# libtcd NINT: round half away from zero
|
|
817
|
+
def nint(value)
|
|
818
|
+
value.negative? ? (value - 0.5).to_i : (value + 0.5).to_i
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# libtcd calculate_bits: bits needed to store value (0 for 0)
|
|
822
|
+
def calculate_bits(value)
|
|
823
|
+
value.zero? ? 0 : value.bit_length
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Bit-level output, most significant bit first, padded with zero bits to
|
|
827
|
+
# a whole byte: the mirror of BitBuffer.
|
|
828
|
+
class BitPacker
|
|
829
|
+
def initialize
|
|
830
|
+
@out = String.new(encoding: Encoding::BINARY)
|
|
831
|
+
@acc = 0
|
|
832
|
+
@nbits = 0
|
|
833
|
+
@total = 0
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def bit_length
|
|
837
|
+
@total
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def write_uint(value, bits, what)
|
|
841
|
+
value = Integer(value)
|
|
842
|
+
if value.negative? || value >= (1 << bits)
|
|
843
|
+
raise RangeError, "#{what} value #{value} does not fit #{bits} unsigned bits"
|
|
844
|
+
end
|
|
845
|
+
put(value, bits)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def write_int(value, bits, what)
|
|
849
|
+
value = Integer(value)
|
|
850
|
+
half = 1 << (bits - 1)
|
|
851
|
+
unless value >= -half && value < half
|
|
852
|
+
raise RangeError, "#{what} value #{value} does not fit #{bits} signed bits"
|
|
853
|
+
end
|
|
854
|
+
put(value & ((1 << bits) - 1), bits)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def write_string(bytes)
|
|
858
|
+
bytes.each_byte { |b| put(b, 8) }
|
|
859
|
+
put(0, 8)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def bytes
|
|
863
|
+
out = @out.dup
|
|
864
|
+
out << ((@acc << (8 - @nbits)) & 0xFF).chr if @nbits.positive?
|
|
865
|
+
out
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
private
|
|
869
|
+
|
|
870
|
+
def put(value, bits)
|
|
871
|
+
return if bits.zero?
|
|
872
|
+
|
|
873
|
+
@acc = (@acc << bits) | value
|
|
874
|
+
@nbits += bits
|
|
875
|
+
@total += bits
|
|
876
|
+
while @nbits >= 8
|
|
877
|
+
@nbits -= 8
|
|
878
|
+
@out << ((@acc >> @nbits) & 0xFF).chr
|
|
879
|
+
end
|
|
880
|
+
@acc &= (1 << @nbits) - 1
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
end
|
data/lib/tcd.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tcd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Ritter
|
|
@@ -60,6 +60,7 @@ files:
|
|
|
60
60
|
- lib/tcd/reader.rb
|
|
61
61
|
- lib/tcd/station.rb
|
|
62
62
|
- lib/tcd/version.rb
|
|
63
|
+
- lib/tcd/writer.rb
|
|
63
64
|
homepage: https://github.com/jpr5/tcd
|
|
64
65
|
licenses:
|
|
65
66
|
- MIT
|