Files
pig-farm-controller/internal/domain/recipe/recipe_generate_manager.go
2025-11-26 20:23:29 +08:00

214 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package recipe
import (
"context"
"errors"
"fmt"
"math"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gonum.org/v1/gonum/mat"
"gonum.org/v1/gonum/optimize/convex/lp"
)
// RecipeGenerateManager 定义了配方生成器的能力。
// 它可以有多种实现,例如基于成本优化、基于生长性能优化等。
type RecipeGenerateManager interface {
// GenerateRecipe 根据猪的营养需求和可用原料,生成一个配方。
GenerateRecipe(ctx context.Context, pigType models.PigType, materials []models.RawMaterial) (*models.Recipe, error)
}
// recipeGenerateManagerImpl 是 RecipeGenerateManager 的默认实现。
// 它实现了基于成本最优的配方生成逻辑。
type recipeGenerateManagerImpl struct {
ctx context.Context
}
// NewRecipeGenerateManager 创建一个默认的配方生成器实例。
func NewRecipeGenerateManager(ctx context.Context) RecipeGenerateManager {
return &recipeGenerateManagerImpl{
ctx: ctx,
}
}
// GenerateRecipe 根据猪的营养需求和可用原料,使用线性规划计算出成本最低的饲料配方。
func (r *recipeGenerateManagerImpl) GenerateRecipe(ctx context.Context, pigType models.PigType, materials []models.RawMaterial) (*models.Recipe, error) {
// 1. 基础校验
if len(materials) == 0 {
return nil, errors.New("cannot generate recipe: no raw materials provided")
}
if len(pigType.PigNutrientRequirements) == 0 {
return nil, errors.New("cannot generate recipe: pig type has no nutrient requirements")
}
// ---------------------------------------------------------
// 2. 准备数据结构
// ---------------------------------------------------------
// 映射: 为了快速查找原料的营养含量 [RawMaterialID][NutrientID] => Value
materialNutrients := make(map[uint32]map[uint32]float64)
// 映射: 原料ID到矩阵列索引的映射 (前 N 列对应 N 种原料)
materialIndex := make(map[uint32]int)
// 列表: 记录原料ID以便结果回溯
materialIDs := make([]uint32, len(materials))
for i, m := range materials {
materialIndex[m.ID] = i
materialIDs[i] = m.ID
materialNutrients[m.ID] = make(map[uint32]float64)
for _, n := range m.RawMaterialNutrients {
// 注意:这里假设 float32 转 float64 精度足够
materialNutrients[m.ID][n.NutrientID] = float64(n.Value)
}
}
// 识别约束数量
// 约束 1: 总重量 = 1 (100%)
// 约束 2..N: 营养素下限 (Min)
// 约束 N..M: 营养素上限 (Max, 仅当 Max > 0 时)
type constraintInfo struct {
isMax bool // true=上限约束(<=), false=下限约束(>=)
nutrientID uint32
limit float64
}
var constraints []constraintInfo
// 添加营养约束
for _, req := range pigType.PigNutrientRequirements {
// 下限约束 (Value >= Min)
// 逻辑: Sum(Mat * x) >= Min -> Sum(Mat * x) - slack = Min
constraints = append(constraints, constraintInfo{
isMax: false,
nutrientID: req.NutrientID,
limit: float64(req.MinRequirement),
})
// 上限约束 (Value <= Max)
// 逻辑: Sum(Mat * x) <= Max -> Sum(Mat * x) + slack = Max
if req.MaxRequirement > 0 {
// 简单的校验,如果 Min > Max 则是逻辑矛盾,直接报错
if req.MinRequirement > req.MaxRequirement {
return nil, fmt.Errorf("invalid requirement for nutrient %d: min > max", req.NutrientID)
}
constraints = append(constraints, constraintInfo{
isMax: true,
nutrientID: req.NutrientID,
limit: float64(req.MaxRequirement),
})
}
}
// ---------------------------------------------------------
// 3. 构建线性规划矩阵 (Ax = b) 和 目标函数 (c)
// ---------------------------------------------------------
// 变量总数 = 原料数量 + 松弛变量数量
// 松弛变量数量 = 约束数量 (每个不等式约束需要一个松弛变量)
// 注意:总量约束 (Sum=1) 是等式,理论上不需要松弛变量,但在单纯形法标准型中通常处理为 Ax=b
numMaterials := len(materials)
numSlack := len(constraints)
numCols := numMaterials + numSlack
// 行数 = 1 (总量约束) + 营养约束数量
numRows := 1 + len(constraints)
// A: 约束系数矩阵
A := mat.NewDense(numRows, numCols, nil)
// b: 约束值向量
b := make([]float64, numRows)
// c: 成本向量 (目标函数系数)
c := make([]float64, numCols)
// --- 填充 c (成本) ---
for i, m := range materials {
c[i] = float64(m.ReferencePrice)
}
// 松弛变量的成本为 0Go 默认初始化为 0无需操作
// --- 填充 Row 0: 总量约束 (Sum(x) = 1) ---
// 系数: 所有原料对应列为 1松弛变量列为 0
for j := 0; j < numMaterials; j++ {
A.Set(0, j, 1.0)
}
b[0] = 1.0
// --- 填充营养约束行 ---
for i, cons := range constraints {
rowIndex := i + 1 // 0行被总量约束占用所以从1开始
slackColIndex := numMaterials + i // 松弛变量列紧跟在原料列之后
b[rowIndex] = cons.limit
// 1. 设置原料系数
for j, m := range materials {
// 获取该原料这种营养素的含量如果没有则为0
val := materialNutrients[m.ID][cons.nutrientID]
A.Set(rowIndex, j, val)
}
// 2. 设置松弛变量系数
// 如果是下限 (>=): Sum - s = Limit => s系数为 -1
// 如果是上限 (<=): Sum + s = Limit => s系数为 +1
if cons.isMax {
A.Set(rowIndex, slackColIndex, 1.0)
} else {
A.Set(rowIndex, slackColIndex, -1.0)
}
}
// ---------------------------------------------------------
// 4. 执行单纯形法求解
// ---------------------------------------------------------
// lp.Simplex 求解: minimize c^T * x subject to A * x = b, x >= 0
optVal, x, err := lp.Simplex(c, A, b, 1e-8, nil)
if err != nil {
if errors.Is(err, lp.ErrInfeasible) {
return nil, errors.New("无法生成配方:根据提供的原料,无法满足所有营养需求 (无可行解)")
}
if errors.Is(err, lp.ErrUnbounded) {
return nil, errors.New("计算错误:解无界 (可能数据配置有误)")
}
return nil, fmt.Errorf("配方计算失败: %w", err)
}
// ---------------------------------------------------------
// 5. 结果解析与构建
// ---------------------------------------------------------
recipe := &models.Recipe{
Name: fmt.Sprintf("%s-%s - 自动计算配方", pigType.Breed.Name, pigType.AgeStage.Name),
Description: fmt.Sprintf("基于 %d 种原料计算的最优成本配方。计算时预估成本: %.2f", len(materials), optVal),
RecipeIngredients: []models.RecipeIngredient{},
}
// 遍历原料部分的解 (前 numMaterials 个变量)
totalPercentage := 0.0
for i := 0; i < numMaterials; i++ {
proportion := x[i]
// 忽略极小值 (浮点数误差)
if proportion < 1e-6 {
continue
}
// 记录总和用于最后的校验
totalPercentage += proportion
recipe.RecipeIngredients = append(recipe.RecipeIngredients, models.RecipeIngredient{
RawMaterialID: materialIDs[i],
// 数据库可能需要 RawMaterial 对象这里只填ID由调用方或ORM处理加载
// 比例: float64 -> float32
Percentage: float32(proportion),
})
}
// 二次校验: 确保总量约为 1
if math.Abs(totalPercentage-1.0) > 1e-3 {
return nil, fmt.Errorf("计算结果异常:原料总量不为 100%% (计算值: %.4f)", totalPercentage)
}
return recipe, nil
}