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 +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
|
+

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