222 lines
7.4 KiB
C#
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;
|
|
}
|
|
}
|