]> Nutra Git (v1) - nutratech/cli.git/commitdiff
pre-planning deep action session
authorShane Jaroch <chown_tee@proton.me>
Sun, 11 Jan 2026 08:00:01 +0000 (03:00 -0500)
committerShane Jaroch <chown_tee@proton.me>
Sun, 11 Jan 2026 08:00:01 +0000 (03:00 -0500)
.geminiignore [new file with mode: 0644]
ntclient/__init__.py
ntclient/argparser/__init__.py
ntclient/argparser/funcs.py
ntclient/models/__init__.py
ntclient/services/analyze.py
ntclient/services/recipe/recipe.py

diff --git a/.geminiignore b/.geminiignore
new file mode 100644 (file)
index 0000000..d9de555
--- /dev/null
@@ -0,0 +1,6 @@
+.venv
+.pytest_cache
+__pycache__
+*.sql
+*.db
+
index ac22a9fd3690f5d66cfb33e9e111b157461bdb6a..1b973b85c0da12fda7739443d84dda617b6f5c50 100644 (file)
@@ -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
 
index 72ff3ea16dc7927d9571f469ddc377a8fa439178..5f261d22d1c8cab035240bd6c1b24a7096fcdefa 100644 (file)
@@ -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)
 
 
index ff8499d622f97142298fc6d0c53f75a8b2c75280..600559823d2e72024c6e26885e845086f716d0de 100644 (file)
@@ -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
+    )
 
 
 ##############################################################################
index d7d7cfff1871cfc97822176c37532ec475a1dcd3..5280b171326dd253b4ecc4c6e3a66c35f5c07a31 100644 (file)
@@ -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,
+        )
index 2a76a6e428f1517591b4ea58e4a6254f673a21a2..bfe8a24e498c4bb43aa3a05ffec735d34443c672 100644 (file)
@@ -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
index eea0460b493f395109e472e7e2e60abf3324ae39..6f8e0fd8122529e481e794b4cb2c834d3751adbe 100644 (file)
@@ -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))