From: Shane Jaroch Date: Sun, 11 Jan 2026 08:00:01 +0000 (-0500) Subject: pre-planning deep action session X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=f32249f5fa7b91b48240f12a3cd3923776bb79d5;p=nutratech%2Fcli.git pre-planning deep action session --- diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000..d9de555 --- /dev/null +++ b/.geminiignore @@ -0,0 +1,6 @@ +.venv +.pytest_cache +__pycache__ +*.sql +*.db + diff --git a/ntclient/__init__.py b/ntclient/__init__.py index ac22a9f..1b973b8 100644 --- a/ntclient/__init__.py +++ b/ntclient/__init__.py @@ -31,7 +31,10 @@ USDA_XZ_SHA256 = "25dba8428ced42d646bec704981d3a95dc7943240254e884aad37d59eee961 # Global variables PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) -NUTRA_HOME = os.getenv("NUTRA_HOME", os.getenv("NUTRA_DIR", os.path.join(os.path.expanduser("~"), ".nutra"))) +NUTRA_HOME = os.getenv( + "NUTRA_HOME", + os.getenv("NUTRA_DIR", os.path.join(os.path.expanduser("~"), ".nutra")), +) USDA_DB_NAME = "usda.sqlite3" # NOTE: NT_DB_NAME = "nt.sqlite3" is defined in ntclient.ntsqlite.sql diff --git a/ntclient/argparser/__init__.py b/ntclient/argparser/__init__.py index 72ff3ea..5f261d2 100644 --- a/ntclient/argparser/__init__.py +++ b/ntclient/argparser/__init__.py @@ -120,6 +120,20 @@ def build_subcommand_analyze(subparsers: argparse._SubParsersAction) -> None: type=float, help="scale to custom number of grams (default is 100g)", ) + analyze_parser.add_argument( + "-s", + dest="scale", + metavar="N", + type=float, + help="scale actual values to N (default: kcal)", + ) + analyze_parser.add_argument( + "-m", + dest="scale_mode", + metavar="MODE", + type=str, + help="scale mode: 'kcal', 'weight', or nutrient name/ID", + ) analyze_parser.add_argument("food_id", type=int, nargs="+") analyze_parser.set_defaults(func=parser_funcs.analyze) @@ -145,6 +159,20 @@ def build_subcommand_day(subparsers: argparse._SubParsersAction) -> None: type=types.file_path, help="provide a custom RDA file in csv format", ) + day_parser.add_argument( + "-s", + dest="scale", + metavar="N", + type=float, + help="scale actual values to N (default: kcal)", + ) + day_parser.add_argument( + "-m", + dest="scale_mode", + metavar="MODE", + type=str, + help="scale mode: 'kcal', 'weight', or nutrient name/ID", + ) day_parser.set_defaults(func=parser_funcs.day) @@ -182,6 +210,20 @@ def build_subcommand_recipe(subparsers: argparse._SubParsersAction) -> None: recipe_anl_parser.add_argument( "path", type=str, help="view (and analyze) recipe by file path" ) + recipe_anl_parser.add_argument( + "-s", + dest="scale", + metavar="N", + type=float, + help="scale actual values to N (default: kcal)", + ) + recipe_anl_parser.add_argument( + "-m", + dest="scale_mode", + metavar="MODE", + type=str, + help="scale mode: 'kcal', 'weight', or nutrient name/ID", + ) recipe_anl_parser.set_defaults(func=parser_funcs.recipe) diff --git a/ntclient/argparser/funcs.py b/ntclient/argparser/funcs.py index ff8499d..6005598 100644 --- a/ntclient/argparser/funcs.py +++ b/ntclient/argparser/funcs.py @@ -60,17 +60,23 @@ def analyze(args: argparse.Namespace) -> tuple: # exc: ValueError, food_ids = set(args.food_id) grams = float(args.grams) if args.grams else 100.0 + scale = float(args.scale) if args.scale else 0.0 + scale_mode = args.scale_mode if args.scale_mode else "kcal" - return ntclient.services.analyze.foods_analyze(food_ids, grams) + return ntclient.services.analyze.foods_analyze( + food_ids, grams, scale=scale, scale_mode=scale_mode + ) def day(args: argparse.Namespace) -> tuple: """Analyze a day's worth of meals""" day_csv_paths = [str(os.path.expanduser(x)) for x in args.food_log] rda_csv_path = str(os.path.expanduser(args.rda)) if args.rda else str() + scale = float(args.scale) if args.scale else 0.0 + scale_mode = args.scale_mode if args.scale_mode else "kcal" return ntclient.services.analyze.day_analyze( - day_csv_paths, rda_csv_path=rda_csv_path + day_csv_paths, rda_csv_path=rda_csv_path, scale=scale, scale_mode=scale_mode ) @@ -96,8 +102,12 @@ def recipe(args: argparse.Namespace) -> tuple: @todo: use as default command? Currently this is reached by `nutra recipe anl` """ recipe_path = args.path + scale = float(args.scale) if args.scale else 0.0 + scale_mode = args.scale_mode if args.scale_mode else "kcal" - return ntclient.services.recipe.recipe.recipe_overview(recipe_path=recipe_path) + return ntclient.services.recipe.recipe.recipe_overview( + recipe_path=recipe_path, scale=scale, scale_mode=scale_mode + ) ############################################################################## diff --git a/ntclient/models/__init__.py b/ntclient/models/__init__.py index d7d7cff..5280b17 100644 --- a/ntclient/models/__init__.py +++ b/ntclient/models/__init__.py @@ -59,6 +59,54 @@ class Recipe: if CLI_CONFIG.debug: print("Finished with recipe.") - def print_analysis(self) -> None: + def print_analysis(self, scale: float = 0, scale_mode: str = "kcal") -> None: """Run analysis on a single recipe""" - # TODO: implement this + from ntclient import BUFFER_WD + from ntclient.persistence.sql.usda.funcs import ( + sql_analyze_foods, + sql_nutrients_overview, + ) + from ntclient.services.analyze import day_format + + # Get nutrient overview (RDAs, units, etc.) + nutrients_rows = sql_nutrients_overview() + nutrients = {int(x[0]): tuple(x) for x in nutrients_rows.values()} + + # Analyze foods in the recipe + food_ids = set(self.food_data.keys()) + foods_analysis = {} + for food in sql_analyze_foods(food_ids): + food_id = int(food[0]) + # nut_id, val (per 100g) + anl = (int(food[1]), float(food[2])) + if food_id not in foods_analysis: + foods_analysis[food_id] = [anl] + else: + foods_analysis[food_id].append(anl) + + # Compute totals + nutrient_totals = {} + total_weight = 0.0 + for food_id, grams in self.food_data.items(): + total_weight += grams + if food_id not in foods_analysis: + continue + for _nutrient in foods_analysis[food_id]: + nutr_id = _nutrient[0] + nutr_per_100g = _nutrient[1] + nutr_val = grams / 100 * nutr_per_100g + if nutr_id not in nutrient_totals: + nutrient_totals[nutr_id] = nutr_val + else: + nutrient_totals[nutr_id] += nutr_val + + # Print results using day_format for consistency + buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD + day_format( + nutrient_totals, + nutrients, + buffer=buffer, + scale=scale, + scale_mode=scale_mode, + total_weight=total_weight, + ) diff --git a/ntclient/services/analyze.py b/ntclient/services/analyze.py index 2a76a6e..bfe8a24 100644 --- a/ntclient/services/analyze.py +++ b/ntclient/services/analyze.py @@ -37,12 +37,12 @@ from ntclient.utils import CLI_CONFIG ############################################################################## # Foods ############################################################################## -def foods_analyze(food_ids: set, grams: float = 100) -> tuple: +def foods_analyze( + food_ids: set, grams: float = 100, scale: float = 0, scale_mode: str = "kcal" +) -> tuple: """ Analyze a list of food_ids against stock RDA values (NOTE: only supports a single food for now... add compare foods support later) - TODO: support flag -t (tabular/non-visual output) - TODO: support flag -s (scale to 2000 kcal) """ ########################################################################## @@ -101,50 +101,34 @@ def foods_analyze(food_ids: set, grams: float = 100) -> tuple: print(refuse[0]) print(" ({0}%, by mass)".format(refuse[1])) - ###################################################################### - # Nutrient colored RDA tree-view - ###################################################################### - print_header("NUTRITION") + # Prepare analysis dict for day_format + analysis_dict = {x[0]: x[1] for x in nut_val_tuples} + + # Reconstruct nutrient_rows to satisfy legacy return contract (and tests) nutrient_rows = [] - # TODO: skip small values (<1% RDA), report as color bar if RDA is available for nutrient_id, amount in nut_val_tuples: - # Skip zero values if not amount: continue - - # Get name and unit nutr_desc = nutrients[nutrient_id][4] or nutrients[nutrient_id][3] unit = nutrients[nutrient_id][2] - - # Insert RDA % into row if rdas[nutrient_id]: rda_perc = float(round(amount / rdas[nutrient_id] * 100, 1)) else: rda_perc = None row = [nutrient_id, nutr_desc, rda_perc, round(amount, 2), unit] - - # Add to list nutrient_rows.append(row) - - # Add to list of lists nutrients_rows.append(nutrient_rows) - # Calculate stuff - _kcal = next((x[1] for x in nut_val_tuples if x[0] == NUTR_ID_KCAL), 0) - - # Print view - # TODO: either make this function singular, or handle plural logic here - # TODO: support flag --absolute (use 2000 kcal; dont' scale to food kcal) - _food_id = list(food_ids)[0] - nutrient_progress_bars( - {_food_id: grams}, - [(_food_id, x[0], x[1] * (grams / 100)) for x in analyses[_food_id]], + # Print view using consistent format + buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD + day_format( + analysis_dict, nutrients, + buffer=buffer, + scale=scale, + scale_mode=scale_mode, + total_weight=grams, ) - # TODO: make this into the `-t` or `--tabular` branch of the function - # headers = ["id", "nutrient", "rda %", "amount", "units"] - # table = tabulate(nutrient_rows, headers=headers, tablefmt="presto") - # print(table) return 0, nutrients_rows, servings_rows @@ -152,7 +136,12 @@ def foods_analyze(food_ids: set, grams: float = 100) -> tuple: ############################################################################## # Day ############################################################################## -def day_analyze(day_csv_paths: Sequence[str], rda_csv_path: str = str()) -> tuple: +def day_analyze( + day_csv_paths: Sequence[str], + rda_csv_path: str = str(), + scale: float = 0, + scale_mode: str = "kcal", +) -> tuple: """Analyze a day optionally with custom RDAs, examples: ./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 # Compute totals nutrients_totals = [] + total_grams_list = [] for log in logs: nutrient_totals = OrderedDict() # NOTE: dict()/{} is NOT ORDERED before 3.6/3.7 + daily_grams = 0.0 for entry in log: if entry["id"]: food_id = int(entry["id"]) grams = float(entry["grams"]) + daily_grams += grams for _nutrient2 in foods_analysis[food_id]: nutr_id = _nutrient2[0] nutr_per_100g = _nutrient2[1] @@ -226,11 +218,19 @@ def day_analyze(day_csv_paths: Sequence[str], rda_csv_path: str = str()) -> tupl else: nutrient_totals[nutr_id] += nutr_val nutrients_totals.append(nutrient_totals) + total_grams_list.append(daily_grams) # Print results buffer = BUFFER_WD - 4 if BUFFER_WD > 4 else BUFFER_WD - for analysis in nutrients_totals: - day_format(analysis, nutrients, buffer=buffer) + for i, analysis in enumerate(nutrients_totals): + day_format( + analysis, + nutrients, + buffer=buffer, + scale=scale, + scale_mode=scale_mode, + total_weight=total_grams_list[i], + ) return 0, nutrients_totals @@ -238,14 +238,52 @@ def day_format( analysis: Mapping[int, float], nutrients: Mapping[int, tuple], buffer: int = 0, + scale: float = 0, + scale_mode: str = "kcal", + total_weight: float = 0, ) -> None: """Formats day analysis for printing to console""" + multiplier = 1.0 + if scale: + if scale_mode == "kcal": + current_val = analysis.get(NUTR_ID_KCAL, 0) + multiplier = scale / current_val if current_val else 0 + elif scale_mode == "weight": + multiplier = scale / total_weight if total_weight else 0 + else: + # Try to interpret scale_mode as nutrient ID or Name + target_id = None + # 1. Check if int + try: + target_id = int(scale_mode) + except ValueError: + # 2. Check names + for n_id, n_data in nutrients.items(): + # n_data usually: (id, rda, unit, tag, name, ...) + # Check tag or desc + if scale_mode.lower() in str(n_data[3]).lower(): + target_id = n_id + break + if scale_mode.lower() in str(n_data[4]).lower(): + target_id = n_id + break + + if target_id and target_id in analysis: + current_val = analysis[target_id] + multiplier = scale / current_val if current_val else 0 + else: + print(f"WARN: Could not scale by '{scale_mode}', nutrient not found.") + + # Apply multiplier + if multiplier != 1.0: + analysis = {k: v * multiplier for k, v in analysis.items()} + # Actual values - kcals = round(analysis[NUTR_ID_KCAL]) - pro = analysis[NUTR_ID_PROTEIN] - net_carb = analysis[NUTR_ID_CARBS] - analysis[NUTR_ID_FIBER] - fat = analysis[NUTR_ID_FAT_TOT] + kcals = round(analysis.get(NUTR_ID_KCAL, 0)) + pro = analysis.get(NUTR_ID_PROTEIN, 0) + net_carb = analysis.get(NUTR_ID_CARBS, 0) - analysis.get(NUTR_ID_FIBER, 0) + fat = analysis.get(NUTR_ID_FAT_TOT, 0) kcals_449 = round(4 * pro + 4 * net_carb + 9 * fat) # Desired values @@ -257,12 +295,15 @@ def day_format( # Print calories and macronutrient bars print_header("Macro-nutrients") kcals_max = max(kcals, kcals_rda) - rda_perc = round(kcals * 100 / kcals_rda, 1) + rda_perc = round(kcals * 100 / kcals_rda, 1) if kcals_rda else 0 print( "Actual: {0} kcal ({1}% RDA), {2} by 4-4-9".format( kcals, rda_perc, kcals_449 ) ) + if scale: + print(" (Scaled to %s %s)" % (scale, scale_mode)) + print_macro_bar(fat, net_carb, pro, kcals_max, _buffer=buffer) print( "\nDesired: {0} kcal ({1} kcal)".format( @@ -278,7 +319,7 @@ def day_format( ) # Nutrition detail report - print_header("Nutrition detail report") + print_header("Nutrition detail report%s" % (" (SCALED)" if scale else "")) for nutr_id, nutr_val in analysis.items(): print_nutrient_bar(nutr_id, nutr_val, nutrients) # TODO: actually filter and show the number of filtered fields diff --git a/ntclient/services/recipe/recipe.py b/ntclient/services/recipe/recipe.py index eea0460..6f8e0fd 100644 --- a/ntclient/services/recipe/recipe.py +++ b/ntclient/services/recipe/recipe.py @@ -57,18 +57,22 @@ def recipes_overview() -> tuple: return 1, None -def recipe_overview(recipe_path: str) -> tuple: +def recipe_overview( + recipe_path: str, scale: float = 0, scale_mode: str = "kcal" +) -> tuple: """ Shows single recipe overview @param recipe_path: full path on disk + @param scale: optional target value to scale to + @param scale_mode: mode for scaling (kcal, weight, nutrient) @return: (exit_code: int, None) """ try: _recipe = Recipe(recipe_path) _recipe.process_data() - # TODO: extract relevant bits off, process, use nutprogbar (e.g. day analysis) + _recipe.print_analysis(scale=scale, scale_mode=scale_mode) return 0, _recipe except (FileNotFoundError, IndexError) as err: print("ERROR: %s" % repr(err))