argos-ruby 1.0.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.
data/lib/argos/ds.rb ADDED
@@ -0,0 +1,438 @@
1
+ module Argos
2
+
3
+ # Argos DS|DAT file parser
4
+ #
5
+ # Usage
6
+ #
7
+ # ds = Argos::Ds.new
8
+ # ds.log = Logger.new(STDERR)
9
+ # puts ds.parse(filename).to_json
10
+ #
11
+ #
12
+ # For information about Argos, see: http://www.argos-system.org
13
+ #
14
+ # @author Espen Egeland
15
+ # @author Conrad Helgeland
16
+ # @todo errors => warn or remove unless asked for? (debug)
17
+ class Ds < Array
18
+ include Argos
19
+
20
+ attr_writer :log, :filename
21
+
22
+ attr_reader :filename, :filter, :filtername, :valid, :filesize, :sha1, :messages
23
+
24
+ START_REGEX = /^\d{5} \d{5,6} +\d+ +\d+/
25
+
26
+ START_REGEX_LEGACY = /\s+\d\.\d{3}\s\d{9}\s+\w{4}$/
27
+
28
+ LOCATION_CLASS = [nil, "0","1","2","3","A","B","G","Z"]
29
+
30
+ def self.ds? filename
31
+ end
32
+
33
+ def filter?
34
+ not @filter.nil?
35
+ end
36
+
37
+ def filter=filter
38
+ if filter.respond_to? :call
39
+ @filter = filter
40
+ elsif filter =~ /lambda|Proc/
41
+ @filtername = filter
42
+ @filter = eval(filter)
43
+ end
44
+ end
45
+
46
+ def log
47
+ if @log.nil?
48
+ @log = Logger.new(STDERR)
49
+ end
50
+ @log
51
+ end
52
+
53
+ # Parses Argos DS file and returns Argos::Ds -> Array
54
+ #
55
+ # The parser loops all messages (stored in @messages), before #unfold
56
+ # creates a sorted Array of measurements
57
+ #
58
+ #@param filename [String] Filename of Argos DS file
59
+ #@return [Argos::Ds]
60
+ def parse(filename=nil)
61
+
62
+ self.clear # Needed if you parse multiple times
63
+ @messages = []
64
+ @valid = false
65
+
66
+ if filename.nil?
67
+ filename = @filename
68
+ end
69
+
70
+
71
+ filename = File.realpath(filename)
72
+ @filename = filename
73
+ if filename.nil? or not File.exists? filename
74
+ raise ArgumentError, "Missing ARGOS DS file: \"#{filename}\""
75
+ end
76
+ @sha1 = Digest::SHA1.file(filename).hexdigest
77
+
78
+ contact = []
79
+ file = File.open(filename)
80
+ @filesize = file.size
81
+
82
+ log.debug "Parsing ARGOS DS file #{filename} source:#{sha1} (#{filesize} bytes)"
83
+ if filter?
84
+ log.debug "Using filter: #{@filtername.nil? ? filter : @filtername }"
85
+ end
86
+
87
+ firstline = file.readline
88
+ file.rewind
89
+
90
+ if firstline =~ START_REGEX_LEGACY
91
+ return parse_legacy(file)
92
+ end
93
+
94
+ file.each_with_index do |line, c|
95
+ line = line.strip
96
+
97
+ #if (c+1) % 1000 == 0
98
+ # log.debug "Line: #{c+1}"
99
+ #end
100
+
101
+ if line =~ START_REGEX
102
+
103
+ @valid = true
104
+
105
+ if contact.any?
106
+ item = parse_message(contact)
107
+
108
+ if self.class.valid_item? item
109
+
110
+ if not filter? or filter.call(item)
111
+ @messages << item
112
+ end
113
+
114
+ else
115
+ raise "Argos DS message #{filename}:#{c} lacks required program and/or platform"
116
+ end
117
+ end
118
+
119
+ contact = [line]
120
+
121
+ else
122
+ # 2010-12-14 15:11:34 1 00 37 01 52
123
+ if contact.any? and line != ""
124
+ contact << line
125
+ end
126
+ end
127
+ end
128
+
129
+ if false == @valid
130
+ #log.debug file.read
131
+ message = "Cannot parse file: #{filename}"
132
+ raise ArgumentError, message
133
+ end
134
+
135
+ last = parse_message(contact)
136
+
137
+ # The last message
138
+ if last
139
+ if not filter? or filter.call(last)
140
+ @messages << last
141
+ end
142
+ end
143
+
144
+ log.debug "Parsed #{@messages.size} Argos DS messages into #{self.class.name} Array"
145
+ @segments = @messages.size
146
+ unfold.each do |d|
147
+ self << d
148
+ end
149
+ self
150
+ end
151
+
152
+ # Pare one DS segment
153
+ def parse_message(contact)
154
+ header = contact[0]
155
+ body = contact[1,contact.count]
156
+ items = process_item_body(body)
157
+ combine_header_with_transmission(items, header)
158
+ end
159
+
160
+ def type
161
+ "ds"
162
+ end
163
+
164
+ # @param [String] header
165
+ # Header is is a space-separated string containing
166
+ # [0] Program number
167
+ # [1] Platform number
168
+ # [2] Number of lines of data per satellite pass
169
+ # [3] Number of sensors
170
+ # [4] Satellite identifier
171
+ # [5] Location class (lc)
172
+ # [6] Location date 2007-03-02
173
+ # [7] Location UTC time
174
+ # [8] Latitude (decimal degrees)
175
+ # [9] Longitude, may be > 180 like 255.452°, equivalent to 255.452 - 360 = -104.548 (°E)
176
+ # [10] Altitude (km)
177
+ # [11] Frequency (calculated)
178
+ #
179
+ # The header varies in information elemenet, often either 0..4|5 or 0..11.
180
+ # Header examples (plit on " "):
181
+ # ["09660", "10788", "4", "3", "D", "0"]
182
+ # ["09660", "10788", "5", "3", "H", "2", "1992-04-06", "22:12:16", "78.248", "15.505", "0.000", "401649604"]
183
+ # ["09660", "10788", "2", "3", "D"]
184
+ # http://www.argos-system.org/files/pmedia/public/r363_9_argos_users_manual-v1.5.pdf page 42
185
+ #
186
+ # Warning, the parser does not support this header format from 1989 [AUO89.DAT]
187
+ # 19890800-19891000: ["09660", "14653", "10", "41", "14", "1", "-.42155E+1", "00", "112", "17DD"]
188
+ def combine_header_with_transmission(measurements, header)
189
+ unless header.is_a? Array
190
+ header = header.split(" ")
191
+ end
192
+ latitude = longitude = positioned = nil
193
+ warn = []
194
+ errors = []
195
+
196
+ lc = header[5]
197
+
198
+ if not header[6].nil? and not header[7].nil?
199
+ positioned = convert_datetime(header[6]+" "+header[7])
200
+ end
201
+
202
+ if header[8] != nil && valid_float?(header[8])
203
+ latitude = header[8].to_f
204
+ end
205
+
206
+ if header[9] != nil && valid_float?(header[9])
207
+ longitude = header[9].to_f
208
+ if (180..360).include? longitude
209
+ longitude = (longitude - 360)
210
+ end
211
+ end
212
+
213
+ altitude = header[10]
214
+ if not altitude.nil?
215
+ altitude = altitude.to_f*1000
216
+ end
217
+
218
+ if positioned.nil? and measurements.nil?
219
+ warn << "missing-time"
220
+ end
221
+
222
+ if latitude.nil? or longitude.nil?
223
+ warn << "missing-position"
224
+ else
225
+
226
+ unless latitude.between?(-90, 90) and longitude.between?(-180, 180)
227
+ errors << "invalid-position"
228
+ end
229
+ end
230
+
231
+ unless LOCATION_CLASS.include? lc
232
+ errors << "invalid-lc"
233
+ end
234
+
235
+ # Satellites
236
+ # ["A", "B", "K", "L", "M", "N", "P", "R"]
237
+
238
+ document = { program: header[0].to_i,
239
+ platform: header[1].to_i,
240
+ lines: header[2].to_i,
241
+ sensors: header[3].to_i,
242
+ satellite: header[4],
243
+ lc: lc,
244
+ positioned: positioned,
245
+ latitude: latitude,
246
+ longitude: longitude,
247
+ altitude: altitude,
248
+ measurements: measurements,
249
+ headers: header.size
250
+ }
251
+ if warn.any?
252
+ document[:warn]=warn
253
+ end
254
+ if errors.any?
255
+ document[:errors]=errors
256
+ end
257
+
258
+ document
259
+ end
260
+
261
+ # Merge position and all other top-level DS fields with each measurement line
262
+ # (containing sensor data)
263
+ # The 3 lines below will unfold to *2* documents, each with
264
+ # "positioned":2010-03-05T14:19:06Z, "platform": "23695", "latitude":"79.989", etc.
265
+ # 23695 074772 3 4 M B 2010-03-05 14:19:06 79.989 12.644 0.036 401639707
266
+ # 2010-03-05 14:17:35 1 01 25 37630 36
267
+ # 2010-03-05 14:20:38 1 00 28 00 65
268
+ def unfold
269
+
270
+ # First, grab all segments *without* measurements (if any)
271
+ unfolded = messages.reject {|ds| ds.key?(:measurements) or ds[:measurements].nil? }
272
+ log.debug "#{messages.size - unfolded.size} / #{messages.size} messages contained measurements"
273
+
274
+ messages.select {|ds|
275
+ ds.key?(:measurements) and not ds[:measurements].nil?
276
+ }.each do |ds|
277
+
278
+ ds[:measurements].each do |measurement|
279
+ unfolded << merge(ds,measurement)
280
+ end
281
+ end
282
+
283
+ unfolded = unfolded.sort_by {|ds|
284
+ if not ds[:measured].nil?
285
+ DateTime.parse(ds[:measured])
286
+ elsif not ds[:positioned].nil?
287
+ DateTime.parse(ds[:positioned])
288
+ else
289
+ ds[:program]
290
+ end
291
+ }
292
+
293
+ log.info "Unfolded #{messages.size} ARGOS DS position and sensor messages into #{unfolded.size} new documents source:#{sha1} #{filename}"
294
+
295
+ unfolded
296
+ end
297
+
298
+ def merge(ds, measurement)
299
+ m = ds.select {|k,v| k != :measurements and k != :errors and k != :warn }
300
+ m = m.merge(measurement)
301
+ m = m.merge({ technology: "argos",
302
+ type: type, filename: filename, source: sha1
303
+ })
304
+
305
+ if not ds[:errors].nil? and ds[:errors].any?
306
+ m[:errors] = ds[:errors].clone
307
+ end
308
+
309
+ if not ds[:warn].nil? and ds[:warn].any?
310
+ m[:warn] = ds[:warn].clone
311
+ end
312
+
313
+ if not m[:sensor_data].nil? and m[:sensor_data].size != ds[:sensors]
314
+ if m[:warn].nil?
315
+ m[:warn] = []
316
+ end
317
+ m[:warn] << "sensors-count-mismatch"
318
+ end
319
+
320
+ idbase = m.clone
321
+ idbase.delete :errors
322
+ idbase.delete :filename
323
+ idbase.delete :warn
324
+
325
+ id = Digest::SHA1.hexdigest(idbase.to_json)
326
+
327
+ m[:parser] = Argos.library_version
328
+ m[:id] = id
329
+ m
330
+ end
331
+
332
+ def process_item_body(body_arr)
333
+ @buf =""
334
+ @transmission_arr = []
335
+ @transmission_arr = recursive_transmission_parse(body_arr)
336
+ end
337
+
338
+
339
+ # @param [Array] body_arr
340
+ # @return [Aray]
341
+ def recursive_transmission_parse(body_arr)
342
+ if body_arr.nil? or body_arr.empty?
343
+ return
344
+ end
345
+ @buf =@buf + " " + body_arr[0]
346
+
347
+ if body_arr[1] =~ /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/ or body_arr[1]==nil
348
+ @transmission_arr << transmission_package(@buf)
349
+ @buf=""
350
+ end
351
+ recursive_transmission_parse(body_arr[1,body_arr.length])
352
+ @transmission_arr
353
+ end
354
+
355
+ def transmission_package(data)
356
+ transmission_time = data[/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/,1]
357
+ transmission_time = convert_datetime(transmission_time)
358
+
359
+ identical = data.split(" ")[2].to_i
360
+ data = data.strip[23,data.length]
361
+
362
+ if not data.nil?
363
+ sensor_data = data.split(" ")
364
+ end
365
+ { measured: transmission_time,
366
+ identical: identical,
367
+ sensor_data: sensor_data
368
+ }
369
+ end
370
+
371
+ def start
372
+ positioned.map {|ds| ds [:positioned] }.first
373
+ end
374
+
375
+ def stop
376
+ positioned.map {|ds| ds [:positioned] }.last
377
+ end
378
+
379
+ def source
380
+ @sha1
381
+ end
382
+
383
+ protected
384
+
385
+ # "1999-04-02 01:28:54"
386
+ def convert_datetime(datetime)
387
+
388
+ #AUO89.DAT/home/ch/github.com/argos-ruby/lib/ds.rb:143:in `parse': can't convert nil into String (TypeError)
389
+ #/home/ch/github.com/api.npolar.no/seed/tracking/argos/19890800-19891000
390
+ #AUO89.DAT/home/ch/github.com/argos-ruby/lib/ds.rb:149:in `parse': invalid date (ArgumentError)
391
+ begin
392
+ datetime = ::DateTime.parse(datetime).iso8601.to_s
393
+ datetime['+00:00'] = "Z"
394
+ datetime
395
+ rescue
396
+ log.error "Invalid date #{datetime}"
397
+ DateTime.new(0).xmlschema.gsub(/\+00:00/, "Z")
398
+ end
399
+ end
400
+
401
+ def positioned
402
+ select {|ds|
403
+ ds.key? :positioned and not ds[:positioned].nil?
404
+ }
405
+ end
406
+
407
+ # Argos format until 1991
408
+ #
409
+ #09660 6 09691 2 14286 89 042 17 18 05 1 3 G 0.000 401650000 0VDI
410
+ #09660 2 59.891 10.629 401649651 0VDJ
411
+ #09660 14286 17 14 26 1 -.72543E+1 00 0VDK
412
+ #09660 14286 17 16 52 1 -.72410E+1 00 0VDL
413
+ #09660 14286 17 19 18 2 -.72376E+1 00 0VDM
414
+ #09660 14286 17 21 44 3 -.72376E+1 00 0VDN
415
+
416
+ # Header: 09660[program] 6[lines] ????? 2 ????? 89[year] 042[day?] 17 18 05[time?] 1 3[?] G[?] 0.000 \d{9}[f] \d\w{3}[ident]
417
+ def parse_legacy(file)
418
+ raise "Legacy DS file parser: not implemented"
419
+ #file.each_with_index do |line, c|
420
+ # line = line.strip
421
+ # log.debug line
422
+ #end
423
+ end
424
+
425
+
426
+ def valid_float?(str)
427
+ !!Float(str) rescue false
428
+ end
429
+
430
+ def self.valid_item?(item)
431
+ unless item.respond_to?(:key)
432
+ return false
433
+ end
434
+ item.key?(:program) and item.key?(:platform)
435
+ end
436
+
437
+ end
438
+ end
@@ -0,0 +1,4 @@
1
+ module Argos
2
+ class Exception < ::Exception
3
+ end
4
+ end
data/lib/argos.rb ADDED
@@ -0,0 +1,142 @@
1
+ require "bigdecimal"
2
+ require "date"
3
+ require "digest/sha1"
4
+ require "json"
5
+ require "logger"
6
+
7
+ require_relative "argos/exception"
8
+ require_relative "argos/ds"
9
+ require_relative "argos/diag"
10
+
11
+ # Argos module
12
+ # Contains parsers for Argos DS/DAT and DIAG files.
13
+ #
14
+ # Code written by staff at the Norwegian Polar Data Centre
15
+ # http://data.npolar.no - a service run by the [Norwegian Polar Institute](http://npolar.no)
16
+ #
17
+ # For information about Argos, see: http://www.argos-system.org
18
+ module Argos
19
+ VERSION = "1.0.0"
20
+ # Detect Argos type ("ds" or "diag" or nil)
21
+ #
22
+ # @param filename [String] Argos (DS or DIAG) file
23
+ # @return [String]
24
+ #"ds"|"diag"
25
+ def self.type filename
26
+
27
+ if File.file? filename
28
+ # Avoid invalid byte sequence in UTF-8 (ArgumentError)
29
+ firstline = File.open(filename, :encoding => "iso-8859-1") {|f| f.readline}
30
+ else
31
+ firstline = filename
32
+ end
33
+
34
+ case firstline
35
+ when Argos::Ds::START_REGEX, Argos::Ds::START_REGEX_LEGACY
36
+ "ds"
37
+ when Argos::Diag::START_REGEX
38
+ "diag"
39
+ when "", nil
40
+ raise ArgumentError, "Not a file or empty string: #{filename}"
41
+ else nil
42
+ end
43
+ end
44
+
45
+ # Argos file?
46
+ #
47
+ # @param filename [String] Argos (DS or DIAG) file
48
+ # @return [Boolean]
49
+ def self.argos?(filename)
50
+ case type(filename)
51
+ when "ds", "diag"
52
+ true
53
+ else
54
+ false
55
+ end
56
+ end
57
+
58
+ # Factory for Argos::Ds / Argos::Diag
59
+ #
60
+ # @param type [String]: Argos (DS or DIAG) file type (or filename)
61
+ # @return [Argos::Ds Argos::Diag]
62
+ # @throws ArgumentError
63
+ def self.factory(type)
64
+
65
+ # Auto-detect file format if not "ds" or "diag"
66
+ if not ["ds","diag"].include? type
67
+ if argos? type
68
+ type = self.type(type)
69
+ end
70
+ end
71
+
72
+ case type
73
+ when "ds"
74
+ Ds.new
75
+ when "diag"
76
+ Diag.new
77
+ else
78
+ raise ArgumentError, "Unknown Argos type: #{type}"
79
+ end
80
+ end
81
+
82
+
83
+ def self.library_version
84
+ "argos-ruby-#{VERSION}"
85
+ end
86
+
87
+ # Source fingerprint of Argos file (sha1 hash, segment and document counts, etc.)
88
+ #
89
+ # @param  [Argos::Ds Argos::Diag] argos
90
+ # @return [Hash]
91
+ def self.source(argos)
92
+
93
+ argos.parse(argos.filename)
94
+
95
+ latitude_mean = longitude_mean = nil
96
+ if argos.latitudes.any?
97
+ latitude_mean = (argos.latitudes.inject{ |sum, latitude| sum + latitude } / argos.latitudes.size).round(3)
98
+ end
99
+ if argos.longitudes.any?
100
+ longitude_mean = (argos.longitudes.inject{ |sum, longitude| sum + longitude } / argos.latitudes.size).round(3)
101
+ end
102
+
103
+
104
+ { id: argos.source,
105
+ type: argos.type,
106
+ programs: argos.programs,
107
+ platforms: argos.platforms,
108
+ start: argos.start,
109
+ stop: argos.stop,
110
+ north: argos.latitudes.max,
111
+ east: argos.longitudes.max,
112
+ south: argos.latitudes.min,
113
+ west: argos.longitudes.min,
114
+ latitude_mean: latitude_mean,
115
+ longitude_mean: longitude_mean,
116
+ filename: argos.filename,
117
+ filesize: argos.filesize,
118
+ messages: argos.messages.size,
119
+ filter: argos.filtername.nil? ? argos.filter : argos.filtername,
120
+ size: argos.size,
121
+ }
122
+ end
123
+
124
+
125
+ def latitudes
126
+ select {|a| a.key? :latitude and a[:latitude].is_a? Float }.map {|a| a[:latitude]}.sort
127
+ end
128
+
129
+ def longitudes
130
+ select {|a| a.key? :longitude and a[:longitude].is_a? Float }.map {|a| a[:longitude]}.sort
131
+ end
132
+
133
+ def platforms
134
+ map {|a| a[:platform]}.uniq.sort
135
+ end
136
+
137
+ def programs
138
+ map {|a| a[:program]}.uniq.sort
139
+ end
140
+
141
+
142
+ end