fits_parser 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 55051bb0876a15d09837328787edb5d4105d104e3c65f3eafe99724681d54a18
4
+ data.tar.gz: 44048b67f3a83a50799d2b4165ff1098705f9e2a331ee2833667c9e3b72bd231
5
+ SHA512:
6
+ metadata.gz: ecf542f8a1809cf7451fb4bfb4050ebcd874bf3d3566215b1c9cb7ad7e2993c288df474eb599ecded906f6c085ae2485484271904c5245b303f32f2dc1afc4e1
7
+ data.tar.gz: e5945ff730f26b2a41ecb5858650551bdb8ef883c92605acfb5bd665d464ef1ab0076c8acaf43e97deb8de722fabe3249ebbb6493131cd9e6606b8cfd55fe4a3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # fits_parser
2
+
3
+ A minimal Ruby gem to parse astronomy FITS files (`.fit` / `.fits`).
4
+
5
+ ## Features
6
+
7
+ - Parse FITS HDUs and headers.
8
+ - Convert common FITS header value types (string, bool, int, float).
9
+ - Read basic BINTABLE metadata and rows for common `TFORM` codes (`A`, `B`, `I`, `J`, `K`, `E`, `D`).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ gem build fits_parser.gemspec
15
+ gem install ./fits_parser-0.1.0.gem
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ruby
21
+ require "fits_parser"
22
+
23
+ parser = FitsParser.new("/path/to/file.fit")
24
+ hdus = parser.parse_hdus
25
+
26
+ table_hdu = hdus.find { |h| h[:header]["XTENSION"] == "BINTABLE" }
27
+ if table_hdu
28
+ table = parser.read_bintable(table_hdu)
29
+ p table[:columns]
30
+ p table[:rows].first
31
+ end
32
+
33
+ parser.close
34
+ ```
35
+
36
+ Using block form:
37
+
38
+ ```ruby
39
+ FitsParser.open("/path/to/file.fits") do |parser|
40
+ p parser.parse_hdus
41
+ end
42
+ ```
43
+
44
+ ### Stream BINTABLE rows (memory-safe)
45
+
46
+ Use this when tables are large and you do not want to load all rows at once.
47
+
48
+ ```ruby
49
+ require "fits_parser"
50
+
51
+ FitsParser.open("/path/to/file.fits") do |parser|
52
+ hdus = parser.parse_hdus
53
+ table_hdu = hdus.find { |h| h[:header]["XTENSION"] == "BINTABLE" }
54
+ raise "No BINTABLE found" unless table_hdu
55
+
56
+ columns = parser.bintable_columns(table_hdu)
57
+ p columns
58
+
59
+ parser.each_bintable_row(table_hdu).with_index do |row, idx|
60
+ puts "row=#{idx} #{row.inspect}"
61
+ break if idx >= 4 # sample only first 5 rows
62
+ end
63
+ end
64
+ ```
65
+
66
+ ### Read one specific row by index
67
+
68
+ ```ruby
69
+ require "fits_parser"
70
+
71
+ target_index = 123
72
+
73
+ FitsParser.open("/path/to/file.fits") do |parser|
74
+ hdu = parser.parse_hdus.find { |h| h[:header]["XTENSION"] == "BINTABLE" }
75
+ row = nil
76
+ parser.each_bintable_row(hdu).with_index do |r, i|
77
+ if i == target_index
78
+ row = r
79
+ break
80
+ end
81
+ end
82
+ p row
83
+ end
84
+ ```
85
+
86
+ ## API Summary
87
+
88
+ - `FitsParser.new(path)` opens a FITS file.
89
+ - `FitsParser.open(path) { |parser| ... }` block form with auto-close.
90
+ - `parse_hdus` returns HDU metadata (headers, data positions, sizes).
91
+ - `read_bintable(hdu)` returns `{ columns:, rows: }` (loads all rows).
92
+ - `bintable_columns(hdu)` returns column metadata only.
93
+ - `each_bintable_row(hdu)` yields rows one by one (streaming).
94
+ - `close` closes the file handle.
95
+
96
+ ## CLI
97
+
98
+ ```bash
99
+ bin/fits-parser /path/to/file.fit
100
+ ```
101
+
102
+ Prints a JSON summary of HDUs.
103
+
104
+ ## Testing
105
+
106
+ Run tests:
107
+
108
+ ```bash
109
+ rake test
110
+ ```
111
+
112
+ Alternative direct test command:
113
+
114
+ ```bash
115
+ ruby -Ilib:test test/fits_parser_test.rb
116
+ ```
117
+
118
+ Quick syntax checks:
119
+
120
+ ```bash
121
+ ruby -c lib/fits_parser.rb
122
+ ruby -c lib/fits_parser/parser.rb
123
+ ```
124
+
125
+ ## Notes
126
+
127
+ - FITS is broad; this gem intentionally implements a practical subset.
128
+ - Variable-length arrays and many advanced FITS conventions are not yet supported.
data/bin/fits-parser ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "../lib/fits_parser"
6
+
7
+ path = ARGV[0]
8
+ abort "Usage: fits-parser /path/to/file.fit" if path.nil? || path.strip.empty?
9
+
10
+ FitsParser.open(path) do |parser|
11
+ hdus = parser.parse_hdus
12
+ summary = hdus.map.with_index do |hdu, idx|
13
+ header = hdu[:header]
14
+ {
15
+ index: idx,
16
+ xtension: header["XTENSION"] || "PRIMARY",
17
+ extname: header["EXTNAME"],
18
+ naxis: header["NAXIS"],
19
+ naxis1: header["NAXIS1"],
20
+ naxis2: header["NAXIS2"],
21
+ tfields: header["TFIELDS"],
22
+ data_size: hdu[:data_size]
23
+ }
24
+ end
25
+
26
+ puts JSON.pretty_generate(summary)
27
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal FITS parser for astronomy .fit/.fits files.
4
+ # Reads:
5
+ # - primary/header HDUs
6
+ # - basic keyword values
7
+ # - BINTABLE metadata
8
+ #
9
+ # This is not a full FITS implementation, but it provides a practical skeleton.
10
+ class FitsParser
11
+ CARD_SIZE = 80
12
+ BLOCK_SIZE = 2880
13
+
14
+ def initialize(path)
15
+ @io = File.open(path, "rb")
16
+ end
17
+
18
+ def self.open(path)
19
+ parser = new(path)
20
+ return parser unless block_given?
21
+
22
+ begin
23
+ yield parser
24
+ ensure
25
+ parser.close
26
+ end
27
+ end
28
+
29
+ def close
30
+ @io&.close
31
+ end
32
+
33
+ def parse_hdus
34
+ hdus = []
35
+
36
+ until @io.eof?
37
+ start_pos = @io.pos
38
+ header_cards = read_header_cards
39
+ break if header_cards.empty?
40
+
41
+ header = cards_to_hash(header_cards)
42
+ data_pos = @io.pos
43
+
44
+ data_size = hdu_data_size(header)
45
+ hdus << {
46
+ start_pos: start_pos,
47
+ header: header,
48
+ data_pos: data_pos,
49
+ data_size: data_size
50
+ }
51
+
52
+ skip_padded_data(data_size)
53
+ break if @io.eof?
54
+ end
55
+
56
+ hdus
57
+ end
58
+
59
+ def read_bintable(hdu)
60
+ columns = bintable_columns(hdu)
61
+ data_rows = each_bintable_row(hdu).to_a
62
+
63
+ {
64
+ columns: columns,
65
+ rows: data_rows
66
+ }
67
+ end
68
+
69
+ def bintable_columns(hdu)
70
+ header = hdu[:header]
71
+ raise "Not a BINTABLE HDU" unless header["XTENSION"] == "BINTABLE"
72
+
73
+ tfields = integer_value(header["TFIELDS"])
74
+ (1..tfields).map do |i|
75
+ {
76
+ index: i,
77
+ name: string_value(header["TTYPE#{i}"]),
78
+ form: string_value(header["TFORM#{i}"])
79
+ }
80
+ end
81
+ end
82
+
83
+ def each_bintable_row(hdu)
84
+ return enum_for(:each_bintable_row, hdu) unless block_given?
85
+
86
+ header = hdu[:header]
87
+ raise "Not a BINTABLE HDU" unless header["XTENSION"] == "BINTABLE"
88
+
89
+ rows = integer_value(header["NAXIS2"])
90
+ rowlen = integer_value(header["NAXIS1"])
91
+ columns = bintable_columns(hdu)
92
+
93
+ @io.seek(hdu[:data_pos])
94
+
95
+ rows.times do
96
+ row_bytes = @io.read(rowlen)
97
+ raise "Short BINTABLE row" if row_bytes.nil? || row_bytes.bytesize != rowlen
98
+
99
+ yield parse_bintable_row(row_bytes, columns)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def read_header_cards
106
+ cards = []
107
+
108
+ loop do
109
+ card = @io.read(CARD_SIZE)
110
+ break unless card
111
+ raise "Short FITS card" if card.bytesize != CARD_SIZE
112
+
113
+ cards << card
114
+ if card.start_with?("END")
115
+ pad_after_end(cards.length)
116
+ return cards
117
+ end
118
+ end
119
+
120
+ cards
121
+ end
122
+
123
+ def pad_after_end(card_count)
124
+ header_bytes = card_count * CARD_SIZE
125
+ padded = ((header_bytes + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE
126
+ extra = padded - header_bytes
127
+ @io.seek(extra, IO::SEEK_CUR) if extra.positive?
128
+ end
129
+
130
+ def cards_to_hash(cards)
131
+ hash = {}
132
+
133
+ cards.each do |card|
134
+ key = card[0, 8].strip
135
+ next if key.empty? || key == "END"
136
+
137
+ if card[8] == "="
138
+ raw = card[10, 70]
139
+ value_part = raw.split("/", 2).first&.rstrip
140
+ hash[key] = parse_value(value_part)
141
+ else
142
+ hash[key] = true
143
+ end
144
+ end
145
+
146
+ hash
147
+ end
148
+
149
+ def parse_value(v)
150
+ return nil if v.nil?
151
+
152
+ v = v.strip
153
+ return nil if v.empty?
154
+
155
+ if v.start_with?("'")
156
+ v.sub(/^'/, "").sub(/'\s*$/, "").rstrip
157
+ elsif v == "T"
158
+ true
159
+ elsif v == "F"
160
+ false
161
+ elsif v.match?(/\A[+-]?\d+\z/)
162
+ v.to_i
163
+ elsif v.match?(/\A[+-]?(?:\d+\.\d*|\.\d+)(?:[EeDd][+-]?\d+)?\z/) || v.match?(/\A[+-]?\d+[EeDd][+-]?\d+\z/)
164
+ v.tr("Dd", "Ee").to_f
165
+ else
166
+ v
167
+ end
168
+ end
169
+
170
+ def hdu_data_size(header)
171
+ # IMAGE HDU
172
+ if header["XTENSION"].nil?
173
+ bitpix = integer_value(header["BITPIX"])
174
+ naxis = integer_value(header["NAXIS"])
175
+ dims = (1..naxis).map { |i| integer_value(header["NAXIS#{i}"]) }
176
+ return 0 if naxis.zero?
177
+
178
+ bytes_per_pixel = bitpix.abs / 8
179
+ dims.inject(1, :*) * bytes_per_pixel
180
+ else
181
+ case header["XTENSION"]
182
+ when "BINTABLE", "TABLE"
183
+ integer_value(header["NAXIS1"]) * integer_value(header["NAXIS2"])
184
+ else
185
+ naxis = integer_value(header["NAXIS"])
186
+ dims = (1..naxis).map { |i| integer_value(header["NAXIS#{i}"]) }
187
+ bitpix = integer_value(header["BITPIX"])
188
+ bytes_per_pixel = bitpix.abs / 8
189
+ dims.inject(1, :*) * bytes_per_pixel
190
+ end
191
+ end
192
+ end
193
+
194
+ def skip_padded_data(data_size)
195
+ padded = ((data_size + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE
196
+ @io.seek(padded, IO::SEEK_CUR)
197
+ end
198
+
199
+ def integer_value(v)
200
+ Integer(v)
201
+ end
202
+
203
+ def string_value(v)
204
+ v.to_s
205
+ end
206
+
207
+ # Very minimal BINTABLE row parser.
208
+ # Extend this for more FITS TFORM types as needed.
209
+ def parse_bintable_row(row_bytes, columns)
210
+ offset = 0
211
+
212
+ columns.each_with_object({}) do |col, row|
213
+ form = col[:form]
214
+
215
+ value, consumed = parse_tform_value(row_bytes, offset, form)
216
+ row[col[:name]] = value
217
+ offset += consumed
218
+ end
219
+ end
220
+
221
+ def parse_tform_value(buf, offset, form)
222
+ # examples: "E", "D", "J", "K", "10A"
223
+ m = form.match(/\A(\d*)([A-Z])\z/)
224
+ raise "Unsupported TFORM: #{form}" unless m
225
+
226
+ repeat = m[1].empty? ? 1 : m[1].to_i
227
+ code = m[2]
228
+
229
+ case code
230
+ when "A"
231
+ bytes = buf.byteslice(offset, repeat)
232
+ [bytes.to_s.rstrip, repeat]
233
+ when "B"
234
+ vals = buf.byteslice(offset, repeat).bytes
235
+ [repeat == 1 ? vals[0] : vals, repeat]
236
+ when "I" # 16-bit signed big-endian
237
+ vals = repeat.times.map { |i| buf.byteslice(offset + i * 2, 2).unpack1("s>") }
238
+ [repeat == 1 ? vals[0] : vals, repeat * 2]
239
+ when "J" # 32-bit signed big-endian
240
+ vals = repeat.times.map { |i| buf.byteslice(offset + i * 4, 4).unpack1("l>") }
241
+ [repeat == 1 ? vals[0] : vals, repeat * 4]
242
+ when "K" # 64-bit signed big-endian
243
+ vals = repeat.times.map { |i| buf.byteslice(offset + i * 8, 8).unpack1("q>") }
244
+ [repeat == 1 ? vals[0] : vals, repeat * 8]
245
+ when "E" # 32-bit float big-endian
246
+ vals = repeat.times.map { |i| buf.byteslice(offset + i * 4, 4).unpack1("g") }
247
+ [repeat == 1 ? vals[0] : vals, repeat * 4]
248
+ when "D" # 64-bit float big-endian
249
+ vals = repeat.times.map { |i| buf.byteslice(offset + i * 8, 8).unpack1("G") }
250
+ [repeat == 1 ? vals[0] : vals, repeat * 8]
251
+ else
252
+ raise "Unsupported FITS TFORM code: #{code}"
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FitsParser
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fits_parser/version"
4
+ require_relative "fits_parser/parser"
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fits_parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Bass
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: A lightweight Ruby parser for FITS files with header/HDU parsing and
42
+ basic BINTABLE support.
43
+ email:
44
+ - tim@unix.com
45
+ executables:
46
+ - fits-parser
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE.txt
51
+ - README.md
52
+ - bin/fits-parser
53
+ - lib/fits_parser.rb
54
+ - lib/fits_parser/parser.rb
55
+ - lib/fits_parser/version.rb
56
+ homepage: https://github.com/unixneo/fits_parser
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://github.com/unixneo/fits_parser
61
+ source_code_uri: https://github.com/unixneo/fits_parser
62
+ changelog_uri: https://github.com/unixneo/fits_parser/releases
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.4.10
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Minimal FITS (.fit/.fits) parser for astronomy data
83
+ test_files: []