Files
pig-farm-controller/internal/domain/recipe/recipe_generate_manager.go
2025-11-26 22:13:51 +08:00

286 lines
10 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 = "内部填充料_InternalFiller"
// 内部虚拟填充营养素的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. 准备数据结构
// ---------------------------------------------------------
// 映射: 为了快速查找原料的营养含量 [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 {
// 排除内部虚拟填充营养素的约束,因为它不应有实际需求
if req.NutrientID == internalFillerNutrientID {
continue
}
// 下限约束 (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("营养素 %d 的需求配置无效: 最小需求 (%f) 大于最大需求 (%f)", req.NutrientID, req.MinRequirement, req.MaxRequirement)
}
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. 结果解析与构建
// ---------------------------------------------------------
// 统计实际原料数量(排除填充料)
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]
// 忽略极小值 (浮点数误差)
if proportion < 1e-4 { // 万分之一即0.01%
continue
}
// 记录总和用于最后的校验
totalPercentage += proportion
recipe.RecipeIngredients = append(recipe.RecipeIngredients, models.RecipeIngredient{
RawMaterialID: materialIDs[i],
Percentage: float32(proportion),
})
}
// 二次校验: 确保实际原料总量不超过 100% (允许小于100%因为填充料被移除)
if totalPercentage > 1.0+1e-3 { // 允许略微超过100%的浮点误差,但不能显著超过
return nil, fmt.Errorf("计算结果异常:实际原料总量超过 100%% (计算值: %.4f),请检查算法或数据配置", totalPercentage)
}
// 如果 totalPercentage 小于 1.0,说明填充料被使用,这是符合预期的。
return recipe, nil
}