Files
pig-farm-controller/internal/domain/recipe/recipe_generate_manager.go

286 lines
10 KiB
Go
Raw Normal View History

2025-11-26 20:23:29 +08:00
package recipe
import (
"context"
"errors"
"fmt"
"math"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
2025-11-26 20:23:29 +08:00
"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,
}
}
2025-11-26 22:13:51 +08:00
const (
// 内部虚拟填充料的名称,用于线性规划计算,不应出现在最终配方中。
internalFillerRawMaterialName = "内部填充料_InternalFiller"
// 内部虚拟填充营养素的ID用于关联填充料确保其不与实际营养素冲突。
// 使用 math.MaxUint32 作为一个极大的、不可能与实际ID冲突的值。
internalFillerNutrientID = math.MaxUint32
)
2025-11-26 20:23:29 +08:00
// GenerateRecipe 根据猪的营养需求和可用原料,使用线性规划计算出成本最低的饲料配方。
func (r *recipeGenerateManagerImpl) GenerateRecipe(ctx context.Context, pigType models.PigType, materials []models.RawMaterial) (*models.Recipe, error) {
// 1. 基础校验
if len(materials) == 0 {
2025-11-26 22:13:51 +08:00
return nil, errors.New("无法生成配方:未提供任何原料")
2025-11-26 20:23:29 +08:00
}
if len(pigType.PigNutrientRequirements) == 0 {
2025-11-26 22:13:51 +08:00
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)
}
2025-11-26 20:23:29 +08:00
}
2025-11-26 22:13:51 +08:00
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) // 将填充料添加到原料列表中
2025-11-26 20:23:29 +08:00
// ---------------------------------------------------------
// 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 {
2025-11-26 22:13:51 +08:00
// 排除内部虚拟填充营养素的约束,因为它不应有实际需求
if req.NutrientID == internalFillerNutrientID {
continue
}
2025-11-26 20:23:29 +08:00
// 下限约束 (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 {
2025-11-26 22:13:51 +08:00
return nil, fmt.Errorf("营养素 %d 的需求配置无效: 最小需求 (%f) 大于最大需求 (%f)", req.NutrientID, req.MinRequirement, req.MaxRequirement)
2025-11-26 20:23:29 +08:00
}
constraints = append(constraints, constraintInfo{
isMax: true,
nutrientID: req.NutrientID,
limit: float64(req.MaxRequirement),
})
}
}
// ---------------------------------------------------------
// 3. 构建线性规划矩阵 (Ax = b) 和 目标函数 (c)
// ---------------------------------------------------------
// 变量总数 = 原料数量 + 松弛变量数量
// 松弛变量数量 = 约束数量 (每个不等式约束需要一个松弛变量)
// 注意:总量约束 (Sum=1) 是等式,理论上不需要松弛变量,但在单纯形法标准型中通常处理为 Ax=b
2025-11-26 22:13:51 +08:00
numMaterials := len(materials) // 此时已包含填充料
2025-11-26 20:23:29 +08:00
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) {
2025-11-26 22:13:51 +08:00
return nil, errors.New("无法生成配方:根据提供的原料,无法满足所有营养需求 (无可行解),请检查原料或营养需求配置")
2025-11-26 20:23:29 +08:00
}
if errors.Is(err, lp.ErrUnbounded) {
2025-11-26 22:13:51 +08:00
return nil, errors.New("计算错误:解无界 (可能数据配置有误,例如某个营养素没有上限约束且成本为负)")
2025-11-26 20:23:29 +08:00
}
return nil, fmt.Errorf("配方计算失败: %w", err)
}
// ---------------------------------------------------------
// 5. 结果解析与构建
// ---------------------------------------------------------
2025-11-26 22:13:51 +08:00
// 统计实际原料数量(排除填充料)
actualMaterialCount := 0
for _, m := range materials {
if m.ID != fillerRawMaterial.ID {
actualMaterialCount++
}
}
2025-11-26 20:23:29 +08:00
recipe := &models.Recipe{
Name: fmt.Sprintf("%s-%s - 自动计算配方", pigType.Breed.Name, pigType.AgeStage.Name),
2025-11-26 22:13:51 +08:00
Description: fmt.Sprintf("基于 %d 种原料计算的最优成本配方。计算时预估成本: %.2f元/kg", actualMaterialCount, optVal),
2025-11-26 20:23:29 +08:00
RecipeIngredients: []models.RecipeIngredient{},
}
// 遍历原料部分的解 (前 numMaterials 个变量)
totalPercentage := 0.0
for i := 0; i < numMaterials; i++ {
2025-11-26 22:13:51 +08:00
// 排除内部虚拟填充料 ---
if materialIDs[i] == fillerRawMaterial.ID {
continue // 跳过填充料,不将其加入最终配方
}
2025-11-26 20:23:29 +08:00
proportion := x[i]
// 忽略极小值 (浮点数误差)
2025-11-26 22:13:51 +08:00
if proportion < 1e-4 { // 万分之一即0.01%
2025-11-26 20:23:29 +08:00
continue
}
// 记录总和用于最后的校验
totalPercentage += proportion
recipe.RecipeIngredients = append(recipe.RecipeIngredients, models.RecipeIngredient{
RawMaterialID: materialIDs[i],
2025-11-26 22:13:51 +08:00
Percentage: float32(proportion),
2025-11-26 20:23:29 +08:00
})
}
2025-11-26 22:13:51 +08:00
// 二次校验: 确保实际原料总量不超过 100% (允许小于100%因为填充料被移除)
if totalPercentage > 1.0+1e-3 { // 允许略微超过100%的浮点误差,但不能显著超过
return nil, fmt.Errorf("计算结果异常:实际原料总量超过 100%% (计算值: %.4f),请检查算法或数据配置", totalPercentage)
2025-11-26 20:23:29 +08:00
}
2025-11-26 22:13:51 +08:00
// 如果 totalPercentage 小于 1.0,说明填充料被使用,这是符合预期的。
2025-11-26 20:23:29 +08:00
return recipe, nil
}