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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52207edb47be792acadf50b6e0c2217572de0301aa38620fc258f7485bcce803
4
- data.tar.gz: a3d9fca3dfba730fa8da2d52a49c9624fd59c6b2f2d79730e17b1d394b3d76a0
3
+ metadata.gz: 93a24ba8b3b951d7d11b827c8acee75b8ea720d70d3c6760e89d8f650dc95a51
4
+ data.tar.gz: 33b7fa3cbef20c4a8f6290a8b5d440eb22675ec0ad491bd16820f933ff1594db
5
5
  SHA512:
6
- metadata.gz: 482e904705050a9bf467b149d744d3e440777db12af520fc5d8ed7883b5753cdab4c47c1b6a321ae666c8aefd973b4984da1809f2717507a01c9e98edbf7a672
7
- data.tar.gz: '0971d1ed74eec9cdff65c09fe62795bfeec8c7bb20de02eb3f6d6f1aa0ac86479889b6b1f9344868eae3edfa5b624052303defdc692850d5ee1552b17dcf87ea'
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:
@@ -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
- def initialize(path)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TCD
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end
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
@@ -3,6 +3,7 @@
3
3
  require_relative "tcd/version"
4
4
  require_relative "tcd/reader"
5
5
  require_relative "tcd/inference"
6
+ require_relative "tcd/writer"
6
7
 
7
8
  module TCD
8
9
  class << self
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.2
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