plantwatchdog 0.0.1

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.
Files changed (50) hide show
  1. data/History.txt +4 -0
  2. data/License.txt +674 -0
  3. data/Manifest.txt +42 -0
  4. data/README.txt +91 -0
  5. data/Rakefile +22 -0
  6. data/bin/plantwatchdog +8 -0
  7. data/bin/upload_measurements +2 -0
  8. data/config.ru +35 -0
  9. data/config/app_config.yaml +10 -0
  10. data/lib/plantwatchdog/aggregation.rb +220 -0
  11. data/lib/plantwatchdog/aggregation_methods.rb +90 -0
  12. data/lib/plantwatchdog/data.rb +126 -0
  13. data/lib/plantwatchdog/db.rb +37 -0
  14. data/lib/plantwatchdog/gems.rb +5 -0
  15. data/lib/plantwatchdog/main.rb +76 -0
  16. data/lib/plantwatchdog/model.rb +442 -0
  17. data/lib/plantwatchdog/sinatra.rb +206 -0
  18. data/public/images/arrow-down.gif +0 -0
  19. data/public/images/arrow-left.gif +0 -0
  20. data/public/images/arrow-right.gif +0 -0
  21. data/public/images/arrow-up.gif +0 -0
  22. data/public/images/spinner.gif +0 -0
  23. data/public/images/tabs.png +0 -0
  24. data/public/js/customflot.js +120 -0
  25. data/public/js/jquery-1.3.2.min.js +19 -0
  26. data/public/js/jquery.flot.crosshair.js +157 -0
  27. data/public/js/jquery.flot.js +2119 -0
  28. data/public/js/jquery.flot.navigate.js +272 -0
  29. data/public/js/jquery.flot.selection.js +299 -0
  30. data/public/js/select-chain.js +71 -0
  31. data/public/js/tools.tabs-1.0.4.js +285 -0
  32. data/public/tabs.css +87 -0
  33. data/sample/solar/create_solar.rb +31 -0
  34. data/sample/solar/measurements/client.sqlite3 +0 -0
  35. data/sample/solar/static/devices.yml +17 -0
  36. data/sample/solar/static/metadata.yml +30 -0
  37. data/sample/solar/static/plants.yml +3 -0
  38. data/sample/solar/static/users.yml +4 -0
  39. data/sample/solar/upload_measurements +26 -0
  40. data/templates/graph.erb +134 -0
  41. data/templates/index.erb +24 -0
  42. data/templates/monthly_graph.erb +41 -0
  43. data/test/test_aggregation.rb +161 -0
  44. data/test/test_aggregation_methods.rb +50 -0
  45. data/test/test_base.rb +83 -0
  46. data/test/test_data.rb +118 -0
  47. data/test/test_model.rb +142 -0
  48. data/test/test_sync.rb +71 -0
  49. data/test/test_web.rb +87 -0
  50. metadata +167 -0
