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.
- data/History.txt +4 -0
- data/License.txt +674 -0
- data/Manifest.txt +42 -0
- data/README.txt +91 -0
- data/Rakefile +22 -0
- data/bin/plantwatchdog +8 -0
- data/bin/upload_measurements +2 -0
- data/config.ru +35 -0
- data/config/app_config.yaml +10 -0
- data/lib/plantwatchdog/aggregation.rb +220 -0
- data/lib/plantwatchdog/aggregation_methods.rb +90 -0
- data/lib/plantwatchdog/data.rb +126 -0
- data/lib/plantwatchdog/db.rb +37 -0
- data/lib/plantwatchdog/gems.rb +5 -0
- data/lib/plantwatchdog/main.rb +76 -0
- data/lib/plantwatchdog/model.rb +442 -0
- data/lib/plantwatchdog/sinatra.rb +206 -0
- data/public/images/arrow-down.gif +0 -0
- data/public/images/arrow-left.gif +0 -0
- data/public/images/arrow-right.gif +0 -0
- data/public/images/arrow-up.gif +0 -0
- data/public/images/spinner.gif +0 -0
- data/public/images/tabs.png +0 -0
- data/public/js/customflot.js +120 -0
- data/public/js/jquery-1.3.2.min.js +19 -0
- data/public/js/jquery.flot.crosshair.js +157 -0
- data/public/js/jquery.flot.js +2119 -0
- data/public/js/jquery.flot.navigate.js +272 -0
- data/public/js/jquery.flot.selection.js +299 -0
- data/public/js/select-chain.js +71 -0
- data/public/js/tools.tabs-1.0.4.js +285 -0
- data/public/tabs.css +87 -0
- data/sample/solar/create_solar.rb +31 -0
- data/sample/solar/measurements/client.sqlite3 +0 -0
- data/sample/solar/static/devices.yml +17 -0
- data/sample/solar/static/metadata.yml +30 -0
- data/sample/solar/static/plants.yml +3 -0
- data/sample/solar/static/users.yml +4 -0
- data/sample/solar/upload_measurements +26 -0
- data/templates/graph.erb +134 -0
- data/templates/index.erb +24 -0
- data/templates/monthly_graph.erb +41 -0
- data/test/test_aggregation.rb +161 -0
- data/test/test_aggregation_methods.rb +50 -0
- data/test/test_base.rb +83 -0
- data/test/test_data.rb +118 -0
- data/test/test_model.rb +142 -0
- data/test/test_sync.rb +71 -0
- data/test/test_web.rb +87 -0
- 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,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
|