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) / 100.0, }) } } // --------------------------------------------------------- // 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) } // 松弛变量的成本为 0,Go 默认初始化为 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.0), }) } // 二次校验: 确保实际原料总量不超过 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 }