nom 0.1.0pre2

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 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: []