Skip to content

Commit f32249f

Browse files
committed
pre-planning deep action session
1 parent 1cb6c76 commit f32249f

File tree

7 files changed

+202
-48
lines changed

7 files changed

+202
-48
lines changed

.geminiignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.venv
2+
.pytest_cache
3+
__pycache__
4+
*.sql
5+
*.db
6+

ntclient/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131

3232
# Global variables
3333
PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
34-
NUTRA_HOME = os.getenv("NUTRA_HOME", os.getenv("NUTRA_DIR", os.path.join(os.path.expanduser("~"), ".nutra")))
34+
NUTRA_HOME = os.getenv(
35+
"NUTRA_HOME",
36+
os.getenv("NUTRA_DIR", os.path.join(os.path.expanduser("~"), ".nutra")),
37+
)
3538
USDA_DB_NAME = "usda.sqlite3"
3639
# NOTE: NT_DB_NAME = "nt.sqlite3" is defined in ntclient.ntsqlite.sql
3740

ntclient/argparser/__init__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,20 @@ def build_subcommand_analyze(subparsers: argparse._SubParsersAction) -> None:
120120
type=float,
121121
help="scale to custom number of grams (default is 100g)",
122122
)
123+
analyze_parser.add_argument(
124+
"-s",
125+
dest="scale",
126+
metavar="N",
127+
type=float,
128+
help="scale actual values to N (default: kcal)",
129+
)
130+
analyze_parser.add_argument(
131+
"-m",
132+
dest="scale_mode",
133+
metavar="MODE",
134+
type=str,
135+
help="scale mode: 'kcal', 'weight', or nutrient name/ID",
136+
)
123137
analyze_parser.add_argument("food_id", type=int, nargs="+")
124138
analyze_parser.set_defaults(func=parser_funcs.analyze)
125139

@@ -145,6 +159,20 @@ def build_subcommand_day(subparsers: argparse._SubParsersAction) -> None:
145159
type=types.file_path,
146160
help="provide a custom RDA file in csv format",
147161
)
162+
day_parser.add_argument(
163+
"-s",
164+
dest="scale",
165+
metavar="N",
166+
type=float,
167+
help="scale actual values to N (default: kcal)",
168+
)
169+
day_parser.add_argument(
170+
"-m",
171+
dest="scale_mode",
172+
metavar="MODE",
173+
type=str,
174+
help="scale mode: 'kcal', 'weight', or nutrient name/ID",
175+
)
148176
day_parser.set_defaults(func=parser_funcs.day)
149177

150178

@@ -182,6 +210,20 @@ def build_subcommand_recipe(subparsers: argparse._SubParsersAction) -> None:
182210
recipe_anl_parser.add_argument(
183211
"path", type=str, help="view (and analyze) recipe by file path"
184212
)
213+
recipe_anl_parser.add_argument(
214+
"-s",
215+
dest="scale",
216+
metavar="N",
217+
type=float,
218+
help="scale actual values to N (default: kcal)",
219+
)
220+
recipe_anl_parser.add_argument(
221+
"-m",
222+
dest="scale_mode",
223+
metavar="MODE",
224+
type=str,
225+
help="scale mode: 'kcal', 'weight', or nutrient name/ID",
226+
)
185227
recipe_anl_parser.set_defaults(func=parser_funcs.recipe)
186228

187229

ntclient/argparser/funcs.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,23 @@ def analyze(args: argparse.Namespace) -> tuple:
6060
# exc: ValueError,
6161
food_ids = set(args.food_id)
6262
grams = float(args.grams) if args.grams else 100.0
63+
scale = float(args.scale) if args.scale else 0.0
64+
scale_mode = args.scale_mode if args.scale_mode else "kcal"
6365

64-
return ntclient.services.analyze.foods_analyze(food_ids, grams)
66+
return ntclient.services.analyze.foods_analyze(
67+
food_ids, grams, scale=scale, scale_mode=scale_mode
68+
)
6569

6670

6771
def day(args: argparse.Namespace) -> tuple:
6872
"""Analyze a day's worth of meals"""
6973
day_csv_paths = [str(os.path.expanduser(x)) for x in args.food_log]
7074
rda_csv_path = str(os.path.expanduser(args.rda)) if args.rda else str()
75+
scale = float(args.scale) if args.scale else 0.0
76+
scale_mode = args.scale_mode if args.scale_mode else "kcal"
7177

