timescaledb 0.2.0 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +41 -9
- data/bin/console +2 -2
- data/bin/tsdb +3 -3
- data/docs/command_line.md +178 -0
- data/docs/img/lttb_example.png +0 -0
- data/docs/img/lttb_sql_vs_ruby.gif +0 -0
- data/docs/img/lttb_zoom.gif +0 -0
- data/docs/index.md +61 -0
- data/docs/migrations.md +69 -0
- data/docs/models.md +78 -0
- data/docs/toolkit.md +394 -0
- data/docs/toolkit_lttb_tutorial.md +557 -0
- data/docs/toolkit_lttb_zoom.md +357 -0
- data/docs/videos.md +16 -0
- data/examples/all_in_one/all_in_one.rb +40 -6
- data/examples/all_in_one/benchmark_comparison.rb +108 -0
- data/examples/all_in_one/caggs.rb +93 -0
- data/examples/all_in_one/query_data.rb +78 -0
- data/examples/ranking/config/initializers/timescale.rb +1 -2
- data/examples/toolkit-demo/compare_volatility.rb +64 -0
- data/examples/toolkit-demo/lttb/README.md +15 -0
- data/examples/toolkit-demo/lttb/lttb.rb +92 -0
- data/examples/toolkit-demo/lttb/lttb_sinatra.rb +139 -0
- data/examples/toolkit-demo/lttb/lttb_test.rb +21 -0
- data/examples/toolkit-demo/lttb/views/index.erb +27 -0
- data/examples/toolkit-demo/lttb-zoom/README.md +13 -0
- data/examples/toolkit-demo/lttb-zoom/lttb_zoomable.rb +90 -0
- data/examples/toolkit-demo/lttb-zoom/views/index.erb +33 -0
- data/lib/timescaledb/acts_as_time_vector.rb +18 -0
- data/lib/timescaledb/dimensions.rb +1 -0
- data/lib/timescaledb/hypertable.rb +5 -1
- data/lib/timescaledb/migration_helpers.rb +30 -11
- data/lib/timescaledb/schema_dumper.rb +4 -1
- data/lib/timescaledb/stats_report.rb +1 -1
- data/lib/timescaledb/toolkit/helpers.rb +20 -0
- data/lib/timescaledb/toolkit/time_vector.rb +66 -0
- data/lib/timescaledb/toolkit.rb +3 -0
- data/lib/timescaledb/version.rb +1 -1
- data/lib/timescaledb.rb +1 -0
- data/mkdocs.yml +33 -0
- metadata +30 -4
- data/examples/all_in_one/Gemfile +0 -11
- data/examples/all_in_one/Gemfile.lock +0 -51
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'bundler/inline' #require only what you need
|
2
|
+
|
3
|
+
gemfile(true) do
|
4
|
+
gem 'timescaledb', path: '../..'
|
5
|
+
gem 'pry'
|
6
|
+
gem 'faker'
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'pp'
|
10
|
+
# ruby all_in_one.rb postgres://user:pass@host:port/db_name
|
11
|
+
ActiveRecord::Base.establish_connection( ARGV.last)
|
12
|
+
|
13
|
+
# Simple example
|
14
|
+
class Event < ActiveRecord::Base
|
15
|
+
self.primary_key = nil
|
16
|
+
acts_as_hypertable
|
17
|
+
|
18
|
+
# If you want to override the automatic assingment of the `created_at ` time series column
|
19
|
+
def self.timestamp_attributes_for_create_in_model
|
20
|
+
[]
|
21
|
+
end
|
22
|
+
def self.timestamp_attributes_for_update_in_model
|
23
|
+
[]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Setup Hypertable as in a migration
|
28
|
+
ActiveRecord::Base.connection.instance_exec do
|
29
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
30
|
+
|
31
|
+
drop_table(Event.table_name) if Event.table_exists?
|
32
|
+
|
33
|
+
hypertable_options = {
|
34
|
+
time_column: 'created_at',
|
35
|
+
chunk_time_interval: '7 day',
|
36
|
+
compress_segmentby: 'identifier',
|
37
|
+
compression_interval: '7 days'
|
38
|
+
}
|
39
|
+
|
40
|
+
create_table(:events, id: false, hypertable: hypertable_options) do |t|
|
41
|
+
t.string :identifier, null: false
|
42
|
+
t.jsonb :payload
|
43
|
+
t.timestamps
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def generate_fake_data(total: 100_000)
|
48
|
+
time = 1.month.ago
|
49
|
+
total.times.flat_map do
|
50
|
+
identifier = %w[sign_up login click scroll logout view]
|
51
|
+
time = time + rand(60).seconds
|
52
|
+
{
|
53
|
+
created_at: time,
|
54
|
+
updated_at: time,
|
55
|
+
identifier: identifier.sample,
|
56
|
+
payload: {
|
57
|
+
"name" => Faker::Name.name,
|
58
|
+
"email" => Faker::Internet.email
|
59
|
+
}
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
batch = generate_fake_data total: 10_000
|
66
|
+
ActiveRecord::Base.logger = nil
|
67
|
+
Event.insert_all(batch, returning: false)
|
68
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
69
|
+
|
70
|
+
pp Event.previous_month.count
|
71
|
+
pp Event.previous_week.count
|
72
|
+
pp Event.previous_month.group('identifier').count
|
73
|
+
pp Event.previous_week.group('identifier').count
|
74
|
+
|
75
|
+
pp Event
|
76
|
+
.previous_month
|
77
|
+
.select("time_bucket('1 day', created_at) as time, identifier, count(*)")
|
78
|
+
.group("1,2").map(&:attributes)
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'timescaledb'
|
3
|
+
|
4
|
+
|
5
|
+
# Compare volatility processing in Ruby vs SQL.
|
6
|
+
class Measurement < ActiveRecord::Base
|
7
|
+
acts_as_hypertable time_column: "ts"
|
8
|
+
acts_as_time_vector segment_by: "device_id", value_column: "val"
|
9
|
+
|
10
|
+
scope :volatility_sql, -> do
|
11
|
+
select("device_id, timevector(#{time_column}, #{value_column}) -> sort() -> delta() -> abs() -> sum() as volatility")
|
12
|
+
.group("device_id")
|
13
|
+
end
|
14
|
+
|
15
|
+
scope :volatility_ruby, -> {
|
16
|
+
volatility = Hash.new(0)
|
17
|
+
previous = Hash.new
|
18
|
+
find_all do |measurement|
|
19
|
+
device_id = measurement.device_id
|
20
|
+
if previous[device_id]
|
21
|
+
delta = (measurement.val - previous[device_id]).abs
|
22
|
+
volatility[device_id] += delta
|
23
|
+
end
|
24
|
+
previous[device_id] = measurement.val
|
25
|
+
end
|
26
|
+
volatility
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
ActiveRecord::Base.establish_connection ENV["PG_URI"]
|
31
|
+
ActiveRecord::Base.connection.add_toolkit_to_search_path!
|
32
|
+
|
33
|
+
|
34
|
+
ActiveRecord::Base.connection.instance_exec do
|
35
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
36
|
+
|
37
|
+
unless Measurement.table_exists?
|
38
|
+
hypertable_options = {
|
39
|
+
time_column: 'ts',
|
40
|
+
chunk_time_interval: '1 day',
|
41
|
+
}
|
42
|
+
create_table :measurements, hypertable: hypertable_options, id: false do |t|
|
43
|
+
t.integer :device_id
|
44
|
+
t.decimal :val
|
45
|
+
t.timestamp :ts
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
if Measurement.count.zero?
|
51
|
+
ActiveRecord::Base.connection.execute(<<~SQL)
|
52
|
+
INSERT INTO measurements (ts, device_id, val)
|
53
|
+
SELECT ts, device_id, random()*80
|
54
|
+
FROM generate_series(TIMESTAMP '2022-01-01 00:00:00',
|
55
|
+
TIMESTAMP '2022-02-01 00:00:00',
|
56
|
+
INTERVAL '5 minutes') AS g1(ts),
|
57
|
+
generate_series(0, 5) AS g2(device_id);
|
58
|
+
SQL
|
59
|
+
end
|
60
|
+
|
61
|
+
Benchmark.bm do |x|
|
62
|
+
x.report("ruby") { Measurement.volatility_ruby }
|
63
|
+
x.report("sql") { Measurement.volatility_sql.map(&:attributes) }
|
64
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# LTTB examples
|
2
|
+
|
3
|
+
This folder contains a few ideas to explore and learn more about the lttb algorithm.
|
4
|
+
|
5
|
+
There is a [./lttb.rb](./lttb.rb) file that is the Ruby implementation of lttb
|
6
|
+
and also contains the related [./lttb_test.rb](./lttb_test.rb) file that
|
7
|
+
verifies the same example from the Timescale Toolkit [implementation](https://github.com/timescale/timescaledb-toolkit/blob/6ee2ea1e8ff64bab10b90bdf4cd4b0f7ed763934/extension/src/lttb.rs#L512-L530).
|
8
|
+
|
9
|
+
The [./lttb_sinatra.rb](./lttb_sinatra.rb) is a small webserver that compares
|
10
|
+
the SQL vs Ruby implementation. It also uses the [./views](./views) folder which
|
11
|
+
contains the view rendering part.
|
12
|
+
|
13
|
+
You can learn more by reading the [LTTB tutorial](https://jonatas.github.io/timescaledb/toolkit_lttb_tutorial/).
|
14
|
+
|
15
|
+
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Triangle
|
2
|
+
module_function
|
3
|
+
def area(a, b, c)
|
4
|
+
(ax, ay),(bx,by),(cx,cy) = a,b,c
|
5
|
+
(
|
6
|
+
(ax - cx).to_f * (by - ay) -
|
7
|
+
(ax - bx).to_f * (cy - ay)
|
8
|
+
).abs * 0.5
|
9
|
+
end
|
10
|
+
end
|
11
|
+
class Lttb
|
12
|
+
class << self
|
13
|
+
def avg(array)
|
14
|
+
array.sum.to_f / array.size
|
15
|
+
end
|
16
|
+
|
17
|
+
def downsample(data, threshold)
|
18
|
+
new(data, threshold).downsample
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :data, :threshold
|
23
|
+
def initialize(data, threshold)
|
24
|
+
fail 'data is not an array' unless data.is_a? Array
|
25
|
+
fail "threshold should be >= 2. It's #{threshold}." if threshold < 2
|
26
|
+
@data = data
|
27
|
+
@threshold = threshold
|
28
|
+
end
|
29
|
+
|
30
|
+
def downsample
|
31
|
+
case @data.first.first
|
32
|
+
when Time, DateTime, Date
|
33
|
+
transformed_dates = true
|
34
|
+
dates_to_numbers()
|
35
|
+
end
|
36
|
+
process.tap do |downsampled|
|
37
|
+
numbers_to_dates(downsampled) if transformed_dates
|
38
|
+
end
|
39
|
+
end
|
40
|
+
private
|
41
|
+
|
42
|
+
def process
|
43
|
+
return data if threshold >= data.size || threshold == 0
|
44
|
+
|
45
|
+
sampled = [data.first, data.last] # Keep first and last point. append in the middle.
|
46
|
+
point_index = 0
|
47
|
+
|
48
|
+
(threshold - 2).times do |i|
|
49
|
+
step = [((i+1.0) * bucket_size).to_i, data.size].min
|
50
|
+
next_point = (i * bucket_size).to_i + 1
|
51
|
+
|
52
|
+
break if next_point > data.size - 2
|
53
|
+
|
54
|
+
points = data[step, slice]
|
55
|
+
avg_x = Lttb.avg(points.map(&:first)).to_i
|
56
|
+
avg_y = Lttb.avg(points.map(&:last))
|
57
|
+
|
58
|
+
max_area = -1.0
|
59
|
+
|
60
|
+
(next_point...(step + 1)).each do |idx|
|
61
|
+
area = Triangle.area(data[point_index], data[idx], [avg_x, avg_y])
|
62
|
+
|
63
|
+
if area > max_area
|
64
|
+
max_area = area
|
65
|
+
next_point = idx
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
sampled.insert(-2, data[next_point])
|
70
|
+
point_index = next_point
|
71
|
+
end
|
72
|
+
|
73
|
+
sampled
|
74
|
+
end
|
75
|
+
|
76
|
+
def bucket_size
|
77
|
+
@bucket_size ||= ((data.size - 2.0) / (threshold - 2.0))
|
78
|
+
end
|
79
|
+
|
80
|
+
def slice
|
81
|
+
@slice ||= bucket_size.to_i
|
82
|
+
end
|
83
|
+
|
84
|
+
def dates_to_numbers
|
85
|
+
@start_date = data[0][0].dup
|
86
|
+
data.each{|d| d[0] = d[0] - @start_date }
|
87
|
+
end
|
88
|
+
|
89
|
+
def numbers_to_dates(downsampled)
|
90
|
+
downsampled.each{|d| d[0] = @start_date + d[0]}
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
# ruby lttb.rb postgres://user:pass@host:port/db_name
|
2
|
+
require 'bundler/inline' #require only what you need
|
3
|
+
|
4
|
+
gemfile(true) do
|
5
|
+
gem 'timescaledb', path: '../../..'
|
6
|
+
gem 'pry'
|
7
|
+
gem 'sinatra', require: false
|
8
|
+
gem 'sinatra-reloader', require: false
|
9
|
+
gem 'sinatra-cross_origin', require: false
|
10
|
+
gem 'chartkick'
|
11
|
+
end
|
12
|
+
|
13
|
+
require 'timescaledb/toolkit'
|
14
|
+
require 'sinatra'
|
15
|
+
require 'sinatra/json'
|
16
|
+
require 'sinatra/cross_origin'
|
17
|
+
require 'chartkick'
|
18
|
+
require_relative 'lttb'
|
19
|
+
|
20
|
+
PG_URI = ARGV.last
|
21
|
+
|
22
|
+
VALID_SIZES = %i[small med big]
|
23
|
+
def download_weather_dataset size: :small
|
24
|
+
unless VALID_SIZES.include?(size)
|
25
|
+
fail "Invalid size: #{size}. Valids are #{VALID_SIZES}"
|
26
|
+
end
|
27
|
+
url = "https://timescaledata.blob.core.windows.net/datasets/weather_#{size}.tar.gz"
|
28
|
+
puts "fetching #{size} weather dataset..."
|
29
|
+
system "wget \"#{url}\""
|
30
|
+
puts "done!"
|
31
|
+
end
|
32
|
+
|
33
|
+
def setup size: :small
|
34
|
+
file = "weather_#{size}.tar.gz"
|
35
|
+
download_weather_dataset(size: size) unless File.exists? file
|
36
|
+
puts "extracting #{file}"
|
37
|
+
system "tar -xvzf #{file} "
|
38
|
+
puts "creating data structures"
|
39
|
+
system "psql #{PG_URI} < weather.sql"
|
40
|
+
system %|psql #{PG_URI} -c "\\COPY locations FROM weather_#{size}_locations.csv CSV"|
|
41
|
+
system %|psql #{PG_URI} -c "\\COPY conditions FROM weather_#{size}_conditions.csv CSV"|
|
42
|
+
end
|
43
|
+
|
44
|
+
ActiveRecord::Base.establish_connection(PG_URI)
|
45
|
+
class Location < ActiveRecord::Base
|
46
|
+
self.primary_key = "device_id"
|
47
|
+
|
48
|
+
has_many :conditions, foreign_key: "device_id"
|
49
|
+
end
|
50
|
+
|
51
|
+
class Condition < ActiveRecord::Base
|
52
|
+
acts_as_hypertable time_column: "time"
|
53
|
+
acts_as_time_vector value_column: "temperature", segment_by: "device_id"
|
54
|
+
|
55
|
+
belongs_to :location, foreign_key: "device_id"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Setup Hypertable as in a migration
|
59
|
+
ActiveRecord::Base.connection.instance_exec do
|
60
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
61
|
+
|
62
|
+
unless Condition.table_exists?
|
63
|
+
setup size: :big
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
require 'sinatra/reloader'
|
68
|
+
require 'sinatra/contrib'
|
69
|
+
register Sinatra::Reloader
|
70
|
+
register Sinatra::Contrib
|
71
|
+
include Chartkick::Helper
|
72
|
+
|
73
|
+
set :bind, '0.0.0.0'
|
74
|
+
set :port, 9999
|
75
|
+
|
76
|
+
def conditions
|
77
|
+
device_ids = (1..9).map{|i|"weather-pro-00000#{i}"}
|
78
|
+
Condition
|
79
|
+
.where(device_id: device_ids.first)
|
80
|
+
.order('time')
|
81
|
+
end
|
82
|
+
|
83
|
+
def threshold
|
84
|
+
params[:threshold]&.to_i || 50
|
85
|
+
end
|
86
|
+
|
87
|
+
configure do
|
88
|
+
enable :cross_origin
|
89
|
+
end
|
90
|
+
before do
|
91
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
92
|
+
end
|
93
|
+
|
94
|
+
# routes...
|
95
|
+
options "*" do
|
96
|
+
response.headers["Allow"] = "GET, PUT, POST, DELETE, OPTIONS"
|
97
|
+
response.headers["Access-Control-Allow-Headers"] = "Authorization,
|
98
|
+
Content-Type, Accept, X-User-Email, X-Auth-Token"
|
99
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
100
|
+
200
|
101
|
+
end
|
102
|
+
|
103
|
+
get '/' do
|
104
|
+
headers 'Access-Control-Allow-Origin' => 'https://cdn.jsdelivr.net/'
|
105
|
+
|
106
|
+
erb :index
|
107
|
+
end
|
108
|
+
|
109
|
+
get '/lttb_ruby' do
|
110
|
+
payload = conditions
|
111
|
+
.pluck(:device_id, :time, :temperature)
|
112
|
+
.group_by(&:first)
|
113
|
+
.map do |device_id, data|
|
114
|
+
data.each(&:shift)
|
115
|
+
{
|
116
|
+
name: device_id,
|
117
|
+
data: Lttb.downsample(data, threshold)
|
118
|
+
}
|
119
|
+
end
|
120
|
+
json payload
|
121
|
+
end
|
122
|
+
|
123
|
+
get "/lttb_sql" do
|
124
|
+
downsampled = conditions
|
125
|
+
.lttb(threshold: threshold)
|
126
|
+
.map do |device_id, data|
|
127
|
+
{
|
128
|
+
name: device_id,
|
129
|
+
data: data.sort_by(&:first)
|
130
|
+
}
|
131
|
+
end
|
132
|
+
json downsampled
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
get '/all_data' do
|
137
|
+
data = conditions.pluck(:time, :temperature)
|
138
|
+
json [ { name: "All data", data: data} ]
|
139
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require_relative 'lttb'
|
2
|
+
require 'pp'
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
data = [
|
6
|
+
['2020-1-1', 10],
|
7
|
+
['2020-1-2', 21],
|
8
|
+
['2020-1-3', 19],
|
9
|
+
['2020-1-4', 32],
|
10
|
+
['2020-1-5', 12],
|
11
|
+
['2020-1-6', 14],
|
12
|
+
['2020-1-7', 18],
|
13
|
+
['2020-1-8', 29],
|
14
|
+
['2020-1-9', 23],
|
15
|
+
['2020-1-10', 27],
|
16
|
+
['2020-1-11', 14]]
|
17
|
+
data.each do |e|
|
18
|
+
e[0] = Time.mktime(*e[0].split('-'))
|
19
|
+
end
|
20
|
+
|
21
|
+
pp Lttb.downsample(data, 5)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"></script>
|
2
|
+
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8hammerjs@2.0.8"></script>
|
3
|
+
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
4
|
+
<script src="https://cdn.jsdelivr.net/npm/highcharts@10.2.1/highcharts.min.js"></script>
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.0/dist/chartjs-adapter-moment.min.js"></script>
|
6
|
+
<script src="https://cdn.jsdelivr.net/npm/chartkick@4.2.0/dist/chartkick.min.js"></script>
|
7
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
|
8
|
+
<h3>Downsampling <%= conditions.count %> records to
|
9
|
+
<select value="<%= threshold %>" onchange="location.href=`/?threshold=${this.value}`">
|
10
|
+
<option><%= threshold %></option>
|
11
|
+
<option value="50">50</option>
|
12
|
+
<option value="100">100</option>
|
13
|
+
<option value="500">500</option>
|
14
|
+
<option value="1000">1000</option>
|
15
|
+
<option value="5000">5000</option>
|
16
|
+
</select> points.
|
17
|
+
</h3>
|
18
|
+
|
19
|
+
<h3>SQL</h3>
|
20
|
+
<%= line_chart("/lttb_sql?threshold=#{threshold}",
|
21
|
+
loading: "dowsampled data from SQL") %>
|
22
|
+
<h3>Ruby</h3>
|
23
|
+
<%= line_chart("/lttb_ruby?threshold=#{threshold}",
|
24
|
+
library: {chart: {zoomType: 'x'}},
|
25
|
+
points: true, loading: "downsampled data from Ruby") %>
|
26
|
+
<!--%= line_chart("/all_data", loading: "Loading all data") %-->
|
27
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# LTTB examples
|
2
|
+
|
3
|
+
This folder contains an example to explore the a dynamic reloading of downsampled data.
|
4
|
+
|
5
|
+
It keeps the same amount of data and refresh the data with a higher resolution
|
6
|
+
as you keep zooming in.
|
7
|
+
There is a [./lttb_zoomable.rb](./lttb_zoomable.rb) file is a small webserver that compares
|
8
|
+
the SQL vs Ruby implementation. It also uses the [./views](./views) folder which
|
9
|
+
contains the view with the rendering and javascript part.
|
10
|
+
|
11
|
+
You can learn more by reading the [LTTB Zoom tutorial](https://jonatas.github.io/timescaledb/toolkit_lttb_zoom/).
|
12
|
+
|
13
|
+
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# ruby lttb_zoomable.rb postgres://user:pass@host:port/db_name
|
2
|
+
require 'bundler/inline' #require only what you need
|
3
|
+
|
4
|
+
gemfile(true) do
|
5
|
+
gem 'timescaledb', path: '../../..'
|
6
|
+
gem 'pry'
|
7
|
+
gem 'sinatra', require: false
|
8
|
+
gem 'sinatra-reloader'
|
9
|
+
gem 'sinatra-cross_origin'
|
10
|
+
end
|
11
|
+
|
12
|
+
require 'timescaledb/toolkit'
|
13
|
+
require 'sinatra'
|
14
|
+
require 'sinatra/json'
|
15
|
+
require 'sinatra/contrib'
|
16
|
+
|
17
|
+
register Sinatra::Reloader
|
18
|
+
register Sinatra::Contrib
|
19
|
+
|
20
|
+
PG_URI = ARGV.last
|
21
|
+
|
22
|
+
VALID_SIZES = %i[small med big]
|
23
|
+
def download_weather_dataset size: :small
|
24
|
+
unless VALID_SIZES.include?(size)
|
25
|
+
fail "Invalid size: #{size}. Valids are #{VALID_SIZES}"
|
26
|
+
end
|
27
|
+
url = "https://timescaledata.blob.core.windows.net/datasets/weather_#{size}.tar.gz"
|
28
|
+
puts "fetching #{size} weather dataset..."
|
29
|
+
system "wget \"#{url}\""
|
30
|
+
puts "done!"
|
31
|
+
end
|
32
|
+
|
33
|
+
def setup size: :small
|
34
|
+
file = "weather_#{size}.tar.gz"
|
35
|
+
download_weather_dataset(size: size) unless File.exists? file
|
36
|
+
puts "extracting #{file}"
|
37
|
+
system "tar -xvzf #{file} "
|
38
|
+
puts "creating data structures"
|
39
|
+
system "psql #{PG_URI} < weather.sql"
|
40
|
+
system %|psql #{PG_URI} -c "\\COPY locations FROM weather_#{size}_locations.csv CSV"|
|
41
|
+
system %|psql #{PG_URI} -c "\\COPY conditions FROM weather_#{size}_conditions.csv CSV"|
|
42
|
+
end
|
43
|
+
|
44
|
+
ActiveRecord::Base.establish_connection(PG_URI)
|
45
|
+
|
46
|
+
class Condition < ActiveRecord::Base
|
47
|
+
acts_as_hypertable time_column: "time"
|
48
|
+
acts_as_time_vector value_column: "temperature", segment_by: "device_id"
|
49
|
+
end
|
50
|
+
|
51
|
+
# Setup Hypertable as in a migration
|
52
|
+
ActiveRecord::Base.connection.instance_exec do
|
53
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
54
|
+
|
55
|
+
if !Condition.table_exists? || Condition.count.zero?
|
56
|
+
|
57
|
+
setup size: :big
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
def filter_by_request_params
|
63
|
+
filter= {device_id: "weather-pro-000001"}
|
64
|
+
if params[:filter] && params[:filter] != "null"
|
65
|
+
from, to = params[:filter].split(",").map(&Time.method(:parse))
|
66
|
+
filter[:time] = from..to
|
67
|
+
end
|
68
|
+
filter
|
69
|
+
end
|
70
|
+
|
71
|
+
def conditions
|
72
|
+
Condition.where(filter_by_request_params).order('time')
|
73
|
+
end
|
74
|
+
|
75
|
+
def threshold
|
76
|
+
params[:threshold]&.to_i || 50
|
77
|
+
end
|
78
|
+
|
79
|
+
configure do
|
80
|
+
enable :cross_origin
|
81
|
+
end
|
82
|
+
|
83
|
+
get '/' do
|
84
|
+
erb :index
|
85
|
+
end
|
86
|
+
|
87
|
+
get "/lttb_sql" do
|
88
|
+
downsampled = conditions.lttb(threshold: threshold, segment_by: nil)
|
89
|
+
json downsampled
|
90
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
<head>
|
2
|
+
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"></script>
|
3
|
+
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
4
|
+
</head>
|
5
|
+
|
6
|
+
<h3>Downsampling <%= conditions.count %> records to
|
7
|
+
<select value="<%= threshold %>" onchange="location.href=`/?threshold=${this.value}`">
|
8
|
+
<option><%= threshold %></option>
|
9
|
+
<option value="50">50</option>
|
10
|
+
<option value="100">100</option>
|
11
|
+
<option value="500">500</option>
|
12
|
+
<option value="1000">1000</option>
|
13
|
+
<option value="5000">5000</option>
|
14
|
+
</select> points.
|
15
|
+
</h3>
|
16
|
+
<div id='container'></div>
|
17
|
+
<script>
|
18
|
+
let chart = document.getElementById('container');
|
19
|
+
function fetch(filter) {
|
20
|
+
$.ajax({
|
21
|
+
url: `/lttb_sql?threshold=<%= threshold %>&filter=${filter}`,
|
22
|
+
success: function(result) {
|
23
|
+
let x = result.map((e) => e[0]);
|
24
|
+
let y = result.map((e) => parseFloat(e[1]));
|
25
|
+
Plotly.newPlot(chart, [{x, y,"mode": "markers", "type": "scatter"}]);
|
26
|
+
chart.on('plotly_relayout',
|
27
|
+
function(eventdata){
|
28
|
+
fetch([eventdata['xaxis.range[0]'],eventdata['xaxis.range[1]']]);
|
29
|
+
});
|
30
|
+
}});
|
31
|
+
}
|
32
|
+
fetch(null);
|
33
|
+
</script>
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Timescaledb
|
2
|
+
module ActsAsTimeVector
|
3
|
+
def acts_as_time_vector(options = {})
|
4
|
+
return if acts_as_time_vector?
|
5
|
+
|
6
|
+
include Timescaledb::Toolkit::TimeVector
|
7
|
+
|
8
|
+
class_attribute :time_vector_options, instance_writer: false
|
9
|
+
define_default_scopes
|
10
|
+
self.time_vector_options = options
|
11
|
+
end
|
12
|
+
|
13
|
+
def acts_as_time_vector?
|
14
|
+
included_modules.include?(Timescaledb::ActsAsTimeVector)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
ActiveRecord::Base.extend Timescaledb::ActsAsTimeVector
|
@@ -10,7 +10,7 @@ module Timescaledb
|
|
10
10
|
foreign_key: "hypertable_name",
|
11
11
|
class_name: "Timescaledb::CompressionSetting"
|
12
12
|
|
13
|
-
|
13
|
+
has_many :dimensions,
|
14
14
|
foreign_key: "hypertable_name",
|
15
15
|
class_name: "Timescaledb::Dimension"
|
16
16
|
|
@@ -18,6 +18,10 @@ module Timescaledb
|
|
18
18
|
foreign_key: "hypertable_name",
|
19
19
|
class_name: "Timescaledb::ContinuousAggregate"
|
20
20
|
|
21
|
+
def main_dimension
|
22
|
+
dimensions.find_by dimension_number: 1
|
23
|
+
end
|
24
|
+
|
21
25
|
def chunks_detailed_size
|
22
26
|
struct_from "SELECT * from chunks_detailed_size('#{self.hypertable_name}')"
|
23
27
|
end
|
@@ -72,26 +72,45 @@ module Timescaledb
|
|
72
72
|
# GROUP BY bucket
|
73
73
|
# SQL
|
74
74
|
#
|
75
|
-
def create_continuous_aggregate(
|
75
|
+
def create_continuous_aggregate(table_name, query, **options)
|
76
76
|
execute <<~SQL
|
77
|
-
CREATE MATERIALIZED VIEW #{
|
77
|
+
CREATE MATERIALIZED VIEW #{table_name}
|
78
78
|
WITH (timescaledb.continuous) AS
|
79
79
|
#{query.respond_to?(:to_sql) ? query.to_sql : query}
|
80
80
|
WITH #{"NO" unless options[:with_data]} DATA;
|
81
81
|
SQL
|
82
82
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
83
|
+
create_continuous_aggregate_policy(table_name, options[:refresh_policies] || {})
|
84
|
+
end
|
85
|
+
|
86
|
+
|
87
|
+
# Drop a new continuous aggregate.
|
88
|
+
#
|
89
|
+
# It basically DROP MATERIALIZED VIEW for a given @name.
|
90
|
+
#
|
91
|
+
# @param name [String, Symbol] The name of the continuous aggregate view.
|
92
|
+
def drop_continuous_aggregates view_name
|
93
|
+
execute "DROP MATERIALIZED VIEW #{view_name}"
|
92
94
|
end
|
95
|
+
|
93
96
|
alias_method :create_continuous_aggregates, :create_continuous_aggregate
|
94
97
|
|
98
|
+
def create_continuous_aggregate_policy(table_name, **options)
|
99
|
+
return if options.empty?
|
100
|
+
|
101
|
+
# TODO: assert valid keys
|
102
|
+
execute <<~SQL
|
103
|
+
SELECT add_continuous_aggregate_policy('#{table_name}',
|
104
|
+
start_offset => #{options[:start_offset]},
|
105
|
+
end_offset => #{options[:end_offset]},
|
106
|
+
schedule_interval => #{options[:schedule_interval]});
|
107
|
+
SQL
|
108
|
+
end
|
109
|
+
|
110
|
+
def remove_continuous_aggregate_policy(table_name)
|
111
|
+
execute "SELECT remove_continuous_aggregate_policy('#{table_name}')"
|
112
|
+
end
|
113
|
+
|
95
114
|
def create_retention_policy(table_name, interval:)
|
96
115
|
execute "SELECT add_retention_policy('#{table_name}', INTERVAL '#{interval}')"
|
97
116
|
end
|