timescaledb 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Fastfile +17 -0
- data/Gemfile.lock +2 -2
- data/README.md +41 -9
- data/bin/console +1 -1
- data/bin/tsdb +2 -2
- 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 +39 -5
- 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/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/job_stats.rb +1 -0
- data/lib/timescaledb/migration_helpers.rb +11 -0
- data/lib/timescaledb/schema_dumper.rb +1 -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 +31 -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
|
@@ -82,6 +82,17 @@ module Timescaledb
|
|
82
82
|
|
83
83
|
create_continuous_aggregate_policy(table_name, options[:refresh_policies] || {})
|
84
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}"
|
94
|
+
end
|
95
|
+
|
85
96
|
alias_method :create_continuous_aggregates, :create_continuous_aggregate
|
86
97
|
|
87
98
|
def create_continuous_aggregate_policy(table_name, **options)
|
@@ -27,7 +27,7 @@ module Timescaledb
|
|
27
27
|
private
|
28
28
|
|
29
29
|
def timescale_hypertable(hypertable, stream)
|
30
|
-
dim = hypertable.dimensions
|
30
|
+
dim = hypertable.dimensions.first
|
31
31
|
extra_settings = {
|
32
32
|
time_column: "#{dim.column_name}",
|
33
33
|
chunk_time_interval: "#{dim.time_interval.inspect}"
|
@@ -19,7 +19,7 @@ module Timescaledb
|
|
19
19
|
end
|
20
20
|
|
21
21
|
def compression_resume(scope)
|
22
|
-
sum = -> (method) { (scope.map(&method).inject(:+) || 0).
|
22
|
+
sum = -> (method) { (scope.map(&method).inject(:+) || 0).to_s(:human_size)}
|
23
23
|
{
|
24
24
|
uncompressed: sum[:before_total_bytes],
|
25
25
|
compressed: sum[:after_total_bytes]
|