7278
return ntclient.services.analyze.day_analyze(
73-
day_csv_paths, rda_csv_path=rda_csv_path
79+
day_csv_paths, rda_csv_path=rda_csv_path, scale=scale, scale_mode=scale_mode
7480
)
7581

7682

@@ -96,8 +102,12 @@ def recipe(args: argparse.Namespace) -> tuple:
96102
@todo: use as default command? Currently this is reached by `nutra recipe anl`
97103
"""
98104
recipe_path = args.path
105+
scale = float(args.scale) if args.scale else 0.0
106+
scale_mode = args.scale_mode if args.scale_mode else "kcal"
99107

100-
return ntclient.services.recipe.recipe.recipe_overview(recipe_path=recipe_path)
108+
return ntclient.services.recipe.recipe.recipe_overview(
109+
recipe_path=recipe_path, scale=scale, scale_mode=scale_mode
110+
)
101111

102112

103113
##############################################################################

ntclient/models/__init__.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,54 @@ def process_data(self) -> None:
5959
if CLI_CONFIG.debug:
6060
print("Finished with recipe.")
6161

62-
def print_analysis(self) -> None:
62+
def print_analysis(self, scale: float = 0, scale_mode: str = "kcal") -> None:
6363
"""Run analysis on a single recipe"""
64-
# TODO: implement this
64+
from ntclient import BUFFER_WD
65+
from ntclient.persistence.sql.usda.funcs import (
66+
sql_analyze_foods,
67+
sql_nutrients_overview,
68+
)
69+
from ntclient.services.analyze import day_format
70+
71+
# Get nutrient overview (RDAs, units, etc.)
72+
nutrients_rows = sql_nutrients_overview()
73+
nutrients = {int(x[0]): tuple(x) for x in nutrients_rows.values()}
74+
75+
# Analyze foods in the recipe
76+
food_ids = set(self.food_data.keys())
77+
foods_analysis = {}
78+
for food in sql_analyze_foods(food_ids):
79+
food_id = int(food[0])
80+
# nut_id, val (per 100g)
81+
anl = (int(food[1]), float(food[2]))
82+
if food_id not in foods_analysis:
83+
foods_analysis[food_id] = [anl]
84+
else:
85+
foods_analysis[food_id].append(anl)
86+
87+
# Compute totals
88+
nutrient_totals = {}
89+
total_weight = 0.0
90+
for food_id, grams in self.food_data.items():
91+
total_weight += grams
92+
if food_id not in foods_analysis:
93+
continue
94+
for _nutrient in foods_analysis[food_id]:
95+
nutr_id = _nutrient[0]
96+
nutr_per_100g = _nutrient[1]
97+
nutr_val = grams / 100 * nutr_per_100g
98+
if nutr_id not in nutrient_totals:
99+
nutrient_totals[nutr_id] = nutr_val
100+
else:
101+
nutrient_totals[nutr_id] += nutr_val
102+
103+
# Print results using day_format for consistency
104+
buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD
105+
day_format(
106+
nutrient_totals,
107+
nutrients,
108+
buffer=buffer,
109+
scale=scale,
110+
scale_mode=scale_mode,
111+
total_weight=total_weight,
112+
)

ntclient/services/analyze.py

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@
3737
##############################################################################
3838
# Foods
3939
##############################################################################
40-
def foods_analyze(food_ids: set, grams: float = 100) -> tuple:
40+
def foods_analyze(
41+
food_ids: set, grams: float = 100, scale: float = 0, scale_mode: str = "kcal"
42+
) -> tuple:
4143
"""
4244
Analyze a list of food_ids against stock RDA values
4345
(NOTE: only supports a single food for now... add compare foods support later)
44-
TODO: support flag -t (tabular/non-visual output)
45-
TODO: support flag -s (scale to 2000 kcal)
4646
"""
4747

4848
##########################################################################
@@ -101,58 +101,47 @@ def foods_analyze(food_ids: set, grams: float = 100) -> tuple:
101101
print(refuse[0])
102102
print(" ({0}%, by mass)".format(refuse[1]))
103103

104-
######################################################################
105-
# Nutrient colored RDA tree-view
106-
######################################################################
107-
print_header("NUTRITION")
104+
# Prepare analysis dict for day_format
105+
analysis_dict = {x[0]: x[1] for x in nut_val_tuples}
106+
107+
# Reconstruct nutrient_rows to satisfy legacy return contract (and tests)
108108
nutrient_rows = []
109-
# TODO: skip small values (<1% RDA), report as color bar if RDA is available
110109
for nutrient_id, amount in nut_val_tuples:
111-
# Skip zero values
112110
if not amount:
113111
continue
114-
115-
# Get name and unit
116112
nutr_desc = nutrients[nutrient_id][4] or nutrients[nutrient_id][3]
117113
unit = nutrients[nutrient_id][2]
118-
119-
# Insert RDA % into row
120114
if rdas[nutrient_id]:
121115
rda_perc = float(round(amount / rdas[nutrient_id] * 100, 1))
122116
else:
123117
rda_perc = None
124118
row = [nutrient_id, nutr_desc, rda_perc, round(amount, 2), unit]
125-
126-
# Add to list
127119
nutrient_rows.append(row)
128-
129-
# Add to list of lists
130120
nutrients_rows.append(nutrient_rows)
131121

132-
# Calculate stuff
133-
_kcal = next((x[1] for x in nut_val_tuples if x[0] == NUTR_ID_KCAL), 0)
134-
135-
# Print view
136-
# TODO: either make this function singular, or handle plural logic here
137-
# TODO: support flag --absolute (use 2000 kcal; dont' scale to food kcal)
138-
_food_id = list(food_ids)[0]
139-
nutrient_progress_bars(
140-
{_food_id: grams},
141-
[(_food_id, x[0], x[1] * (grams / 100)) for x in analyses[_food_id]],
122+
# Print view using consistent format
123+
buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD
124+
day_format(
125+
analysis_dict,
142126
nutrients,
127+
buffer=buffer,
128+
scale=scale,
129+
scale_mode=scale_mode,
130+
total_weight=grams,
143131
)
144-
# TODO: make this into the `-t` or `--tabular` branch of the function
145-
# headers = ["id", "nutrient", "rda %", "amount", "units"]
146-
# table = tabulate(nutrient_rows, headers=headers, tablefmt="presto")
147-
# print(table)
148132

149133
return 0, nutrients_rows, servings_rows
150134

151135

152136
##############################################################################
153137
# Day
154138
##############################################################################
155-
def day_analyze(day_csv_paths: Sequence[str], rda_csv_path: str = str()) -> tuple:
139+
def day_analyze(
140+
day_csv_paths: Sequence[str],
141+
rda_csv_path: str = str(),
142+
scale: float = 0,
143+
scale_mode: str = "kcal",
144+
) -> tuple:
156145
"""Analyze a day optionally with custom RDAs, examples:
157146
158147
./nutra day tests/resources/day/human-test.csv
@@ -211,12 +200,15 @@ def day_analyze(day_csv_paths: Sequence[str], rda_csv_path: str = str()) -> tupl
211200