@@ -0,0 +1,126 @@
1
+ # Copyright (C) 2010 Markus Barchfeld, Vassilis Rizopoulos
2
+ #
3
+ # This program is free software; you can redistribute it and/or modify it under
4
+ # the terms of the GNU General Public License as published by the Free Software
5
+ # Foundation; either version 3 of the License, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but WITHOUT
9
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along with
13
+ # this program; if not, see <http://www.gnu.org/licenses/>.
14
+ $:.unshift File.join(File.dirname(__FILE__),"..")
15
+ require 'plantwatchdog/model'
16
+
17
+ module PlantWatchdog
18
+ module Datadetection
19
+ def years_with_data(user)
20
+ start_year = user.start_year
21
+ end_year = user.end_year
22
+ return [] unless start_year and end_year
23
+ return (start_year .. end_year).to_a
24
+ end
25
+
26
+ def days_with_data(user, year)
27
+ device_ids = user.plant.devices.collect { |d| d.id.to_s }
28
+ rows = ActiveRecord::Base.connection.select_all("select distinct time_day_of_year from measurement_chunks where device_id in (#{device_ids.join(",")}) and time_year=#{year} order by time_day_of_year")
29
+ return rows.collect {|r| r['time_day_of_year'].to_i}
30
+ end
31
+
32
+ # extract timeseries from the data chunks
33
+ # helpers for the presentation layer
34
+
35
+ def utc_offset(user, secs_since_epoch)
36
+ # mind that the offset also depends on daylight_saving_time
37
+ # use libc to transform timezones, does not work on windows,
38
+ # there is a pure ruby implementation, too
39
+ ENV["TZ"] = user.timezone # eg "Europe/Berlin"
40
+ offset = Time.at(secs_since_epoch).utc_offset
41
+ ENV["TZ"] = "UTC"
42
+ return offset
43
+ end
44
+
45
+ def time_series(inverter, year, day_of_year)
46
+ chunk = Model::MeasurementChunk.find(:first, :conditions => ["device_id=? and time_year=? and time_day_of_year=?", inverter.id, year, day_of_year])
47
+ result= {}
48
+ return result unless chunk
49
+ # ENV["TZ"] must be set, does not work on windows
50
+ utc_offset = chunk.measurements.empty? ? 0 : Time.at(chunk.measurements.first.time).utc_offset
51
+ keys = chunk.meta.collect {|m| m.first} - ["time"]
52
+ keys.each {
53
+ |key|
54
+ result[key] = chunk.measurements.collect {
55
+ |m|
56
+ [(m.time + utc_offset) * 1000, m[key]]
57
+ }
58
+ }
59
+ return result
60
+ end
61
+
62
+ def plant_aggregates(plant, days)
63
+ result = {}
64
+ plant.aggrules.keys.each { |k| result[k] = [] }
65
+ daymillis = 3600*24*1000
66
+ # TODO: check for changed aggrules
67
+ days.each {
68
+ | a |
69
+ year = a.first
70
+ yday = a.second
71
+ year_start_millis = Time.utc(year, "jan", 1, 12, 0, 0).tv_sec * 1000
72
+ time_millis = year_start_millis + (yday-1)*daymillis
73
+ chunk = Model::MeasurementAggregate.find(:first, :conditions => ["plant_id=? and time_year=? and time_day_of_year=?", plant.id, year, yday])
74
+ plant.aggrules.keys.each {
75
+ |key|
76
+ value = 0
77
+ if (chunk) then
78
+ v = chunk.data[key]
79
+ value = v if v
80
+ end
81
+ result[key] << [time_millis, value]
82
+ }
83
+ }
84
+ return result
85
+ end
86
+
87
+ end
88
+
89
+ module Monthhelper
90
+ def days_of_month(year, month)
91
+ t = Time.utc(year, month, 1, 12, 0, 0)
92
+ result = [t]
93
+ while (true)
94
+ t += 3600*24
95
+ break if t.month != month
96
+ result << t
97
+ end
98
+ return result
99
+ end
100
+ end
101
+
102
+ class DayOfYearConverter
103
+ def initialize(year, days_of_year)
104
+ @year = year
105
+ start = Time.utc(year, 1, 1)
106
+ @days_of_year = days_of_year.collect { |d| secs_of_year = (d - 1) * 3600*24 ; start + secs_of_year }
107
+ logger.debug "DayOfYearConverter: Added #{@days_of_year.size} days for year #{@year}"
108
+ end
109
+
110
+ def months
111
+ logger.debug "DayOfYearConverter: Searching for months in #{@year}, #{self}"
112
+ months = @days_of_year.collect{|d| d.month }
113
+ months.uniq!
114
+ months
115
+ end
116
+
117
+ def days(month)
118
+ logger.debug "DayOfYearConverter: Searching days for #{month} in #{@year}, #{self}"
119
+ @days_of_year.select{|d| d.month == month}.collect{|d| d.day}
120
+ end
121
+
122
+ def logger
123
+ return ActiveRecord::Base.logger
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,37 @@
1
+ # Copyright (C) 2010 Markus Barchfeld, Vassilis Rizopoulos
2
+ #
3
+ # This program is free software; you can redistribute it and/or modify it under
4
+ # the terms of the GNU General Public License as published by the Free Software
5
+ # Foundation; either version 3 of the License, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but WITHOUT
9
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along with
13
+ # this program; if not, see <http://www.gnu.org/licenses/>.
14
+ $:.unshift File.join(File.dirname(__FILE__),"..")
15
+
16
+ module PlantWatchdog
17
+ class ConnectionError<RuntimeError
18
+ end
19
+
20
+ module ActiveRecordConnections
21
+ #Establishes an ActiveRecord connection
22
+ def connect_to_active_record cfg,logger
23
+ conn=connect(cfg,logger)
24
+ end
25
+ private
26
+ #Establishes an active record connection using the cfg hash
27
+ #There is only a rudimentary check to ensure the integrity of cfg
28
+ def connect cfg,logger
29
+ if cfg[:adapter] && cfg[:database]
30
+ logger.debug("Connecting to #{cfg[:database]}")
31
+ return ActiveRecord::Base.establish_connection(cfg)
32
+ else
33
+ raise ConnectionError,"Erroneous database configuration. Missing :adapter and/or :database"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ gem 'activerecord',"2.3.5"
2
+ #gem 'ar-extensions',"0.9.2"
3
+ gem 'sinatra','0.9.4'
4
+ #used for the logger setup code
5
+ gem 'patir','0.6.4'
@@ -0,0 +1,76 @@
1
+ # Copyright (C) 2010 Markus Barchfeld, Vassilis Rizopoulos
2
+ #
3
+ # This program is free software; you can redistribute it and/or modify it under
4
+ # the terms of the GNU General Public License as published by the Free Software
5
+ # Foundation; either version 3 of the License, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but WITHOUT
9
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along with
13
+ # this program; if not, see <http://www.gnu.org/licenses/>.
14
+ $:.unshift File.join(File.dirname(__FILE__),"..")
15
+ #redirect $0 because otherwise sinatra/main.rb tries to parse the command line
16
+ $0="plantwatchdog"
17
+ require 'plantwatchdog/sinatra'
18
+ require 'plantwatchdog/db'
19
+ require 'optparse'
20
+ require 'patir/base'
21
+ require 'yaml'
22
+
23
+ module PlantWatchdog
24
+ module Version
25
+ MAJOR=0
26
+ MINOR=0
27
+ TINY=1
28
+ STRING=[ MAJOR, MINOR, TINY ].join( "." )
29
+ end
30
+
31
+ #Parses the command line arguments
32
+ def self.parse_command_line args
33
+ options = OpenStruct.new
34
+ options.config_file = File.join(File.dirname(__FILE__),'..', '..', 'config', 'app_config.yaml')
35
+ options.aggregate = false
36
+ args.options do |opt|
37
+ opt.on("Usage:")
38
+ opt.on("plantwatchdog [options]")
39
+ opt.on("Options:")
40
+ opt.on("--debug", "-d","Turns on debug messages") { $DEBUG=true }
41
+ opt.on("-v", "--version","Displays the version") { $stdout.puts("v#{Version::STRING}");exit 0 }
42
+ opt.on("--config_file FILE", "-c FILE", "The config file") { |file| options.config_file = file }
43
+ opt.on("--aggregate", "-a", "Run daily data aggregation") { options.aggregate = true }
44
+ opt.on("--create_solar", nil, "Create database with sample data from a solar generator") { options.createsolar = true }
45
+ opt.on("--help", "-h", "-?", "This text") { $stdout.puts opt; exit 0 }
46
+ opt.parse!
47
+ end
48
+ options
49
+ end
50
+ #Starts App
51
+ def self.start
52
+ logger=Patir.setup_logger
53
+ options=parse_command_line(ARGV)
54
+ if File.exists?(options.config_file)
55
+ config=YAML.load(File.read(options.config_file))
56
+ config[:logger]=logger
57
+ else
58
+ logger.fatal("Cannot find #{options.config_file}")
59
+ exit 1
60
+ end
61
+ extend PlantWatchdog::ActiveRecordConnections
62
+ self.connect_to_active_record(config[:database_configuration],logger)
63
+ if options.createsolar then
64
+ $:.unshift File.join(File.dirname(__FILE__),"..","..")
65
+ require 'sample/solar/create_solar'
66
+ PlantWatchdog::CreateSolar.create
67
+ elsif options.aggregate then
68
+ require 'plantwatchdog/aggregation'
69
+ Aggregation::Runner.new.run
70
+ else
71
+ PlantWatchdog::UI::SinatraApp.run!
72
+ end
73
+ end
74
+ end
75
+
76
+ PlantWatchdog.start
@@ -0,0 +1,442 @@
1
+ # Copyright (C) 2010 Markus Barchfeld, Vassilis Rizopoulos
2
+ #
3
+ # This program is free software; you can redistribute it and/or modify it under
4
+ # the terms of the GNU General Public License as published by the Free Software
5
+ # Foundation; either version 3 of the License, or (at your option) any later
6
+ # version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but WITHOUT
9
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along with
13
+ # this program; if not, see <http://www.gnu.org/licenses/>.
14
+
15
+ $:.unshift File.join(File.dirname(__FILE__),"..")
16
+ require 'plantwatchdog/gems' # lock versions
17
+ require 'active_record'
18
+
19
+ #require 'benchmark'
20
+
21
+ #this fixes the AR Logger hack that annoys me sooooo much
22
+ class Logger
23
+ private
24
+ def format_message(severity, datetime, progname, msg)
25
+ (@formatter || @default_formatter).call(severity, datetime, progname, msg)
26
+ end
27
+ end
28
+
29
+ ActiveRecord::Base.logger = Logger.new(STDERR)
30
+ ActiveRecord::Base.logger.level = Logger::DEBUG
31
+
32
+ module ActiveRecord
33
+ class BaseWithoutTable < Base
34
+ self.abstract_class = true
35
+ def create_or_update
36
+ errors.empty?
37
+ end
38
+
39
+ def == other
40
+ return false if other.class != self.class
41
+ attributes.values.eql?(other.attributes.values)
42
+ end
43
+
44
+ def hash
45
+ attributes.values.hash
46
+ end
47
+
48
+ class << self
49
+ def columns()
50
+ @columns ||= []
51
+ end
52
+
53
+ def column(name, sql_type = nil, default = nil, null = true)
54
+ columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
55
+ reset_column_information
56
+ end
57
+
58
+ # Do not reset @columns
59
+ def reset_column_information
60
+ generated_methods.each { |name| undef_method(name) }
61
+ @column_names = @columns_hash = @content_columns = @dynamic_methods_hash = @read_methods = nil
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ module PlantWatchdog
68
+ module Model
69
+ class Schema<ActiveRecord::Migration
70
+ def self.up
71
+
72
+ create_table :users do |t|
73
+ t.column :id, :integer # prim key
74
+ t.column :timezone, :string # for converting UTC to local time
75
+ t.column :password, :string
76
+ t.column :name, :string
77
+ # *_year is a performance optimization: it is used from the UI to find the years
78
+ # for which there is data avalailable.
79
+ # it could always be restored by looking at the available measurement_chunks
80
+ t.column :start_year, :integer
81
+ t.column :end_year, :integer
82
+ end
83
+
84
+ create_table :plants do |t|
85
+ t.column :id, :integer # prim key
86
+ t.column :user_id, :integer # foreign key
87
+ t.column :aggregationrule_id, :integer # foreign key
88
+ end
89
+
90
+ # single table inheritance: inverter and sunmeter are devices
91
+ create_table :devices do |t|
92
+ t.column :type, :text # single table inheritance
93
+ t.column :plant_id, :integer # foreign key
94
+ t.column :aggregationrule_id, :integer # foreign key
95
+ t.column :metadata_id, :integer # foreign key
96
+ t.column :name, :string
97
+ # the unique id is needed for the synchronization in order to assign measurements to a device
98
+ # an example for a unique would be the serial number of an inverter
99
+ t.column :unique_id, :string
100
+ end
101
+
102
+ create_table :measurement_chunks do |t|
103
+ t.column :type, :text # for single table inheritance
104
+ t.column :device_id, :integer
105
+ t.column :plant_id, :integer # a Measurement Aggregate can belong to a plant instead of a device
106
+
107
+ # the day on which data was collected according to local time
108
+ t.column :time_year, :integer
109
+ t.column :time_day_of_year, :integer
110
+
111
+ t.column :data, :text, :limit => 16000000 # raw csv, limit to 16MB
112
+ t.column :metadata_id, :integer # description of the columns of data
113
+ end
114
+
115
+ # metadata, used for describing the content of measurements and for describing aggregation rules
116
+ # description must not be altered. Instead, create new records
117
+ # whenever the column description changes
118
+ create_table :metadata do |t|
119
+ t.column :description, :string # serialized representation (JSON)
120
+ end
121
+
122
+ # performance only, could be retrieved by scanning the measurements table
123
+ create_table :syncs do |t|
124
+ t.column :user_id, :integer # foreign key
125
+ t.column :device_id, :integer
126
+ t.column :last_time, :integer # utc, epoch secs
127
+ end
128
+
129
+ end # self.up
130
+
131
+ def self.down
132
+ drop_table :syncs
133
+ drop_table :devices
134
+ drop_table :plants
135
+ drop_table :users
136
+ drop_table :measurement_chunks
137
+ drop_table :metadata
138
+ end
139
+ end
140
+
141
+ class User<ActiveRecord::Base
142
+ # environment_measurements is supposed to contain a lot of data so we do
143
+ # not want to have it O/R mapped
144
+ #has_many :environment_measurements
145
+ has_one :plant
146
+ has_many :syncs
147
+ def data_updated time_year
148
+ new_start_year = time_year if start_year.nil? or time_year < start_year
149
+ new_end_year = time_year if end_year.nil? or time_year > end_year
150
+ if (new_start_year or new_end_year)
151
+ self.start_year = new_start_year if new_start_year
152
+ self.end_year = new_end_year if new_end_year
153
+ p save
154
+ end
155
+ end
156
+ end
157
+
158
+ module MetadataOwner
159
+ def meta=(array)
160
+ return if metadata and metadata.description == array
161
+ self.metadata = Metadata.new
162
+ metadata.description = array
163
+ end
164
+
165
+ def meta
166
+ metadata.description
167
+ end
168
+ end
169
+
170
+ module AggregationRuleOwner
171
+ # set a dictionary defining the aggregation rules
172
+ # dict-keys are the names of the resulting columns
173
+ # dict-values defines the aggregation, the first entry is the name of the agg function, following (optional)
174
+ # are parameters for the function
175
+ def aggrules=(dict)
176
+ return if aggregationrule and aggregationrule.description == dict
177
+ self.aggregationrule = Metadata.new
178
+ self.aggregationrule.description = dict
179
+ end
180
+
181
+ def aggrules
182
+ self.aggregationrule ? self.aggregationrule.description : {};
183
+ end
184
+ end
185
+
186
+ class Plant<ActiveRecord::Base
187
+ has_many :devices
188
+ belongs_to :user
189
+ belongs_to :aggregationrule, :class_name => 'Metadata'
190
+ include AggregationRuleOwner
191
+ end
192
+
193
+ class Device<ActiveRecord::Base
194
+ belongs_to :plant
195
+ belongs_to :aggregationrule, :class_name => 'Metadata'
196
+ has_one :sync
197
+ belongs_to :metadata#, :autosave => :true
198
+ include MetadataOwner
199
+ include AggregationRuleOwner
200
+ end
201
+
202
+ module MeasurementClassExtension
203
+ def metadata
204
+ @metadata
205
+ end
206
+
207
+ def metadata= m
208
+ @metadata = m
209
+ end
210
+ end
211
+
212
+ class Measurement < ActiveRecord::BaseWithoutTable
213
+
214
+ column :time_year, :integer
215
+ column :time_day_of_year, :integer
216
+ column :time_seconds_of_day, :integer
217
+ extend MeasurementClassExtension
218
+ # set from a time object and split into
219
+ # year, day of year and seconds of day
220
+ # time t: seconds since epoch in utc
221
+ def time= epoch_secs
222
+ t = Time.at(epoch_secs.to_i) # this is local time, mind to set ENV["TZ"]
223
+ write_attribute(:time_year, t.year)
224
+ write_attribute(:time_day_of_year, t.yday)
225
+ write_attribute(:time_seconds_of_day, t.sec + t.min*60 + t.hour*3600)
226
+ write_attribute("time", epoch_secs)
227
+ end
228
+
229
+ def time
230
+ t = read_attribute("time")
231
+ return t if t
232
+ t = Time.utc(time_year) + ((time_day_of_year-1)*3600*24) + time_seconds_of_day
233
+ return t.tv_sec
234
+ end
235
+
236
+ def line= original_line
237
+ @line= original_line
238
+ end
239
+
240
+ def line
241
+ @line = to_csv unless @line
242
+ @line
243
+ end
244
+
245
+ def to_csv
246
+ meta = self.class.metadata.description
247
+ logger.debug "to_csv, using metadata " + meta.to_s
248
+ result = meta.collect { | coldesc | self[coldesc.first].to_s }.join(",")
249
+ logger.debug "to_csv, result " + result
250
+ result
251
+ end
252
+
253
+ # create a list of Measurement instances from csv lines
254
+ # clazz is a sublass of Measurement
255
+ # csv must understand readlines, eg. be an instance of IO or StringIO
256
+ # the first column is a time column, other columns are defined by fields
257
+ def self.from_CSV clazz, fields, csv
258
+ # already converted ?
259
+ return csv if csv.class == Array
260
+ result = []
261
+ return result if csv.nil?
262
+ csv.each {
263
+ |l|
264
+ v = l.split(",")
265
+ m = clazz.new
266
+ m.line = l.chomp
267
+ fields.each_index {|i| m.send(fields[i], v[i])}
268
+ result << m
269
+ }
270
+ return result
271
+ end
272
+
273
+ def self.to_csv measurements
274
+ measurements.collect { |m| m.line }.join "\n"
275
+ end
276
+
277
+ def self.parse_csv metadata, csv
278
+ setters = metadata.dataclass.columns.collect{ |col| (col.name + "=").to_sym }
279
+ result = self.from_CSV metadata.dataclass, setters, csv
280
+ logger.debug "Read #{result.size} environment measurement entries from CSV"
281
+ result
282
+ end
283
+
284
+ def self.partition_by_day measurements
285
+ # assuming measurements is a list of measurements sorted by time
286
+ result = {}
287
+ # Gruppenwechsel in time_day_of_year
288
+ changes = (1..measurements.size-1).select { |i| measurements[i-1].time_day_of_year != measurements[i].time_day_of_year }
289
+ changes << measurements.size unless measurements.empty?
290
+ changes.inject(0) { |s,e|
291
+ m = measurements[s];
292
+ # a hack to dismiss data which has been tagged with year 2000 which is the default
293
+ # for a fritz!box before it has polled the time from a ntp server
294
+ result[[m.time_year, m.time_day_of_year]] = measurements.slice(s,e-s) if m.time_year>2000;
295
+ e
296
+ }
297
+ return result
298
+ end
299
+
300
+ end
301
+
302
+ class AbstractMeasurementChunk < ActiveRecord::Base
303
+ set_table_name :measurement_chunks
304
+ serialize :data # MeasurementAggregate saves data as hash
305
+
306
+ belongs_to :device
307
+
308
+ belongs_to :metadata#, :autosave => :true
309
+ include MetadataOwner
310
+ end
311
+
312
+ class MeasurementChunk < AbstractMeasurementChunk
313
+ def measurements
314
+ @measurements = Model::Measurement.parse_csv(metadata, data) unless @measurements
315
+ @measurements
316
+ end
317
+
318
+ def append_measurements new_measurements
319
+ # new_measurements are supposed to be taken at the same day as the existing data
320
+ logger.debug("Appending #{new_measurements.size} measurement(s).")
321
+ logger.debug(measurements)
322
+ self.measurements = measurements | new_measurements
323
+ end
324
+
325
+ def measurements= new_measurements
326
+ # all measurements are supposed to be from one day
327
+ return if new_measurements.size == 0
328
+ logger.debug("Setting #{new_measurements.size} measurement(s) for #{time_year}-#{time_day_of_year}.")
329
+ @measurements = new_measurements
330
+ self.data = Measurement.to_csv(new_measurements)
331
+ device.plant.user.data_updated time_year
332
+ end
333
+
334
+ def self.save_measurements device, measurements
335
+ return if measurements.empty?
336
+ Measurement.partition_by_day(measurements).each_pair {
337
+ | key, measurements |
338
+ chunk = MeasurementChunk.find(:first, :conditions => ["device_id=? and time_year=? and time_day_of_year=?", device.id, key.first, key.last] )
339
+ if chunk.nil? then
340
+ chunk = MeasurementChunk.new
341
+ chunk.device = device
342
+ chunk.metadata = device.metadata
343
+ chunk.time_year = key.first
344
+ chunk.time_day_of_year = key.last
345
+ end
346
+ chunk.append_measurements(measurements)
347
+ chunk.save
348
+ }
349
+ end
350
+ end
351
+
352
+ class MeasurementAggregate < AbstractMeasurementChunk
353
+ belongs_to :plant
354
+ end
355
+
356
+ class Metadata < ActiveRecord::Base
357
+ set_table_name :metadata
358
+ has_one :device
359
+ #has_one :plant
360
+ @@description_to_dataclass = {}
361
+
362
+ def dataclass
363
+ logger.debug "Accessing dataclass for metadata " + description.to_s
364
+ if ! @@description_to_dataclass.has_key? description then
365
+ logger.debug "Creating new dataclass for metadata " + description.to_s
366
+ dataclass = Class.new(Model::Measurement)
367
+ description.each { |args| dataclass.column *args }
368
+ logger.debug dataclass.class
369
+ dataclass.metadata = self
370
+ @@description_to_dataclass[description] = dataclass
371
+ end
372
+ @@description_to_dataclass[description]
373
+ end
374
+
375
+ def description
376
+ serialized = read_attribute("description")
377
+ ActiveSupport::JSON.decode(serialized) unless serialized.nil?
378
+ end
379
+
380
+ def description=(arg)
381
+ # the description is immutable
382
+ # once an instance of metadata has been saved, no update is allowed
383
+ raise "description is immutable" if read_attribute("description")
384
+ serialized = arg
385
+ serialized = ActiveSupport::JSON.encode(serialized) unless serialized.is_a? String
386
+ write_attribute("description",serialized)
387
+ end
388
+
389
+ end
390
+
391
+ class SyncError < StandardError
392
+ end
393
+
394
+ class SyncManager
395
+ def sync(user, device_unique_id, csv)
396
+ # assuming that csv is a timeseries, i.e. ordered by time
397
+ device = device(user, device_unique_id)
398
+ data = Measurement.parse_csv(device.metadata, csv)
399
+ if (data.size == 0)
400
+ logger.info "The data received from user #{user.name} (id=#{user.id}) is invalid, either it is empty or can not be parsed"
401
+ return data.size
402
+ end
403
+ sync = device.sync
404
+ unless sync
405
+ logger.info "Creating new sync for user '#{user.name}' and device unique id '#{device_unique_id}'"
406
+ sync = Sync.new
407
+ sync.device = device
408
+ sync.last_time = 0
409
+ end
410
+ if (data.first.time <= sync.last_time)
411
+ raise SyncError.new("The period is already sync'ed.")
412
+ end
413
+ MeasurementChunk.save_measurements(device, data)
414
+ sync.last_time = data.last.time
415
+ sync.save
416
+ return data.size
417
+ end
418
+
419
+ def device(user, device_unique_id)
420
+ raise SyncError.new("The user must create a plant and devices before uploading data") if user.plant.nil?
421
+ devices = user.plant.devices.select { |i| i.unique_id == device_unique_id }
422
+ raise SyncError.new("Could not identify device '#{device_unique_id}' of user '#{user.name}'. Found '#{devices.size}' devices.") if devices.size != 1
423
+ device = devices.first
424
+ end
425
+
426
+ def latest(user, device_unique_id)
427
+ device = device(user, device_unique_id)
428
+ device.sync ? device.sync.last_time : 0;
429
+ end
430
+
431
+ def logger
432
+ return ActiveRecord::Base.logger
433
+ end
434
+ end
435
+
436
+ class Sync<ActiveRecord::Base
437
+ belongs_to :user
438
+ belongs_to :device
439
+ end
440
+
441
+ end
442
+ end