785 lines
31 KiB
Python
785 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""Ti-2 配方动态优化 · 优化问题建模(约束/目标定义)(Issue #78 / PRD 5.3 ②)。
|
|||
|
|
|
|||
|
|
承接 PRD 5.3「② 配方动态优化」与超参包示例(``objective`` / ``features`` /
|
|||
|
|
``target``):把"给定质量目标 + 工艺约束,求最优配方/参数"这条链路**模板化、
|
|||
|
|
可配置、可测试**,且与 #79 求解器、#80 跨工序寻优、#81 可解释建议解耦。
|
|||
|
|
|
|||
|
|
PRD 设计口径
|
|||
|
|
------------
|
|||
|
|
- 架构表(PRD §5.3):``工艺优化/配方推荐 | 优化/推荐 | 入:质量目标+约束;
|
|||
|
|
出:参数/配方建议 | ② 配方动态优化 | 高(需闭环反馈)``。
|
|||
|
|
- 模板化技术路径:超参包驱动——``objective``、输入特征清单、``target`` 等可变量
|
|||
|
|
外置为 JSON 超参包,切换模板仅改此包;跨行业差异落资产,不落代码。
|
|||
|
|
- 风险表:二期交付(一期数据门槛不足),故本期**先把问题建模沉淀为可校验的声明
|
|||
|
|
式规格**,为 #79 求解器、#80 跨工序寻优、#81 可解释建议提供**统一的问题描述
|
|||
|
|
契约**;先有"能跑通、可测试"的模型,数据就绪后接求解器(#79/#80)。
|
|||
|
|
|
|||
|
|
本模块交付
|
|||
|
|
----------
|
|||
|
|
1. **决策变量 ``DecisionVariable``**:配方/工艺可调参数的声明式规格——变量名、
|
|||
|
|
单位、取值域(``Bounds`` 连续区间 / ``Choices`` 离散枚举)、初值、是否整型、
|
|||
|
|
工艺含义(``meaning``,供 #81 可解释建议引用)。
|
|||
|
|
2. **目标函数规格 ``ObjectiveSpec``**:``Sense``(minimize/maximize)+ 目标项
|
|||
|
|
(``ObjectiveTerm``:系数 × 变量,线性目标)+ 目标 ``target``(PRD 超参包字段)。
|
|||
|
|
3. **约束规格 ``ConstraintSpec``**:``ConstraintKind``(box / linear / ratio /
|
|||
|
|
forbidden)统一描述工艺约束(温度上下限、配方配比、禁止组合等)。
|
|||
|
|
4. **问题模型 ``OptimizationProblem``**:聚合变量 + 目标 + 约束,提供校验
|
|||
|
|
(``validate``,聚合并列出全部错误,便于配置台一次性反馈)、声明式加载
|
|||
|
|
(零第三方依赖 YAML 子集解析,与 data-bus/rag-kb/impurity-forecast 同款)。
|
|||
|
|
|
|||
|
|
设计要点
|
|||
|
|
--------
|
|||
|
|
- **零运行时依赖**(纯标准库):与内核既有模块一致,便于离线/隔离网部署。
|
|||
|
|
- **求解器无关**:本模块只描述"问题",``solve`` 留给 #79 注入;便于换行业复用、
|
|||
|
|
单测无需真实求解器。
|
|||
|
|
- **可解释前置**:变量 ``meaning`` + 约束 ``reason`` 字段,为 #81 优化建议"可溯源"
|
|||
|
|
预留引用依据(对齐 PRD"要求结果可解释、可溯源,要引用依据")。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
import os
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
|||
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|||
|
|
|
|||
|
|
# 缺失值统一用 float('nan'),与 impurity-forecast 一致,便于上层判空屏蔽。
|
|||
|
|
NAN = float("nan")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProblemError(ValueError):
|
|||
|
|
"""配方优化问题建模错误(未知变量 / 越界 / 约束矛盾 / 重复定义等)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 决策变量
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DomainKind(str, Enum):
|
|||
|
|
"""决策变量取值域类型。"""
|
|||
|
|
|
|||
|
|
BOUNDS = "bounds" # 连续区间 [low, high](如温度 800~900℃)
|
|||
|
|
CHOICES = "choices" # 离散枚举(如催化剂型号 A/B/C)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class DecisionVariable:
|
|||
|
|
"""一个可调配方/工艺参数的声明式规格。
|
|||
|
|
|
|||
|
|
``bounds`` 与 ``choices`` 二选一(由 ``kind`` 决定):
|
|||
|
|
- ``bounds``:``[low, high]``,``integer=True`` 时取整;
|
|||
|
|
- ``choices``:离散可选值列表(任意可比较的标量,多为 float/str)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
kind: DomainKind
|
|||
|
|
meaning: str = "" # 工艺含义,供 #81 可解释建议引用
|
|||
|
|
unit: str = "" # 单位(℃、m³/h、kg、…)
|
|||
|
|
bounds: Optional[Tuple[float, float]] = None
|
|||
|
|
choices: Optional[List[Any]] = None
|
|||
|
|
initial: Optional[float] = None # 当前工况/配方初值
|
|||
|
|
integer: bool = False # 仅 bounds 连续域生效
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
if not self.name or not str(self.name).strip():
|
|||
|
|
raise ProblemError("DecisionVariable.name 不能为空")
|
|||
|
|
if self.kind == DomainKind.BOUNDS:
|
|||
|
|
if self.bounds is None:
|
|||
|
|
raise ProblemError(f"变量 {self.name!r} kind=bounds 但未提供 bounds")
|
|||
|
|
low, high = self.bounds
|
|||
|
|
if _is_num(low) and _is_num(high) and low > high:
|
|||
|
|
raise ProblemError(
|
|||
|
|
f"变量 {self.name!r} bounds 下界 {low} 大于上界 {high}")
|
|||
|
|
if self.integer and self.bounds is not None:
|
|||
|
|
low, high = self.bounds
|
|||
|
|
if _is_num(low) and float(low).is_integer() is False:
|
|||
|
|
raise ProblemError(
|
|||
|
|
f"变量 {self.name!r} integer=True 但下界 {low} 非整")
|
|||
|
|
if _is_num(high) and float(high).is_integer() is False:
|
|||
|
|
raise ProblemError(
|
|||
|
|
f"变量 {self.name!r} integer=True 但上界 {high} 非整")
|
|||
|
|
elif self.kind == DomainKind.CHOICES:
|
|||
|
|
if not self.choices:
|
|||
|
|
raise ProblemError(f"变量 {self.name!r} kind=choices 但 choices 为空")
|
|||
|
|
else: # pragma: no cover - 枚举穷尽
|
|||
|
|
raise ProblemError(f"变量 {self.name!r} 未知 kind={self.kind!r}")
|
|||
|
|
|
|||
|
|
def contains(self, value: Any) -> bool:
|
|||
|
|
"""取值是否落在该变量合法域内。"""
|
|||
|
|
if self.kind == DomainKind.BOUNDS and self.bounds is not None:
|
|||
|
|
if not _is_num(value):
|
|||
|
|
return False
|
|||
|
|
low, high = self.bounds
|
|||
|
|
if self.integer and float(value).is_integer() is False:
|
|||
|
|
return False
|
|||
|
|
return low <= value <= high
|
|||
|
|
# choices
|
|||
|
|
return value in (self.choices or [])
|
|||
|
|
|
|||
|
|
def clamp(self, value: Any) -> Any:
|
|||
|
|
"""把越界的连续域取值夹回合法区间(离散域不夹,原值返回)。"""
|
|||
|
|
if self.kind == DomainKind.BOUNDS and self.bounds is not None and _is_num(value):
|
|||
|
|
low, high = self.bounds
|
|||
|
|
value = max(low, min(high, value))
|
|||
|
|
if self.integer:
|
|||
|
|
value = float(round(value))
|
|||
|
|
return value
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
d: Dict[str, Any] = {
|
|||
|
|
"name": self.name,
|
|||
|
|
"kind": self.kind.value,
|
|||
|
|
"meaning": self.meaning,
|
|||
|
|
"unit": self.unit,
|
|||
|
|
"integer": self.integer,
|
|||
|
|
}
|
|||
|
|
if self.kind == DomainKind.BOUNDS:
|
|||
|
|
d["bounds"] = list(self.bounds) if self.bounds else None
|
|||
|
|
else:
|
|||
|
|
d["choices"] = list(self.choices) if self.choices else None
|
|||
|
|
if self.initial is not None:
|
|||
|
|
d["initial"] = self.initial
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, d: Dict[str, Any]) -> "DecisionVariable":
|
|||
|
|
name = d.get("name")
|
|||
|
|
if not isinstance(name, str):
|
|||
|
|
raise ProblemError("DecisionVariable 缺少 name 字段")
|
|||
|
|
kind_raw = d.get("kind", "bounds")
|
|||
|
|
try:
|
|||
|
|
kind = DomainKind(str(kind_raw))
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise ProblemError(f"变量 {name!r} 未知 kind={kind_raw!r}") from e
|
|||
|
|
bounds = d.get("bounds")
|
|||
|
|
choices = d.get("choices")
|
|||
|
|
if kind == DomainKind.BOUNDS and bounds is not None:
|
|||
|
|
if (not isinstance(bounds, (list, tuple))) or len(bounds) != 2:
|
|||
|
|
raise ProblemError(f"变量 {name!r} bounds 必须是 [low, high]")
|
|||
|
|
bounds = (float(bounds[0]), float(bounds[1]))
|
|||
|
|
if kind == DomainKind.CHOICES and choices is not None:
|
|||
|
|
choices = list(choices)
|
|||
|
|
return cls(
|
|||
|
|
name=name,
|
|||
|
|
kind=kind,
|
|||
|
|
meaning=str(d.get("meaning", "")),
|
|||
|
|
unit=str(d.get("unit", "")),
|
|||
|
|
bounds=bounds,
|
|||
|
|
choices=choices,
|
|||
|
|
initial=d.get("initial"),
|
|||
|
|
integer=bool(d.get("integer", False)),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 目标函数
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Sense(str, Enum):
|
|||
|
|
"""优化方向。"""
|
|||
|
|
|
|||
|
|
MINIMIZE = "minimize"
|
|||
|
|
MAXIMIZE = "maximize"
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def label(self) -> str:
|
|||
|
|
return {Sense.MINIMIZE: "最小化", Sense.MAXIMIZE: "最大化"}[self]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ObjectiveTerm:
|
|||
|
|
"""线性目标项:``coefficient * variable``(变量名引用 ``DecisionVariable.name``)。"""
|
|||
|
|
|
|||
|
|
variable: str
|
|||
|
|
coefficient: float = 1.0
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {"variable": self.variable, "coefficient": self.coefficient}
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveTerm":
|
|||
|
|
if "variable" not in d:
|
|||
|
|
raise ProblemError("ObjectiveTerm 缺少 variable 字段")
|
|||
|
|
return cls(variable=str(d["variable"]), coefficient=float(d.get("coefficient", 1.0)))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ObjectiveSpec:
|
|||
|
|
"""目标函数声明式规格(线性加权,对齐 PRD 超参包 ``objective`` 字段)。
|
|||
|
|
|
|||
|
|
形如 ``sense(coef1*var1 + coef2*var2 + ...)``,目标质量 ``target`` 为达标量
|
|||
|
|
(如 ``Ti_purity ≥ 99.5%`` 中的 99.5),仅记录、不参与求解,供 #81 可解释。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
sense: Sense = Sense.MAXIMIZE
|
|||
|
|
terms: List[ObjectiveTerm] = field(default_factory=list)
|
|||
|
|
target: Optional[str] = None # PRD 超参包 ``target``:如 "Ti_purity"
|
|||
|
|
target_value: Optional[float] = None # 达标量(可选)
|
|||
|
|
description: str = ""
|
|||
|
|
|
|||
|
|
def evaluate(self, assignment: Dict[str, float]) -> float:
|
|||
|
|
"""给定一组变量取值,计算目标函数值(未知变量按 0 计)。"""
|
|||
|
|
total = 0.0
|
|||
|
|
for t in self.terms:
|
|||
|
|
v = assignment.get(t.variable)
|
|||
|
|
if _is_num(v):
|
|||
|
|
total += t.coefficient * v
|
|||
|
|
return total
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
d: Dict[str, Any] = {
|
|||
|
|
"sense": self.sense.value,
|
|||
|
|
"terms": [t.to_dict() for t in self.terms],
|
|||
|
|
}
|
|||
|
|
if self.target is not None:
|
|||
|
|
d["target"] = self.target
|
|||
|
|
if self.target_value is not None:
|
|||
|
|
d["target_value"] = self.target_value
|
|||
|
|
if self.description:
|
|||
|
|
d["description"] = self.description
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveSpec":
|
|||
|
|
sense_raw = d.get("sense", "maximize")
|
|||
|
|
try:
|
|||
|
|
sense = Sense(str(sense_raw))
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise ProblemError(f"未知 sense={sense_raw!r}") from e
|
|||
|
|
terms = [ObjectiveTerm.from_dict(t) for t in d.get("terms", [])]
|
|||
|
|
tv = d.get("target_value")
|
|||
|
|
return cls(
|
|||
|
|
sense=sense,
|
|||
|
|
terms=terms,
|
|||
|
|
target=d.get("target"),
|
|||
|
|
target_value=float(tv) if _is_num(tv) else None,
|
|||
|
|
description=str(d.get("description", "")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 约束
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConstraintKind(str, Enum):
|
|||
|
|
"""约束类型(统一描述常见工艺约束)。"""
|
|||
|
|
|
|||
|
|
BOX = "box" # 变量上下界(冗余于 DecisionVariable.bounds,供"运行期收紧")
|
|||
|
|
LINEAR = "linear" # 线性不等式 Σ a_i*x_i (</<=/>/>=) b
|
|||
|
|
RATIO = "ratio" # 配比约束:x_a / x_b (op) value
|
|||
|
|
FORBIDDEN = "forbidden" # 禁止组合:若干变量取值组合不允许
|
|||
|
|
|
|||
|
|
|
|||
|
|
_LINEAR_OPS = {"<", "<=", ">", ">=", "==", "!="}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ConstraintSpec:
|
|||
|
|
"""约束声明式规格。
|
|||
|
|
|
|||
|
|
每条约束带 ``reason``(工艺依据,供 #81 可解释建议"可溯源、引用依据")。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
kind: ConstraintKind
|
|||
|
|
reason: str = ""
|
|||
|
|
# box
|
|||
|
|
variable: Optional[str] = None
|
|||
|
|
bounds: Optional[Tuple[float, float]] = None
|
|||
|
|
# linear
|
|||
|
|
coefficients: Optional[Dict[str, float]] = None
|
|||
|
|
op: str = "<="
|
|||
|
|
rhs: float = 0.0
|
|||
|
|
# ratio
|
|||
|
|
numerator: Optional[str] = None
|
|||
|
|
denominator: Optional[str] = None
|
|||
|
|
value: float = 0.0
|
|||
|
|
# forbidden
|
|||
|
|
combination: Optional[Dict[str, Any]] = None
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
if self.kind == ConstraintKind.LINEAR and self.op not in _LINEAR_OPS:
|
|||
|
|
raise ProblemError(f"线性约束非法 op={self.op!r}")
|
|||
|
|
if self.kind == ConstraintKind.LINEAR and not self.coefficients:
|
|||
|
|
raise ProblemError("线性约束 coefficients 不能为空")
|
|||
|
|
|
|||
|
|
# ---- 校验(返回错误信息列表,不抛异常,便于聚合) ------------------
|
|||
|
|
|
|||
|
|
def errors_against(self, variables: Dict[str, DecisionVariable]) -> List[str]:
|
|||
|
|
"""对该约束引用的变量是否存在做静态校验,返回错误信息列表。"""
|
|||
|
|
errs: List[str] = []
|
|||
|
|
if self.kind == ConstraintKind.BOX:
|
|||
|
|
if not self.variable:
|
|||
|
|
errs.append("box 约束缺少 variable")
|
|||
|
|
elif self.variable not in variables:
|
|||
|
|
errs.append(f"box 约束引用未知变量 {self.variable!r}")
|
|||
|
|
elif self.kind == ConstraintKind.LINEAR:
|
|||
|
|
for vname in (self.coefficients or {}):
|
|||
|
|
if vname not in variables:
|
|||
|
|
errs.append(f"线性约束引用未知变量 {vname!r}")
|
|||
|
|
elif self.kind == ConstraintKind.RATIO:
|
|||
|
|
for fld, vname in (("numerator", self.numerator),
|
|||
|
|
("denominator", self.denominator)):
|
|||
|
|
if not vname:
|
|||
|
|
errs.append(f"ratio 约束缺少 {fld}")
|
|||
|
|
elif vname not in variables:
|
|||
|
|
errs.append(f"ratio 约束引用未知变量 {vname!r}")
|
|||
|
|
elif self.kind == ConstraintKind.FORBIDDEN:
|
|||
|
|
for vname in (self.combination or {}):
|
|||
|
|
if vname not in variables:
|
|||
|
|
errs.append(f"forbidden 约束引用未知变量 {vname!r}")
|
|||
|
|
return errs
|
|||
|
|
|
|||
|
|
# ---- 可行性判定(给定取值,判断该约束是否满足) --------------------
|
|||
|
|
|
|||
|
|
def satisfied_by(self, assignment: Dict[str, Any]) -> bool:
|
|||
|
|
"""给定一组变量取值,判断该约束是否被满足(未知变量视为未约束)。"""
|
|||
|
|
if self.kind == ConstraintKind.BOX and self.bounds is not None and self.variable:
|
|||
|
|
v = assignment.get(self.variable)
|
|||
|
|
if not _is_num(v):
|
|||
|
|
return True # 未知取值不判
|
|||
|
|
low, high = self.bounds
|
|||
|
|
return low <= v <= high
|
|||
|
|
if self.kind == ConstraintKind.LINEAR and self.coefficients:
|
|||
|
|
total = 0.0
|
|||
|
|
unknown = False
|
|||
|
|
for vname, coef in self.coefficients.items():
|
|||
|
|
v = assignment.get(vname)
|
|||
|
|
if not _is_num(v):
|
|||
|
|
unknown = True
|
|||
|
|
break
|
|||
|
|
total += coef * v
|
|||
|
|
if unknown:
|
|||
|
|
return True
|
|||
|
|
return _apply_op(total, self.op, self.rhs)
|
|||
|
|
if self.kind == ConstraintKind.RATIO and self.numerator and self.denominator:
|
|||
|
|
a = assignment.get(self.numerator)
|
|||
|
|
b = assignment.get(self.denominator)
|
|||
|
|
if not _is_num(a) or not _is_num(b) or b == 0:
|
|||
|
|
return True
|
|||
|
|
return _apply_op(a / b, self.op, self.value)
|
|||
|
|
if self.kind == ConstraintKind.FORBIDDEN and self.combination:
|
|||
|
|
# 组合中每个键值都命中才算"禁止组合"被触发
|
|||
|
|
for vname, want in self.combination.items():
|
|||
|
|
if assignment.get(vname) != want:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
d: Dict[str, Any] = {"kind": self.kind.value}
|
|||
|
|
if self.reason:
|
|||
|
|
d["reason"] = self.reason
|
|||
|
|
if self.kind == ConstraintKind.BOX:
|
|||
|
|
d["variable"] = self.variable
|
|||
|
|
d["bounds"] = list(self.bounds) if self.bounds else None
|
|||
|
|
elif self.kind == ConstraintKind.LINEAR:
|
|||
|
|
d["coefficients"] = dict(self.coefficients or {})
|
|||
|
|
d["op"] = self.op
|
|||
|
|
d["rhs"] = self.rhs
|
|||
|
|
elif self.kind == ConstraintKind.RATIO:
|
|||
|
|
d["numerator"] = self.numerator
|
|||
|
|
d["denominator"] = self.denominator
|
|||
|
|
d["op"] = self.op
|
|||
|
|
d["value"] = self.value
|
|||
|
|
elif self.kind == ConstraintKind.FORBIDDEN:
|
|||
|
|
d["combination"] = dict(self.combination or {})
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, d: Dict[str, Any]) -> "ConstraintSpec":
|
|||
|
|
kind_raw = d.get("kind")
|
|||
|
|
try:
|
|||
|
|
kind = ConstraintKind(str(kind_raw))
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise ProblemError(f"未知约束 kind={kind_raw!r}") from e
|
|||
|
|
bounds = d.get("bounds")
|
|||
|
|
if kind == ConstraintKind.BOX and bounds is not None:
|
|||
|
|
bounds = (float(bounds[0]), float(bounds[1]))
|
|||
|
|
coefs = d.get("coefficients")
|
|||
|
|
if coefs is not None:
|
|||
|
|
coefs = {k: float(v) for k, v in coefs.items()}
|
|||
|
|
return cls(
|
|||
|
|
kind=kind,
|
|||
|
|
reason=str(d.get("reason", "")),
|
|||
|
|
variable=d.get("variable"),
|
|||
|
|
bounds=bounds,
|
|||
|
|
coefficients=coefs,
|
|||
|
|
op=str(d.get("op", "<=")),
|
|||
|
|
rhs=float(d.get("rhs", 0.0)),
|
|||
|
|
numerator=d.get("numerator"),
|
|||
|
|
denominator=d.get("denominator"),
|
|||
|
|
value=float(d.get("value", 0.0)),
|
|||
|
|
combination=d.get("combination"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 优化问题
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def objective_factory() -> ObjectiveSpec:
|
|||
|
|
"""dataclass 默认值工厂:空目标(最大化、无项)。"""
|
|||
|
|
return ObjectiveSpec(sense=Sense.MAXIMIZE)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class OptimizationProblem:
|
|||
|
|
"""配方优化问题模型(变量 + 目标 + 约束),求解器无关。
|
|||
|
|
|
|||
|
|
设计为「先建模、后求解」:``validate`` 做静态一致性校验(变量引用、域完整性),
|
|||
|
|
``is_feasible`` 做取值可行性判定(运行期收紧约束 / 禁止组合),``solve`` 留给
|
|||
|
|
#79 注入求解器,本模块不绑任何优化库。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
variables: List[DecisionVariable] = field(default_factory=list)
|
|||
|
|
objective: ObjectiveSpec = field(default_factory=objective_factory)
|
|||
|
|
constraints: List[ConstraintSpec] = field(default_factory=list)
|
|||
|
|
problem_id: str = ""
|
|||
|
|
template: str = "" # 如 "iAOP-Template-Ti"
|
|||
|
|
description: str = ""
|
|||
|
|
|
|||
|
|
# ---- 变量索引 ----------------------------------------------------
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def variable_map(self) -> Dict[str, DecisionVariable]:
|
|||
|
|
return {v.name: v for v in self.variables}
|
|||
|
|
|
|||
|
|
# ---- 校验 --------------------------------------------------------
|
|||
|
|
|
|||
|
|
def validate(self) -> List[str]:
|
|||
|
|
"""聚合所有静态错误,返回错误信息列表(空列表表示通过)。"""
|
|||
|
|
errs: List[str] = []
|
|||
|
|
seen: set = set()
|
|||
|
|
for v in self.variables:
|
|||
|
|
if v.name in seen:
|
|||
|
|
errs.append(f"重复定义变量 {v.name!r}")
|
|||
|
|
seen.add(v.name)
|
|||
|
|
vmap = self.variable_map
|
|||
|
|
for t in self.objective.terms:
|
|||
|
|
if t.variable not in vmap:
|
|||
|
|
errs.append(f"目标项引用未知变量 {t.variable!r}")
|
|||
|
|
for i, c in enumerate(self.constraints):
|
|||
|
|
for e in c.errors_against(vmap):
|
|||
|
|
errs.append(f"约束 #{i} ({c.kind.value}): {e}")
|
|||
|
|
if self.objective.terms and not any(
|
|||
|
|
t.variable in vmap for t in self.objective.terms
|
|||
|
|
):
|
|||
|
|
errs.append("目标函数所有项均引用未知变量")
|
|||
|
|
return errs
|
|||
|
|
|
|||
|
|
# ---- 可行性判定 --------------------------------------------------
|
|||
|
|
|
|||
|
|
def is_feasible(self, assignment: Dict[str, Any]) -> bool:
|
|||
|
|
"""给定一组变量取值,判断是否满足全部约束与变量域。"""
|
|||
|
|
vmap = self.variable_map
|
|||
|
|
for name, val in assignment.items():
|
|||
|
|
v = vmap.get(name)
|
|||
|
|
if v is not None and not v.contains(val):
|
|||
|
|
return False
|
|||
|
|
return all(c.satisfied_by(assignment) for c in self.constraints)
|
|||
|
|
|
|||
|
|
def violated_constraints(self, assignment: Dict[str, Any]) -> List[ConstraintSpec]:
|
|||
|
|
"""返回被该取值违反的约束列表(供 #81 可解释建议引用依据)。"""
|
|||
|
|
return [c for c in self.constraints if not c.satisfied_by(assignment)]
|
|||
|
|
|
|||
|
|
# ---- 序列化 ------------------------------------------------------
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
d: Dict[str, Any] = {
|
|||
|
|
"variables": [v.to_dict() for v in self.variables],
|
|||
|
|
"objective": self.objective.to_dict(),
|
|||
|
|
"constraints": [c.to_dict() for c in self.constraints],
|
|||
|
|
}
|
|||
|
|
if self.problem_id:
|
|||
|
|
d["problem_id"] = self.problem_id
|
|||
|
|
if self.template:
|
|||
|
|
d["template"] = self.template
|
|||
|
|
if self.description:
|
|||
|
|
d["description"] = self.description
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, d: Dict[str, Any]) -> "OptimizationProblem":
|
|||
|
|
return cls(
|
|||
|
|
variables=[DecisionVariable.from_dict(v) for v in d.get("variables", [])],
|
|||
|
|
objective=ObjectiveSpec.from_dict(d.get("objective", {})),
|
|||
|
|
constraints=[ConstraintSpec.from_dict(c) for c in d.get("constraints", [])],
|
|||
|
|
problem_id=str(d.get("problem_id", "")),
|
|||
|
|
template=str(d.get("template", "")),
|
|||
|
|
description=str(d.get("description", "")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 辅助函数
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_num(x: object) -> bool:
|
|||
|
|
return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _apply_op(left: float, op: str, right: float) -> bool:
|
|||
|
|
"""应用比较算子(线性/配比约束共用)。"""
|
|||
|
|
if op == "<":
|
|||
|
|
return left < right
|
|||
|
|
if op == "<=":
|
|||
|
|
return left <= right
|
|||
|
|
if op == ">":
|
|||
|
|
return left > right
|
|||
|
|
if op == ">=":
|
|||
|
|
return left >= right
|
|||
|
|
if op == "==":
|
|||
|
|
return abs(left - right) < 1e-12
|
|||
|
|
if op == "!=":
|
|||
|
|
return abs(left - right) >= 1e-12
|
|||
|
|
return False # pragma: no cover
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 零依赖 YAML 子集加载(与 data-bus / rag-kb / impurity-forecast 同款)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_problem(path: str) -> OptimizationProblem:
|
|||
|
|
"""从声明式模板资产(YAML 子集)加载优化问题。
|
|||
|
|
|
|||
|
|
解析支持:缩进块、``key: value``、``- item``、行内 ``# 注释``、字符串/数字/
|
|||
|
|
布尔、内联 ``[a, b]`` 列表与 ``{a: 1}`` 映射。足以覆盖本模板资产格式;
|
|||
|
|
不引入第三方依赖,与内核既有模块一致。
|
|||
|
|
"""
|
|||
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|||
|
|
text = fh.read()
|
|||
|
|
data = _parse_yaml_subset(text)
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
raise ProblemError(f"模板 {path} 顶层应为映射")
|
|||
|
|
return OptimizationProblem.from_dict(data)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_yaml_subset(text: str) -> Any:
|
|||
|
|
"""极简 YAML 子集解析器(仅供模板资产,非通用 YAML)。"""
|
|||
|
|
# 去注释 + 去尾部空白,保留缩进
|
|||
|
|
lines: List[str] = []
|
|||
|
|
for raw in text.splitlines():
|
|||
|
|
# 行内注释:仅在 "# " 前不是值的一部分时剥离;这里取保守策略——行首/值后
|
|||
|
|
# 的 " #" 视为注释。冒号/方括号内的 # 不处理。
|
|||
|
|
stripped = raw.rstrip()
|
|||
|
|
if not stripped.strip():
|
|||
|
|
continue
|
|||
|
|
# 简单注释行
|
|||
|
|
if stripped.lstrip().startswith("#"):
|
|||
|
|
continue
|
|||
|
|
# 去行尾注释(" #" 形式)
|
|||
|
|
hash_idx = _find_inline_comment(stripped)
|
|||
|
|
if hash_idx is not None:
|
|||
|
|
stripped = stripped[:hash_idx].rstrip()
|
|||
|
|
if stripped:
|
|||
|
|
lines.append(stripped)
|
|||
|
|
parser = _YamlParser(lines)
|
|||
|
|
return parser.parse_block(0)[0] if lines else {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_inline_comment(line: str) -> Optional[int]:
|
|||
|
|
"""返回行内注释 ``#`` 的索引(无则 None),跳过 ``[...]``/``{...}`` 内的 #。"""
|
|||
|
|
depth = 0
|
|||
|
|
in_str = False
|
|||
|
|
for i, ch in enumerate(line):
|
|||
|
|
if ch == '"':
|
|||
|
|
in_str = not in_str
|
|||
|
|
elif not in_str:
|
|||
|
|
if ch in "[{":
|
|||
|
|
depth += 1
|
|||
|
|
elif ch in "]}":
|
|||
|
|
depth = max(0, depth - 1)
|
|||
|
|
elif ch == "#" and depth == 0 and i > 0 and line[i - 1] in (" ", "\t"):
|
|||
|
|
return i
|
|||
|
|
elif ch == "#" and depth == 0 and i == 0:
|
|||
|
|
return i
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _YamlParser:
|
|||
|
|
"""递归下降的 YAML 子集解析器(按缩进分层)。"""
|
|||
|
|
|
|||
|
|
def __init__(self, lines: List[str]) -> None:
|
|||
|
|
self.lines = lines
|
|||
|
|
self.i = 0
|
|||
|
|
|
|||
|
|
def _indent(self, line: str) -> int:
|
|||
|
|
return len(line) - len(line.lstrip(" "))
|
|||
|
|
|
|||
|
|
def parse_block(self, indent: int) -> Tuple[Any, bool]:
|
|||
|
|
"""解析当前缩进层级的一个块,返回 (value, is_list_marker)。"""
|
|||
|
|
if self.i >= len(self.lines):
|
|||
|
|
return {}, False
|
|||
|
|
line = self.lines[self.i]
|
|||
|
|
cur_indent = self._indent(line)
|
|||
|
|
if cur_indent < indent:
|
|||
|
|
return {}, False
|
|||
|
|
stripped = line.strip()
|
|||
|
|
if stripped.startswith("- ") or stripped == "-":
|
|||
|
|
return self._parse_list(cur_indent), True
|
|||
|
|
return self._parse_mapping(cur_indent), False
|
|||
|
|
|
|||
|
|
def _parse_mapping(self, indent: int) -> Dict[str, Any]:
|
|||
|
|
result: Dict[str, Any] = {}
|
|||
|
|
# 实际子键缩进可能 > indent(如 "- key: v" 后 4 空格键、项缩进 2)。
|
|||
|
|
# 用首行真实缩进对齐,避免误把合法子键当"孤立缩进"跳过。
|
|||
|
|
effective = indent
|
|||
|
|
if self.i < len(self.lines):
|
|||
|
|
first = self._indent(self.lines[self.i])
|
|||
|
|
if first > indent:
|
|||
|
|
effective = first
|
|||
|
|
while self.i < len(self.lines):
|
|||
|
|
line = self.lines[self.i]
|
|||
|
|
cur = self._indent(line)
|
|||
|
|
if cur < effective:
|
|||
|
|
break
|
|||
|
|
if cur > effective:
|
|||
|
|
# 跳过孤立缩进(不应出现,保守跳过)
|
|||
|
|
self.i += 1
|
|||
|
|
continue
|
|||
|
|
stripped = line.strip()
|
|||
|
|
if stripped.startswith("- "):
|
|||
|
|
break
|
|||
|
|
key, sep, rest = stripped.partition(":")
|
|||
|
|
if not sep:
|
|||
|
|
self.i += 1
|
|||
|
|
continue
|
|||
|
|
key = key.strip()
|
|||
|
|
rest = rest.strip()
|
|||
|
|
self.i += 1
|
|||
|
|
if rest:
|
|||
|
|
result[key] = _parse_scalar(rest)
|
|||
|
|
else:
|
|||
|
|
# 子块:用首行真实缩进解析(列表或映射),兼容 4 空格子键等
|
|||
|
|
if self.i < len(self.lines) and self._indent(self.lines[self.i]) > effective:
|
|||
|
|
child_indent = self._indent(self.lines[self.i])
|
|||
|
|
val, _ = self.parse_block(child_indent)
|
|||
|
|
result[key] = val
|
|||
|
|
else:
|
|||
|
|
result[key] = None
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def _parse_list(self, indent: int) -> List[Any]:
|
|||
|
|
result: List[Any] = []
|
|||
|
|
while self.i < len(self.lines):
|
|||
|
|
line = self.lines[self.i]
|
|||
|
|
cur = self._indent(line)
|
|||
|
|
if cur < indent:
|
|||
|
|
break
|
|||
|
|
if cur > indent:
|
|||
|
|
self.i += 1
|
|||
|
|
continue
|
|||
|
|
stripped = line.strip()
|
|||
|
|
if not stripped.startswith("-"):
|
|||
|
|
break
|
|||
|
|
item_text = stripped[1:].strip()
|
|||
|
|
self.i += 1
|
|||
|
|
# 后续更深缩进的行是否归属本项
|
|||
|
|
if self.i < len(self.lines):
|
|||
|
|
child_indent = self._indent(self.lines[self.i])
|
|||
|
|
else:
|
|||
|
|
child_indent = cur
|
|||
|
|
has_deeper = child_indent > cur
|
|||
|
|
if item_text:
|
|||
|
|
# 可能是 "- key: value"(映射项)或 "- 标量"
|
|||
|
|
if ":" in item_text and not item_text.startswith("["):
|
|||
|
|
# 单行映射项的首键
|
|||
|
|
key, sep, rest = item_text.partition(":")
|
|||
|
|
kval = _parse_scalar(rest.strip()) if rest.strip() else None
|
|||
|
|
if has_deeper:
|
|||
|
|
# 把首键与后续子块合并:先解析子块,再把首键塞入
|
|||
|
|
sub, _ = self.parse_block(child_indent)
|
|||
|
|
item: Dict[str, Any] = sub if isinstance(sub, dict) else {}
|
|||
|
|
item[key.strip()] = kval
|
|||
|
|
else:
|
|||
|
|
item = {key.strip(): kval}
|
|||
|
|
result.append(item)
|
|||
|
|
else:
|
|||
|
|
if has_deeper:
|
|||
|
|
# 标量头 + 子块(本模板未使用,保守取子块)
|
|||
|
|
sub, _ = self.parse_block(child_indent)
|
|||
|
|
result.append(sub)
|
|||
|
|
else:
|
|||
|
|
result.append(_parse_scalar(item_text))
|
|||
|
|
else:
|
|||
|
|
# "- " 后跟子块
|
|||
|
|
if has_deeper:
|
|||
|
|
sub, _ = self.parse_block(child_indent)
|
|||
|
|
result.append(sub)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_scalar(text: str) -> Any:
|
|||
|
|
"""解析标量:数字/布尔/字符串/内联列表/内联映射。"""
|
|||
|
|
text = text.strip()
|
|||
|
|
if not text:
|
|||
|
|
return ""
|
|||
|
|
# 内联列表
|
|||
|
|
if text.startswith("[") and text.endswith("]"):
|
|||
|
|
inner = text[1:-1].strip()
|
|||
|
|
if not inner:
|
|||
|
|
return []
|
|||
|
|
return [_parse_scalar(part.strip()) for part in _split_top(inner, ",")]
|
|||
|
|
# 内联映射
|
|||
|
|
if text.startswith("{") and text.endswith("}"):
|
|||
|
|
inner = text[1:-1].strip()
|
|||
|
|
if not inner:
|
|||
|
|
return {}
|
|||
|
|
out: Dict[str, Any] = {}
|
|||
|
|
for part in _split_top(inner, ","):
|
|||
|
|
k, sep, v = part.partition(":")
|
|||
|
|
if sep:
|
|||
|
|
out[k.strip()] = _parse_scalar(v.strip())
|
|||
|
|
return out
|
|||
|
|
low = text.lower()
|
|||
|
|
if low == "true":
|
|||
|
|
return True
|
|||
|
|
if low == "false":
|
|||
|
|
return False
|
|||
|
|
if low in ("null", "none", "~"):
|
|||
|
|
return None
|
|||
|
|
# 数字
|
|||
|
|
try:
|
|||
|
|
if "." in text or "e" in low:
|
|||
|
|
return float(text)
|
|||
|
|
return int(text)
|
|||
|
|
except ValueError:
|
|||
|
|
# 去引号
|
|||
|
|
if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
|
|||
|
|
return text[1:-1]
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _split_top(text: str, sep: str) -> List[str]:
|
|||
|
|
"""按分隔符切分顶层(跳过 []/{} 内的)。"""
|
|||
|
|
parts: List[str] = []
|
|||
|
|
depth = 0
|
|||
|
|
cur: List[str] = []
|
|||
|
|
in_str = False
|
|||
|
|
for ch in text:
|
|||
|
|
if ch == '"':
|
|||
|
|
in_str = not in_str
|
|||
|
|
cur.append(ch)
|
|||
|
|
elif not in_str and ch in "[{":
|
|||
|
|
depth += 1
|
|||
|
|
cur.append(ch)
|
|||
|
|
elif not in_str and ch in "]}":
|
|||
|
|
depth = max(0, depth - 1)
|
|||
|
|
cur.append(ch)
|
|||
|
|
elif ch == sep and depth == 0:
|
|||
|
|
parts.append("".join(cur))
|
|||
|
|
cur = []
|
|||
|
|
else:
|
|||
|
|
cur.append(ch)
|
|||
|
|
if cur:
|
|||
|
|
parts.append("".join(cur))
|
|||
|
|
return parts
|