212201
# Compute totals
213202
nutrients_totals = []
203+
total_grams_list = []
214204
for log in logs:
215205
nutrient_totals = OrderedDict() # NOTE: dict()/{} is NOT ORDERED before 3.6/3.7
206+
daily_grams = 0.0
216207
for entry in log:
217208
if entry["id"]:
218209
food_id = int(entry["id"])
219210
grams = float(entry["grams"])
211+
daily_grams += grams
220212
for _nutrient2 in foods_analysis[food_id]:
221213
nutr_id = _nutrient2[0]
222214
nutr_per_100g = _nutrient2[1]
@@ -226,26 +218,72 @@ def day_analyze(day_csv_paths: Sequence[str], rda_csv_path: str = str()) -> tupl
226218
else:
227219
nutrient_totals[nutr_id] += nutr_val
228220
nutrients_totals.append(nutrient_totals)
221+
total_grams_list.append(daily_grams)
229222

230223
# Print results
231224
buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD
232-
for analysis in nutrients_totals:
233-
day_format(analysis, nutrients, buffer=buffer)
225+
for i, analysis in enumerate(nutrients_totals):
226+
day_format(
227+
analysis,
228+
nutrients,
229+
buffer=buffer,
230+
scale=scale,
231+
scale_mode=scale_mode,
232+
total_weight=total_grams_list[i],
233+
)
234234
return 0, nutrients_totals
235235

