nom 0.1.0pre2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +101 -0
- data/bin/nom +67 -0
- data/lib/nom/config.rb +75 -0
- data/lib/nom/food_entry.rb +21 -0
- data/lib/nom/nom.plt.erb +42 -0
- data/lib/nom/nom.rb +421 -0
- data/lib/nom/weight_database.rb +120 -0
- metadata +70 -0
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
|
data/lib/nom/nom.plt.erb
ADDED
@@ -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: []
|