* Extended functionalities
* Added different UIs
This commit is contained in:
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user