Files
pig-farm-controller/internal/domain/recipe/recipe_generate_manager.go
2025-11-27 15:52:38 +08:00

339 lines
13 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,
}
}
const (
// internalFillerRawMaterialName 是内部虚拟填充料的名称。
// 该填充料用于线性规划计算确保总比例为100%,但不会出现在最终配方中。
internalFillerRawMaterialName = "内部填充料_InternalFiller"
// internalFillerNutrientID 是内部虚拟填充营养素的ID。
// 使用 math.MaxUint32 作为一个极大的、不可能与实际ID冲突的值用于关联填充料。
internalFillerNutrientID = math.MaxUint32
)
// 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("无法生成配方:未提供任何原料")
}
if len(pigType.PigNutrientRequirements) == 0 {
return nil, errors.New("无法生成配方:猪类型未设置营养需求")
}
// 收集猪类型所有有需求的营养素ID (包括min_requirement或max_requirement不为0的)。
// 用于后续过滤掉完全不相关的原料。
requiredNutrientIDs := make(map[uint32]bool)
for _, req := range pigType.PigNutrientRequirements {
requiredNutrientIDs[req.NutrientID] = true
}
// 过滤掉那些不包含猪类型任何所需营养素的原料。
var filteredMaterials []models.RawMaterial
for _, mat := range materials {
hasRelevantNutrient := false
for _, matNut := range mat.RawMaterialNutrients {
// 检查原料是否包含猪类型所需的任何营养素
if requiredNutrientIDs[matNut.NutrientID] {
hasRelevantNutrient = true
break
}
}
// 如果原料包含至少一个猪类型需求的营养素,则保留
if hasRelevantNutrient {
filteredMaterials = append(filteredMaterials, mat)
}
}
materials = filteredMaterials // 使用过滤后的原料列表
if len(materials) == 0 {
return nil, errors.New("无法生成配方:所有提供的原料都不包含猪类型所需的任何营养素,请检查原料配置或猪类型营养需求")
}
// 创建一个虚拟的、价格为0、不含任何实际营养素的填充料。
// 其唯一目的是在LP求解中作为“凑数”的选项确保总比例为100%,且不影响实际配方成本。
fillerRawMaterial := models.RawMaterial{
Model: models.Model{
ID: math.MaxUint32 - 1, // 使用一个极大的、不可能与实际原料ID冲突的值
},
Name: internalFillerRawMaterialName,
Description: "内部虚拟填充料用于线性规划凑足100%比例不含实际营养价格为0。",
ReferencePrice: 0.0, // 价格为0确保LP优先选择它来凑数
RawMaterialNutrients: []models.RawMaterialNutrient{
{
NutrientID: internalFillerNutrientID, // 关联一个虚拟营养素确保其在LP中被识别但其含量为0
Value: 0.0,
},
},
}
materials = append(materials, fillerRawMaterial) // 将填充料添加到原料列表中
// ---------------------------------------------------------
// 2. 准备数据结构
// ---------------------------------------------------------
// materialNutrients 映射: 为了快速查找原料的营养含量 [RawMaterialID][NutrientID] => Value
materialNutrients := make(map[uint32]map[uint32]float64)
// materialIndex 映射: 原料ID到矩阵列索引的映射 (前 N 列对应 N 种原料)
materialIndex := make(map[uint32]int)
// materialIDs 列表: 记录原料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)
}
}
// nutrientConstraints 存储营养素的下限和上限约束信息。
type nutrientConstraintInfo struct {
isMax bool // true=上限约束(<=), false=下限约束(>=)
nutrientID uint32
limit float64
}
var nutrientConstraints []nutrientConstraintInfo
// 添加营养约束
for _, req := range pigType.PigNutrientRequirements {
// 排除内部虚拟填充营养素的约束,因为它不应有实际需求
if req.NutrientID == internalFillerNutrientID {
continue
}
// 添加下限约束 (Value >= Min)
// 逻辑: Sum(Mat * x) >= Min -> Sum(Mat * x) - slack = Min
nutrientConstraints = append(nutrientConstraints, nutrientConstraintInfo{
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("营养素 %d 的需求配置无效: 最小需求 (%f) 大于最大需求 (%f)", req.NutrientID, req.MinRequirement, req.MaxRequirement)
}
nutrientConstraints = append(nutrientConstraints, nutrientConstraintInfo{
isMax: true,
nutrientID: req.NutrientID,
limit: float64(req.MaxRequirement),
})
}
}
// maxAdditionConstraints 存储每个原料的最大添加比例约束 (x_i <= limit)。
type maxAdditionConstraintInfo struct {
materialColIndex int // 原料在 A 矩阵中的列索引
limit float64
}
var maxAdditionConstraints []maxAdditionConstraintInfo
// 遍历所有原料,包括填充料,添加 MaxAdditionRatio 约束
for _, mat := range materials {
// 填充料不应受 MaxAdditionRatio 限制
if mat.ID == fillerRawMaterial.ID {
continue
}
// 只有当 MaxAdditionRatio > 0 时才添加约束。
// 如果 MaxAdditionRatio 为 0 或负数,则表示该原料没有最大添加比例限制。
if mat.MaxAdditionRatio > 0 {
materialColIndex, ok := materialIndex[mat.ID]
if !ok {
return nil, fmt.Errorf("内部错误:未找到原料 %d (%s) 的列索引", mat.ID, mat.Name)
}
maxAdditionConstraints = append(maxAdditionConstraints, maxAdditionConstraintInfo{
materialColIndex: materialColIndex,
limit: float64(mat.MaxAdditionRatio),
})
}
}
// ---------------------------------------------------------
// 3. 构建线性规划矩阵 (Ax = b) 和 目标函数 (c)
// ---------------------------------------------------------
numMaterials := len(materials) // 此时已包含填充料
numNutrientConstraints := len(nutrientConstraints)
numMaxAdditionConstraints := len(maxAdditionConstraints)
// 松弛变量数量 = 营养约束数量 + 最大添加比例约束数量
numSlack := numNutrientConstraints + numMaxAdditionConstraints
numCols := numMaterials + numSlack
// 行数 = 1 (总量约束) + 营养约束数量 + 最大添加比例约束数量
numRows := 1 + numNutrientConstraints + numMaxAdditionConstraints
// 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
// currentConstraintRowIndex 记录当前正在填充的约束行索引从1开始0行被总量约束占用
currentConstraintRowIndex := 1
// 填充营养约束行
for i, cons := range nutrientConstraints {
rowIndex := currentConstraintRowIndex + i
// 营养约束的松弛变量列紧跟在原料列之后
slackColIndex := numMaterials + i
b[rowIndex] = cons.limit
// 设置原料系数
for j, m := range materials {
// 获取该原料这种营养素的含量如果没有则为0
val := materialNutrients[m.ID][cons.nutrientID]
A.Set(rowIndex, j, val)
}
// 设置松弛变量系数
// 如果是下限 (>=): 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)
}
}
currentConstraintRowIndex += numNutrientConstraints // 推进当前约束行索引
// 填充 MaxAdditionRatio 约束行
for i, cons := range maxAdditionConstraints {
rowIndex := currentConstraintRowIndex + i
// MaxAdditionRatio 约束的松弛变量列在营养约束的松弛变量之后
slackColIndex := numMaterials + numNutrientConstraints + i
// 约束形式: x_j + s_k = Limit_j (其中 x_j 是原料 j 的比例s_k 是松弛变量)
A.Set(rowIndex, cons.materialColIndex, 1.0) // 原料本身的系数
A.Set(rowIndex, slackColIndex, 1.0) // 松弛变量的系数
b[rowIndex] = cons.limit
}
// ---------------------------------------------------------
// 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. 结果解析与构建
// ---------------------------------------------------------
// 统计实际原料数量(排除填充料)
actualMaterialCount := 0
for _, m := range materials {
if m.ID != fillerRawMaterial.ID {
actualMaterialCount++
}
}
recipe := &models.Recipe{
Name: fmt.Sprintf("%s-%s - 自动计算配方", pigType.Breed.Name, pigType.AgeStage.Name),
Description: fmt.Sprintf("基于 %d 种原料计算的最优成本配方。计算时预估成本: %.2f元/kg", actualMaterialCount, optVal),
RecipeIngredients: []models.RecipeIngredient{},
}
// 遍历原料部分的解 (前 numMaterials 个变量)
totalPercentage := 0.0
for i := 0; i < numMaterials; i++ {
// 排除内部虚拟填充料,不将其加入最终配方
if materialIDs[i] == fillerRawMaterial.ID {
continue
}
proportion := x[i]
// 忽略极小值 (浮点数误差)。
// 调整过滤阈值到万分之一 (0.01%)即小于0.0001的比例将被忽略。
if proportion < 1e-4 {
continue
}
// 记录总和用于最后的校验
totalPercentage += proportion
recipe.RecipeIngredients = append(recipe.RecipeIngredients, models.RecipeIngredient{
RawMaterialID: materialIDs[i],
// 比例: float64 -> float32
Percentage: float32(proportion),
})
}
// 二次校验: 确保实际原料总量不超过 100% (允许小于100%因为填充料被移除)。
// 允许略微超过100%的浮点误差,但不能显著超过。
if totalPercentage > 1.0+1e-3 {
return nil, fmt.Errorf("计算结果异常:实际原料总量超过 100%% (计算值: %.2f),请检查算法或数据配置", totalPercentage)
}
// 如果 totalPercentage 小于 1.0,说明填充料被使用,这是符合预期的。
// 此时需要在描述中说明需要添加的廉价填充料的百分比。
if totalPercentage < 1.0-1e-4 { // 允许微小的浮点误差
fillerPercentage := (1.0 - totalPercentage) * 100.0
recipe.Description = fmt.Sprintf("%s。注意配方中实际原料占比 %.2f%%,需额外补充 %.2f%% 廉价填充料", recipe.Description, totalPercentage*100.0, fillerPercentage)
}
return recipe, nil
}