Files
DailyMeals/MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Piotr Kus f15bb7a916 * Extended functionalities
* Added different UIs
2026-06-24 21:43:40 +02:00

222 lines
7.4 KiB
C#

namespace MealPlan.Api.Services.Generators;
public record RecipeCandidate(
int Id,
string Name,
int MealCategory,
int Calories,
decimal Protein,
decimal Fat,
decimal Carbs);
public class DraftMealSlot
{
public DateOnly PlanDate { get; set; }
public int MealCategory { get; set; }
public int RecipeId { get; set; }
public decimal Portions { get; set; } = 1m;
public int CaloriesPerPortion { get; set; }
public decimal ProteinPerPortion { get; set; }
public decimal FatPerPortion { get; set; }
public decimal CarbsPerPortion { get; set; }
public bool IsLocked { get; set; }
public int TotalCalories => (int)Math.Round(CaloriesPerPortion * Portions);
public decimal TotalProtein => ProteinPerPortion * Portions;
public decimal TotalFat => FatPerPortion * Portions;
public decimal TotalCarbs => CarbsPerPortion * Portions;
public DraftMealSlot CloneForDate(DateOnly planDate) =>
new()
{
PlanDate = planDate,
MealCategory = MealCategory,
RecipeId = RecipeId,
Portions = Portions,
CaloriesPerPortion = CaloriesPerPortion,
ProteinPerPortion = ProteinPerPortion,
FatPerPortion = FatPerPortion,
CarbsPerPortion = CarbsPerPortion,
IsLocked = IsLocked,
};
}
public static class MealPlanOptimizer
{
public static IReadOnlyList<int> MealCategoriesFromMask(int mask) =>
Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList();
public static void BalanceDay(IList<DraftMealSlot> meals, int targetCalories, int tolerance)
{
if (meals.Count == 0) return;
var unlocked = meals.Where(m => !m.IsLocked).ToList();
if (unlocked.Count == 0) return;
var current = meals.Sum(m => m.TotalCalories);
if (Math.Abs(current - targetCalories) <= tolerance) return;
if (current <= 0)
{
foreach (var meal in unlocked)
{
meal.Portions = 1m;
}
current = meals.Sum(m => m.TotalCalories);
}
if (current <= 0) return;
var scale = (decimal)targetCalories / current;
foreach (var meal in unlocked)
{
meal.Portions = Math.Clamp(Math.Round(meal.Portions * scale, 2), 0.5m, 2.5m);
}
current = meals.Sum(m => m.TotalCalories);
if (Math.Abs(current - targetCalories) <= tolerance) return;
var diff = targetCalories - current;
var adjustable = unlocked.OrderByDescending(m => m.CaloriesPerPortion).FirstOrDefault();
if (adjustable is null || adjustable.CaloriesPerPortion <= 0) return;
var portionDelta = diff / (decimal)adjustable.CaloriesPerPortion;
adjustable.Portions = Math.Clamp(Math.Round(adjustable.Portions + portionDelta, 2), 0.5m, 2.5m);
}
public static bool IsDayWithinTolerance(IEnumerable<DraftMealSlot> meals, int targetCalories, int tolerance) =>
Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance;
private static RecipeCandidate? FindGreedyCandidate(
IReadOnlyList<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> excludedRecipeIds)
{
var candidates = pool
.Where(r => r.MealCategory == mealCategory && !excludedRecipeIds.Contains(r.Id))
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
.ToList();
if (candidates.Count == 0)
{
candidates = pool
.Where(r => !excludedRecipeIds.Contains(r.Id))
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
.ToList();
}
return candidates.FirstOrDefault();
}
public static bool TryPickGreedyMeal(
IReadOnlyList<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> excludedRecipeIds,
out DraftMealSlot slot)
{
var recipe = FindGreedyCandidate(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds);
if (recipe is null)
{
slot = null!;
return false;
}
var portions = recipe.Calories > 0
? Math.Clamp(Math.Round((decimal)targetCaloriesForSlot / recipe.Calories, 2), 0.5m, 2.5m)
: 1m;
slot = new DraftMealSlot
{
MealCategory = mealCategory,
RecipeId = recipe.Id,
Portions = portions,
CaloriesPerPortion = recipe.Calories,
ProteinPerPortion = recipe.Protein,
FatPerPortion = recipe.Fat,
CarbsPerPortion = recipe.Carbs,
};
return true;
}
public static DraftMealSlot PickGreedyMeal(
IReadOnlyList<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> excludedRecipeIds)
{
if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot))
{
return slot;
}
throw new InvalidOperationException("No recipes available for meal planning.");
}
public static IReadOnlyList<DraftMealSlot> BuildGreedyDay(
DateOnly planDate,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
int targetCalories,
int tolerance,
HashSet<int> excludedRecipeIds)
{
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
var meals = new List<DraftMealSlot>();
foreach (var category in mealCategories)
{
var slot = PickGreedyMeal(pool, category, perSlot, excludedRecipeIds);
slot.PlanDate = planDate;
meals.Add(slot);
excludedRecipeIds.Add(slot.RecipeId);
}
BalanceDay(meals, targetCalories, tolerance);
return meals;
}
public static int RequiredUniqueRecipes(int planDayCount, IReadOnlyList<int> mealCategories) =>
planDayCount * mealCategories.Count;
/// <summary>
/// Replaces duplicate recipe picks so each recipe appears at most once in the plan.
/// </summary>
public static void EnsureWeeklyUniqueRecipes(
IList<DraftMealSlot> slots,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
int targetCalories,
int tolerance)
{
if (slots.Count == 0) return;
var used = new HashSet<int>();
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
foreach (var slot in slots.OrderBy(s => s.PlanDate).ThenBy(s => s.MealCategory))
{
if (used.Add(slot.RecipeId)) continue;
var replacement = PickGreedyMeal(pool, slot.MealCategory, perSlot, used);
CopyRecipeIntoSlot(slot, replacement);
used.Add(slot.RecipeId);
var daySlots = slots.Where(s => s.PlanDate == slot.PlanDate).ToList();
BalanceDay(daySlots, targetCalories, tolerance);
}
}
private static void CopyRecipeIntoSlot(DraftMealSlot target, DraftMealSlot source)
{
target.RecipeId = source.RecipeId;
target.Portions = source.Portions;
target.CaloriesPerPortion = source.CaloriesPerPortion;
target.ProteinPerPortion = source.ProteinPerPortion;
target.FatPerPortion = source.FatPerPortion;
target.CarbsPerPortion = source.CarbsPerPortion;
}
}