nom 0.1.0pre2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6b6805b185ac85fdf3742991b4a7295762cc7cbc
4
+ data.tar.gz: edfa4f02662d97c2cf5ab29b0e31e121086be2c8
5
+ SHA512:
6
+ metadata.gz: 08b47ef6700ad9b98c5b9841c68c9cc2a8fb78870cf3c907cea9175cadfdcd3d03842ae6ec380ae2ff9eccb40521b919afc9852fec6ceff11f5169c2172d8f4d
7
+ data.tar.gz: 554929b8d9fd603622a5d24a68ba62b00a2afcc341f0839d0311aef02a1422f5583fa6d33a1d1800f5a6706510108d0d3146b4911a40fe851e5d3f9566a689bc
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # nom
2
+
3
+ **nom** is a command line tool that helps you lose weight by tracking your energy intake and creating a negative feedback loop. It's inspired by John Walker's [The Hacker's Diet](https://www.fourmilab.ch/hackdiet/) and tries to automate things as much as possible.
4
+
5
+ # Installation
6
+
7
+ You'll need Ruby and gnuplot, as well as the `nokogiri` gem:
8
+
9
+ $ gem install nokogiri
10
+
11
+ Then clone this repository, and put the `nom` executable in your `PATH`.
12
+
13
+ When you run `nom` for the first time, it will ask for your current and your desired weight, as well as a target "weight loss per week" (0.5 is a good value).
14
+
15
+ # Basics
16
+
17
+ *nom* operates on three files in the directory `~/.nom/`: `config` contains configuration settings, `input` contains stuff you ate, `weight` contains weight measurements. You can edit them by hand.
18
+
19
+ By default, energy quantities will have the unit "kcal". You can change this by adding a line like `unit: 0.239` to your `~/.nom/config`, which means you want to use the unit "0.239 kcal" (= "1 kJ"). Energy quantities are displayed in parentheses: `(42)`
20
+
21
+ Weight quantities are displayed as "kg", but you can use arbitrary units, like pounds.
22
+
23
+ # Usage
24
+
25
+ Enter `nom help` if you're lost:
26
+
27
+ Available subcommands:
28
+ status Display a short food log
29
+ w, weight <weight> Report a weight measurement
30
+ s, search <term> Search for a food item in the web
31
+ n, nom <description> <energy> Report that you ate something
32
+ y, yesterday <desc.> <energy> Like nom, but for yesterday
33
+ p, plot Plot a weight/intake graph
34
+ l, log Display the full food log
35
+ g, grep <term> Search in the food log
36
+ e, edit Edit the input file
37
+ ew, editw Edit the weight file
38
+ help Print this help
39
+ There are some useful defaults:
40
+ (no arguments) status
41
+ <number> weight <number>
42
+ <term> search <term>
43
+ <term> <number> nom <term> <number>
44
+ Configuration options (put these in /home/seb/.nom/config):
45
+ rate How much weight you want to lose per week (default: '0.5')
46
+ goal Your target weight
47
+ image_viewer Your preferred svg viewer, for example 'eog -f', 'firefox', 'chromium' (default: 'xdg-open')
48
+ unit Your desired base unit in kcal (default: '1')
49
+ start_date The first day that should be considered by nom [yyyy-mm-dd]
50
+ balance_start The day from which on nom should keep track of a energy balance [yyyy-mm-dd]
51
+
52
+ So, call `nom` without arguments to get a summary of your current status:
53
+
54
+ $ nom
55
+ 5.3 kg down (34%), 10.3 kg to go!
56
+
57
+ Today: (1774)
58
+
59
+ (200) Griespudding
60
+ (110) Graubrot
61
+ (125) Käse
62
+ (87) Orangensaft
63
+ ---------------------
64
+ (1252) remaining
65
+
66
+ You ate/drank something? Look up at FDDB how much energy it contained. (The search is German only for now, sorry.)
67
+
68
+ $ nom Mate
69
+ Club Mate (Brauerei Loscher)
70
+ (40) 1 Glas (200 ml)
71
+ (66) 1 kleine Flasche (330 ml)
72
+ (100) 1 Flasche (500 ml)
73
+ Mate Tee, Figurfit (Bad Heilbrunner)
74
+ (0) 1 Beutel (2 ml)
75
+ (1) 100 g (100 ml)
76
+ Mate Tee, Orange (Bad Heilbrunner)
77
+ (2) 1 Glas (200 ml)
78
+ (0) 15 Filterbeutel (1 ml)
79
+ Mate Tee, Guarana (Bad Heilbrunner)
80
+ (0) 1 Glas (200 ml)
81
+ Club-Mate Cola (Brauerei Loscher)
82
+ (99) 1 Flasche (330 ml)
83
+ (60) 1 Glas (200 ml)
84
+
85
+ Report your energy intake:
86
+
87
+ $ nom Club-Mate 100
88
+
89
+ Enter your weight regularly:
90
+
91
+ $ nom 78.2
92
+
93
+ And get nice graphs. The upper graph shows weight over time, with a weighted (no pun intended) moving average, a weight prediction, and a green finish line. The lower graph shows daily energy intake targets and actual intake:
94
+
95
+ $ nom plot
96
+
97
+ ![Graphs of weight and input over time](http://files.morr.cc/nom-0.1.0.svg)
98
+
99
+ ## License: GPLv2+
100
+
101
+ *nom* is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
data/bin/nom ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "date"
4
+ require "nom/nom"
5
+
6
+ commands = [
7
+ # format: [ long_form, short_form, arguments, description ]
8
+ [ "status", nil, nil, "Display a short food log" ],
9
+ [ "weight", "w", "<weight>", "Report a weight measurement" ],
10
+ [ "search", "s", "<term>", "Search for a food item in the web" ],
11
+ [ "nom", "n", "<description> <energy>", "Report that you ate something" ],
12
+ [ "yesterday", "y", "<desc.> <energy>", "Like nom, but for yesterday" ],
13
+ [ "plot", "p", nil, "Plot a weight/intake graph" ],
14
+ [ "log", "l", nil, "Display the full food log" ],
15
+ [ "grep", "g", "<term>", "Search in the food log" ],
16
+ [ "edit", "e", nil, "Edit the input file" ],
17
+ [ "editw", "ew", nil, "Edit the weight file" ],
18
+ [ "help", nil, nil, "Print this help" ],
19
+ ]
20
+
21
+ nom = Nom.new
22
+
23
+ cmd_name = ARGV.shift or "status"
24
+ command = commands.find{|c| c[0] == cmd_name or c[1] == cmd_name}
25
+
26
+ if command.nil?
27
+ ARGV.unshift(cmd_name)
28
+ if ARGV.last.to_f != 0
29
+ if ARGV.size > 1
30
+ # some words followed by a number
31
+ cmd_name = "nom"
32
+ else
33
+ # a single number
34
+ cmd_name = "weight"
35
+ end
36
+ else
37
+ # some words
38
+ cmd_name = "search"
39
+ end
40
+
41
+ command = commands.find{|c| c[0] == cmd_name or c[1] == cmd_name}
42
+ end
43
+
44
+ if command[0] == "help"
45
+ puts "Available subcommands:"
46
+ commands.each do |c|
47
+ puts " "+"#{c[1].to_s.rjust(2)}#{c[1] ? "," : " "} #{c[0]} #{c[2]}".ljust(32)+c[3]
48
+ end
49
+ puts "There are some useful defaults:"
50
+ puts " "+"(no arguments)".ljust(28)+"status"
51
+ puts " "+"<number>".ljust(28)+"weight <number>"
52
+ puts " "+"<term>".ljust(28)+"search <term>"
53
+ puts " "+"<term> <number>".ljust(28)+"nom <term> <number>"
54
+ nom.config_usage
55
+ else
56
+ begin
57
+ if ARGV.empty?
58
+ nom.send(command[0])
59
+ else
60
+ nom.send(command[0], ARGV)
61
+ end
62
+ rescue Exception => e
63
+ puts e.backtrace
64
+ puts e.message
65
+ puts "Something went wrong. Usage of this command is: nom #{command[0]} #{command[2]}"
66
+ end
67
+ end
data/lib/nom/config.rb ADDED
@@ -0,0 +1,75 @@
1
+ class Config
2
+ def initialize file
3
+ @file = file
4
+
5
+ @config = {}
6
+ if File.exists? file
7
+ @config = YAML.load_file(file)
8
+ end
9
+
10
+ @defaults = {
11
+ # format: [ key, description, default_value, type ]
12
+ "rate" => [ "how much weight you want to lose per week", 0.5, Float ],
13
+ "goal" => [ "your target weight", nil, Float],
14
+ "image_viewer" => [ "your preferred SVG viewer, for example 'eog -f', 'firefox', 'chromium'", guess_image_viewer, String ],
15
+ "unit" => [ "your desired base unit in kcal", 1, Float ],
16
+ "start_date" => [ "the first day that should be considered by nom [yyyy-mm-dd]", nil, Date ],
17
+ "balance_start" => [ "the day from which on nom should keep track of a energy balance [yyyy-mm-dd]", nil, Date ],
18
+ }
19
+ end
20
+
21
+ def guess_image_viewer
22
+ if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
23
+ "start"
24
+ elsif RbConfig::CONFIG['host_os'] =~ /darwin/
25
+ "open"
26
+ elsif RbConfig::CONFIG['host_os'] =~ /linux|bsd/
27
+ "xdg-open"
28
+ else
29
+ nil
30
+ end
31
+ end
32
+
33
+ def has key
34
+ @config.has_key?(key) or (@defaults.has_key?(key) and not @defaults[key][1].nil?)
35
+ end
36
+
37
+ def get key
38
+ v = nil
39
+ if @config.has_key?(key)
40
+ v = @config[key]
41
+ elsif @defaults.has_key?(key)
42
+ if @defaults[key][1].nil?
43
+ print "Please enter #{@defaults[key][0]}: "
44
+ @config[key] = STDIN.gets.chomp
45
+ open(@file, "w") do |f|
46
+ f << @config.to_yaml
47
+ end
48
+ v = @config[key]
49
+ else
50
+ v = @defaults[key][1]
51
+ end
52
+ else
53
+ raise "Unknown configuration option '#{key}'"
54
+ end
55
+
56
+ if @defaults[key][2] == Float
57
+ v.to_f
58
+ elsif @defaults[key][2] == Date
59
+ if v.class == Date
60
+ v
61
+ else
62
+ Date.parse(v)
63
+ end
64
+ else
65
+ v
66
+ end
67
+ end
68
+
69
+ def print_usage
70
+ puts "Configuration options (put these in #{@file}):"
71
+ @defaults.each do |key, value|
72
+ puts " #{key}".ljust(34)+value[0].capitalize+(value[1].nil? ? "" : " (default: '#{value[1]}')")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ class FoodEntry
2
+ attr_reader :date, :kcal, :description
3
+
4
+ def self.from_line line
5
+ date, kcal, description = line.split(" ", 3)
6
+ date = Date.parse(date)
7
+ kcal = kcal.to_i
8
+ description.chomp!
9
+ FoodEntry.new(date, kcal, description)
10
+ end
11
+
12
+ def initialize date, kcal, description
13
+ @date = date
14
+ @kcal = kcal
15
+ @description = description
16
+ end
17
+
18
+ def to_s
19
+ "#{@date} #{@kcal} #{@description}\n"
20
+ end
21
+ end
@@ -0,0 +1,42 @@
1
+ set terminal svg size 1440,900 font "Linux Biolinum,20"
2
+ set output "<%= svg.path %>"
3
+
4
+ set multiplot layout 2,1
5
+
6
+ set border 11
7
+
8
+ set xdata time
9
+ set timefmt "%Y-%m-%d"
10
+ set format x "%Y-%m"
11
+ set grid
12
+ set lmargin 6
13
+
14
+ set xrange [ "<%= @weights.first %>" : "<%= plot_end %>" ]
15
+ set yrange [ <%= [goal-1, @weights.min].min.floor %> : <%= [goal+1, @weights.max].max.ceil %> ]
16
+ set ytics 1 nomirror
17
+ set mxtics 1
18
+ set xtics 2592000 nomirror
19
+
20
+ set obj 1 rectangle behind from screen 0,0 to screen 1,1
21
+ set obj 1 fillstyle solid 1.0 fillcolor rgb "white" linewidth 0
22
+
23
+ set obj 2 rectangle behind from "<%= @weights.first %>",<%= goal-1 %> to "<%= plot_end %>",<%= goal+1 %>
24
+ set obj 2 fillstyle solid 0.2 fillcolor rgb "green" linewidth 0
25
+
26
+ plot <%= goal %> t "Goal" lc rgb "forest-green" lw 2, \
27
+ "<%= weight_dat.path %>" using 1:2 w points t "Weight" pt 13 ps 0.3 lc rgb "navy", \
28
+ "<%= weight_dat.path %>" using 1:3 w l t "" lt 1 lw 2 lc rgb "navy", \
29
+ "<%= weight_dat.path %>" using 1:4 w l t "" lc rgb "navy" dt 3 lw 2
30
+
31
+ unset obj 1
32
+
33
+ set yrange [ 0 : <%= quantize((@weights.first..@weights.last).map{|d| [consumed_at(d), allowed_kcal(d, 0), allowed_kcal(d, rate)]}.flatten.max) %> ]
34
+ set ytics 200 nomirror
35
+ set key right bottom
36
+
37
+ plot "<%= input_dat.path %>" using 1:2:(0):($4-$2) w vectors nohead lc rgb "navy" notitle, \
38
+ "<%= input_dat.path %>" using 1:2 w points pt 13 ps 0.3 lc rgb "navy" t "Consumed energy", \
39
+ "<%= input_dat.path %>" using 1:3 w lines lc rgb "black" t "0 kg/week", \
40
+ "<%= input_dat.path %>" using 1:4 w lines lc rgb "forest-green" lw 2 t "<%= rate %> kg/week"
41
+
42
+ unset multiplot
data/lib/nom/nom.rb ADDED
@@ -0,0 +1,421 @@
1
+ require "open-uri"
2
+ require "fileutils"
3
+ require "nokogiri"
4
+ require "uri"
5
+ require "tempfile"
6
+ require "erb"
7
+
8
+ require "nom/food_entry"
9
+ require "nom/config"
10
+ require "nom/weight_database"
11
+
12
+ class Nom
13
+ def initialize
14
+ @nom_dir = File.join(Dir.home,".nom")
15
+ if not Dir.exists? @nom_dir
16
+ puts "Creating #{@nom_dir}"
17
+ Dir.mkdir(@nom_dir)
18
+ end
19
+
20
+ @config = Config.new(File.join(@nom_dir, "config"))
21
+
22
+ @weights = WeightDatabase.new(File.join(@nom_dir, "weight"))
23
+ @inputs = read_file("input", FoodEntry)
24
+
25
+ if @weights.empty?
26
+ print "Welcome to nom! Please enter your current weight: "
27
+ weight [STDIN.gets.chomp]
28
+ end
29
+
30
+ date = truncate_date
31
+ @inputs.delete_if{|i| i.date < date}
32
+ @weights.truncate(date)
33
+
34
+ @weights.interpolate_gaps!
35
+ @weights.precompute_moving_average!(0.1, 0.1, goal, rate)
36
+ @weights.predict_weights!(rate, goal, 30)
37
+ @weights.precompute_moving_average!(0.1, 0.1, goal, rate)
38
+
39
+ precompute_inputs_at
40
+ precompute_base_rate_at
41
+ end
42
+
43
+ def status
44
+ kg_lost = @weights.moving_average_at(@weights.first) - @weights.moving_average_at(@weights.last_real)
45
+ print "#{kg_lost.round(1)} kg down"
46
+ if kg_lost+kg_to_go > 0
47
+ print " (#{(100*kg_lost/(kg_lost+kg_to_go)).round}%)"
48
+ end
49
+ print ", #{kg_to_go.round(1)} kg to go!"
50
+ print " You'll reach your goal in approximately #{format_duration(days_to_go)}."
51
+ puts
52
+
53
+ log_since([@weights.first,Date.today-1].max)
54
+ end
55
+
56
+ def log
57
+ log_since(@weights.first)
58
+ end
59
+
60
+ def grep args
61
+ term = args.join(" ")
62
+
63
+ inputs = @inputs.select{|i| i.description =~ Regexp.new(term, Regexp::IGNORECASE)}
64
+
65
+ if inputs.empty?
66
+ puts "(no matching entries found)"
67
+ end
68
+
69
+ inputs.each do |i|
70
+ entry(quantize(i.kcal), i.date.to_s+" "+i.description)
71
+ end
72
+
73
+ separator
74
+ entry(quantize(inputs.inject(0){|sum, i| sum+i.kcal}), "total")
75
+ end
76
+
77
+ def weight args
78
+ if @weights.real?(Date.today)
79
+ raise "You already entered a weight for today. Use `nom editw` to modify it."
80
+ end
81
+
82
+ date = Date.today
83
+ weight = args.pop.to_f
84
+
85
+ open(File.join(@nom_dir,"weight"), "a") do |f|
86
+ f << "#{date} #{weight}\n"
87
+ end
88
+
89
+ initialize
90
+ plot
91
+ end
92
+
93
+ def nom args
94
+ nom_entry args, (Time.now-5*60*60).to_date
95
+ end
96
+
97
+ def yesterday args
98
+ nom_entry args, Date.today-1
99
+ end
100
+
101
+ def search args
102
+ puts "Previous log entries:"
103
+ grep(args)
104
+ term = args.join(" ")
105
+ puts
106
+ term = term.encode("ISO-8859-1")
107
+ url = "http://fddb.info/db/de/suche/?udd=0&cat=site-de&search=#{URI.escape(term)}"
108
+
109
+ page = Nokogiri::HTML(open(url))
110
+ results = page.css(".standardcontent a").map{|a| a["href"]}.select{|href| href.include? "lebensmittel"}
111
+
112
+ results[0..4].each do |result|
113
+ page = Nokogiri::HTML(open(result))
114
+ title = page.css(".breadcrumb a").last.text
115
+ brand = page.css(".standardcontent p a").select{|a| a["href"].include? "hersteller"}.first.text
116
+ puts "#{title} (#{brand})"
117
+
118
+ page.css(".serva").each do |serving|
119
+ size = serving.css("a.servb").text
120
+ kcal = serving.css("div")[5].css("div")[1].text.to_i
121
+ #kj = serving.css("div")[2].css("div")[1].text.to_i
122
+ puts " (#{quantize(kcal)}) #{size}"
123
+ end
124
+ end
125
+ end
126
+
127
+ def plot
128
+ raise "To use this subcommand, please install 'gnuplot'." unless which("gnuplot")
129
+
130
+ weight_dat = Tempfile.new("weight")
131
+ (@weights.first).upto(plot_end) do |date|
132
+ weight_dat << "#{date}\t"
133
+ if @weights.real?(date)
134
+ weight_dat << "#{@weights.at(date)}"
135
+ else
136
+ weight_dat << "-"
137
+ end
138
+ if date <= @weights.last_real
139
+ weight_dat << "\t#{@weights.moving_average_at(date)}\t"
140
+ else
141
+ weight_dat << "\t-"
142
+ end
143
+ if date >= @weights.last_real
144
+ weight_dat << "\t#{@weights.moving_average_at(date)}\n"
145
+ else
146
+ weight_dat << "\t-\n"
147
+ end
148
+ end
149
+ weight_dat.close
150
+
151
+ input_dat = Tempfile.new("input")
152
+ input_dat << "#{@weights.first-1}\t0\t0\n"
153
+ (@weights.first).upto(Date.today) do |date|
154
+ input_dat << "#{date}\t"
155
+ if consumed_at(date) == 0
156
+ input_dat << "-"
157
+ else
158
+ input_dat << quantize(consumed_at(date))
159
+ end
160
+ input_dat << "\t#{quantize(allowed_kcal(date, 0))}"
161
+ input_dat << "\t#{quantize(allowed_kcal(date))}"
162
+ input_dat << "\n"
163
+ end
164
+ input_dat.close
165
+
166
+ svg = Tempfile.new(["plot", ".svg"])
167
+ svg.close
168
+ ObjectSpace.undefine_finalizer(svg) # prevent the svg file from being deleted
169
+
170
+ plt_erb = IO.read(File.join(File.dirname(File.expand_path(__FILE__)), "nom.plt.erb"))
171
+
172
+ plt = Tempfile.new("plt")
173
+ plt << ERB.new(plt_erb).result(binding)
174
+ plt.close
175
+
176
+ system("gnuplot "+plt.path)
177
+
178
+ image_viewer = @config.get("image_viewer")
179
+ system(image_viewer+" "+svg.path)
180
+ end
181
+
182
+ def edit
183
+ edit_file "input"
184
+ end
185
+
186
+ def editw
187
+ edit_file "weight"
188
+ end
189
+
190
+ def config_usage
191
+ @config.print_usage
192
+ end
193
+
194
+ private
195
+
196
+ def nom_entry args, date
197
+ summands = args.pop.split("+")
198
+ kcal = summands.inject(0) do |sum, summand|
199
+ factors = summand.split("x")
200
+ sum + factors.map{ |f| f.to_f }.inject(1){ |p,f| p*f }
201
+ end
202
+
203
+ if kcal == 0
204
+ raise "energy term cannot be zero"
205
+ end
206
+
207
+ description = args.join(" ")
208
+ entry = FoodEntry.new(date, kcal, description)
209
+
210
+ open(File.join(@nom_dir,"input"), "a") do |f|
211
+ if not @inputs.empty? and entry.date != @inputs.last.date
212
+ f << "\n"
213
+ end
214
+ f << entry.to_s
215
+ end
216
+
217
+ @inputs << entry
218
+ if @inputs_at[date].nil?
219
+ @inputs_at[date] = []
220
+ end
221
+ @inputs_at[date] << entry
222
+
223
+ status
224
+ end
225
+
226
+ def edit_file filename
227
+ editor = ENV["EDITOR"]
228
+ editor = "vim" if editor.nil?
229
+ system("#{editor} #{ENV["HOME"]}/.nom/#{filename}")
230
+ end
231
+
232
+ def allowed_kcal date, r=nil
233
+ if r.nil?
234
+ r = @weights.rate_at(date, goal, rate)
235
+ end
236
+ if date > @weights.last
237
+ date = @weights.last
238
+ end
239
+ @base_rate_at[date] + r*1000
240
+ end
241
+
242
+ def consumed_at date
243
+ inputs_at(date).inject(0){ |sum, i| sum+i.kcal }
244
+ end
245
+
246
+ def kg_to_go
247
+ @weights.moving_average_at(Date.today) - goal
248
+ end
249
+
250
+ def kcal_to_burn
251
+ kcal_per_kg_body_fat = 7000
252
+ kg_to_go * kcal_per_kg_body_fat
253
+ end
254
+
255
+ def days_to_go
256
+ kcal_to_burn.abs/(rate*1000)
257
+ end
258
+
259
+ def plot_end
260
+ @weights.last
261
+ end
262
+
263
+ def balance_start
264
+ if @config.has("balance_start")
265
+ @config.get("balance_start")
266
+ else
267
+ @weights.first
268
+ end
269
+ end
270
+
271
+ def balance_end
272
+ Date.today-1
273
+ end
274
+
275
+ def kcal_balance
276
+ sum = 0
277
+ balance_start.upto(balance_end) do |d|
278
+ if consumed_at(d) != 0
279
+ sum += consumed_at(d) - allowed_kcal(d)
280
+ end
281
+ end
282
+ sum
283
+ end
284
+
285
+ def truncate_date
286
+ first_start = @weights.first
287
+
288
+ if @config.has("start_date")
289
+ user_start = @config.get("start_date")
290
+ [user_start, first_start].max
291
+ else
292
+ # find the last gap longer than 30 days
293
+ gap = @weights.find_gap(30)
294
+
295
+ if gap.nil?
296
+ first_start
297
+ else
298
+ gap[1]
299
+ end
300
+ end
301
+ end
302
+
303
+ def quantize kcal
304
+ return (1.0*kcal/@config.get("unit")).round
305
+ end
306
+
307
+ def format_date date
308
+ if date == Date.today
309
+ return "Today"
310
+ elsif date == Date.today-1
311
+ return "Yesterday"
312
+ else
313
+ return date.to_s
314
+ end
315
+ end
316
+
317
+ def format_duration days
318
+ if days <= 7
319
+ n = days.round(1)
320
+ unit = "day"
321
+ elsif days <= 7*4
322
+ n = (days/7.0).round(1)
323
+ unit = "week"
324
+ else
325
+ n = (days/7.0/4.0).round(1)
326
+ unit = "month"
327
+ end
328
+ "#{n} #{unit}#{n == 1 ? "" : "s"}"
329
+ end
330
+
331
+ def entry value, text=""
332
+ puts "#{" "*(6-value.to_s.length)}(#{value}) #{text}"
333
+ end
334
+
335
+ def separator
336
+ puts "---------------------"
337
+ end
338
+
339
+ def log_since start
340
+ remaining = 0
341
+ start.upto(Date.today) do |date|
342
+ remaining += allowed_kcal(date)
343
+ puts
344
+ puts "#{format_date(date)}: (#{quantize(allowed_kcal(date))})"
345
+ puts
346
+ remaining = allowed_kcal(date)
347
+ inputs_at(date).each do |i|
348
+ entry(quantize(i.kcal), i.description)
349
+ remaining -= i.kcal
350
+ end
351
+ separator
352
+ entry(quantize(remaining), "remaining (#{(100-100.0*remaining/allowed_kcal(date)).round}% used)")
353
+ end
354
+ if kcal_balance > 0
355
+ entry(quantize(kcal_balance.abs), "too much since #{balance_start}")
356
+ end
357
+ end
358
+
359
+ def read_file name, klass
360
+ result = []
361
+ file = File.join(@nom_dir,name)
362
+ FileUtils.touch(file)
363
+ IO.readlines(file).each do |line|
364
+ next if line == "\n"
365
+ result << klass::from_line(line)
366
+ end
367
+ result
368
+ end
369
+
370
+ def goal
371
+ @config.get("goal")
372
+ end
373
+
374
+ def rate
375
+ @config.get("rate")
376
+ end
377
+
378
+ def inputs_at date
379
+ @inputs_at[date] || []
380
+ end
381
+
382
+ def precompute_inputs_at
383
+ @inputs_at = {}
384
+ @inputs.each do |i|
385
+ @inputs_at[i.date] = [] if @inputs_at[i.date].nil?
386
+ @inputs_at[i.date] << i
387
+ end
388
+ end
389
+
390
+ def precompute_base_rate_at
391
+ alpha = 0.05
392
+ @base_rate_at = {@weights.first => @weights.at(@weights.first)*25*1.2}
393
+
394
+ (@weights.first+1).upto(@weights.last) do |d|
395
+ intake = consumed_at(d-1)
396
+ if intake == 0
397
+ @base_rate_at[d] = @base_rate_at[d-1]
398
+ next
399
+ end
400
+ loss = @weights.moving_average_at(d-1) - @weights.moving_average_at(d)
401
+ kcal_per_kg_body_fat = 7000
402
+ burned_kcal = loss*kcal_per_kg_body_fat
403
+ new_base_rate_estimation = intake + burned_kcal
404
+ @base_rate_at[d] = alpha*new_base_rate_estimation + (1-alpha)*@base_rate_at[d-1]
405
+ end
406
+ (@weights.last+1).upto(Date.today) do |d|
407
+ @base_rate_at[d] = @base_rate_at[d-1]
408
+ end
409
+ end
410
+
411
+ def which(cmd)
412
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
413
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
414
+ exts.each { |ext|
415
+ exe = File.join(path, "#{cmd}#{ext}")
416
+ return exe if File.executable?(exe) && !File.directory?(exe)
417
+ }
418
+ end
419
+ return nil
420
+ end
421
+ end
@@ -0,0 +1,120 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ class WeightDatabase
5
+ def initialize(file)
6
+ @interpolated = {}
7
+ @weights = {}
8
+ @moving_averages = {}
9
+
10
+ FileUtils.touch(file)
11
+ IO.readlines(file).each do |line|
12
+ date, weight = line.split(" ", 2)
13
+ date = Date.parse(date)
14
+ @weights[date] = weight.to_f
15
+ @interpolated[date] = false
16
+ end
17
+ end
18
+
19
+ def interpolate_gaps!
20
+ @weights.keys.each_cons(2) do |a, b|
21
+ (a+1).upto(b-1) do |d|
22
+ @weights[d] = @weights[a] + (@weights[a]-@weights[b])/(a-b)*(d-a)
23
+ @interpolated[d] = true
24
+ end
25
+ end
26
+ end
27
+
28
+ def precompute_moving_average!(alpha, beta, goal, rate)
29
+ trend = dampened_rate(@weights[first], goal, rate)/7.0
30
+
31
+ @moving_averages[first] = at(first)
32
+ (first+1).upto(last).each do |d|
33
+ @moving_averages[d] = alpha*at(d) + (1-alpha)*(@moving_averages[d-1]+trend)
34
+ trend = beta*(@moving_averages[d]-@moving_averages[d-1]) + (1-beta)*trend
35
+ end
36
+ end
37
+
38
+ def predict_weights!(rate, goal, tail)
39
+ d = (last)
40
+ loop do
41
+ if (@weights[d] - goal).abs < 0.1
42
+ tail -= 1
43
+ end
44
+ if tail == 0
45
+ break
46
+ end
47
+
48
+ d += 1
49
+ prev_weight = @moving_averages[d-1] || @weights[d-1]
50
+ @weights[d] = prev_weight+dampened_rate(prev_weight, goal, rate)/7.0
51
+ @interpolated[d] = true
52
+ end
53
+ end
54
+
55
+ def dampened_rate weight, goal, rate
56
+ r = (goal-weight).to_f
57
+ if r.abs > 1
58
+ r/r.abs*rate
59
+ else
60
+ r*rate
61
+ end
62
+ end
63
+
64
+ def real? date
65
+ @weights[date] and not @interpolated[date]
66
+ end
67
+
68
+ def at date
69
+ @weights[date]
70
+ end
71
+
72
+ def moving_average_at date
73
+ @moving_averages[date]
74
+ end
75
+
76
+ def rate_at date, goal, rate
77
+ if date > last_real
78
+ dampened_rate(@weights[date], goal, rate)
79
+ else
80
+ dampened_rate(@moving_averages[date], goal, rate)
81
+ end
82
+ end
83
+
84
+ def empty?
85
+ @weights.empty?
86
+ end
87
+
88
+ def first
89
+ @weights.keys.min
90
+ end
91
+
92
+ def last
93
+ @weights.keys.max
94
+ end
95
+
96
+ def last_real
97
+ @interpolated.select{|d, i| not i}.keys.max
98
+ end
99
+
100
+ def truncate date
101
+ @weights.delete_if{|d, w| d < date}
102
+ end
103
+
104
+ def min
105
+ @weights.values.min
106
+ end
107
+
108
+ def max
109
+ @weights.values.max
110
+ end
111
+
112
+ def find_gap days
113
+ gap = @weights.keys.reverse.each_cons(2).find{|a,b| a-b > days}
114
+ if gap
115
+ gap.reverse
116
+ else
117
+ nil
118
+ end
119
+ end
120
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nom
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0pre2
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Morr
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ description: |-
28
+ nom is a command line tool that helps you lose weight by
29
+ tracking your energy intake and creating a negative feedback loop.
30
+ It's inspired by John Walker's "The Hacker's Diet" and tries to
31
+ automate things as much as possible.
32
+ email: sebastian@morr.cc
33
+ executables:
34
+ - nom
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - README.md
39
+ - bin/nom
40
+ - lib/nom/config.rb
41
+ - lib/nom/food_entry.rb
42
+ - lib/nom/nom.plt.erb
43
+ - lib/nom/nom.rb
44
+ - lib/nom/weight_database.rb
45
+ homepage: https://github.com/blinry/nom
46
+ licenses:
47
+ - GPL-2.0+
48
+ metadata: {}
49
+ post_install_message:
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">"
61
+ - !ruby/object:Gem::Version
62
+ version: 1.3.1
63
+ requirements:
64
+ - gnuplot >= 5.0
65
+ rubyforge_project:
66
+ rubygems_version: 2.4.5
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: Lose weight and hair through stress and poor nutrition
70
+ test_files: []