236236

237237
def day_format(
238238
analysis: Mapping[int, float],
239239
nutrients: Mapping[int, tuple],
240240
buffer: int = 0,
241+
scale: float = 0,
242+
scale_mode: str = "kcal",
243+
total_weight: float = 0,
241244
) -> None:
242245
"""Formats day analysis for printing to console"""
243246

247+
multiplier = 1.0
248+
if scale:
249+
if scale_mode == "kcal":
250+
current_val = analysis.get(NUTR_ID_KCAL, 0)
251+
multiplier = scale / current_val if current_val else 0
252+
elif scale_mode == "weight":
253+
multiplier = scale / total_weight if total_weight else 0
254+
else:
255+
# Try to interpret scale_mode as nutrient ID or Name
256+
target_id = None
257+
# 1. Check if int
258+
try:
259+
target_id = int(scale_mode)
260+
except ValueError:
261+
# 2. Check names
262+
for n_id, n_data in nutrients.items():
263+
# n_data usually: (id, rda, unit, tag, name, ...)
264+
# Check tag or desc
265+
if scale_mode.lower() in str(n_data[3]).lower():
266+
target_id = n_id
267+
break
268+
if scale_mode.lower() in str(n_data[4]).lower():
269+
target_id = n_id
270+
break
271+
272+
if target_id and target_id in analysis:
273+
current_val = analysis[target_id]
274+
multiplier = scale / current_val if current_val else 0
275+
else:
276+
print(f"WARN: Could not scale by '{scale_mode}', nutrient not found.")
277+
278+
# Apply multiplier
279+
if multiplier != 1.0:
280+
analysis = {k: v * multiplier for k, v in analysis.items()}
281+
244282
# Actual values
245-
kcals = round(analysis[NUTR_ID_KCAL])
246-
pro = analysis[NUTR_ID_PROTEIN]
247-
net_carb = analysis[NUTR_ID_CARBS] - analysis[NUTR_ID_FIBER]
248-
fat = analysis[NUTR_ID_FAT_TOT]
283+
kcals = round(analysis.get(NUTR_ID_KCAL, 0))
284+
pro = analysis.get(NUTR_ID_PROTEIN, 0)
285+
net_carb = analysis.get(NUTR_ID_CARBS, 0) - analysis.get(NUTR_ID_FIBER, 0)
286+
fat = analysis.get(NUTR_ID_FAT_TOT, 0)
249287
kcals_449 = round(4 * pro + 4 * net_carb + 9 * fat)
250288

251289
# Desired values
@@ -257,12 +295,15 @@ def day_format(
257295
# Print calories and macronutrient bars
258296
print_header("Macro-nutrients")
259297
kcals_max = max(kcals, kcals_rda)
260-
rda_perc = round(kcals * 100 / kcals_rda, 1)
298+
rda_perc = round(kcals * 100 / kcals_rda, 1) if kcals_rda else 0
261299
print(
262300
"Actual: {0} kcal ({1}% RDA), {2} by 4-4-9".format(
263301
kcals, rda_perc, kcals_449
264302
)
265303
)
304+
if scale:
305+
print(" (Scaled to %s %s)" % (scale, scale_mode))
306+
266307
print_macro_bar(fat, net_carb, pro, kcals_max, _buffer=buffer)
267308
print(
268309
"\nDesired: {0} kcal ({1} kcal)".format(
@@ -278,7 +319,7 @@ def day_format(
278319
)
279320

280321
# Nutrition detail report
281-
print_header("Nutrition detail report")
322+
print_header("Nutrition detail report%s" % (" (SCALED)" if scale else ""))
282323
for nutr_id, nutr_val in analysis.items():
283324
print_nutrient_bar(nutr_id, nutr_val, nutrients)
284325
# TODO: actually filter and show the number of filtered fields

0 commit comments

Comments
 (0)