feat(prediction): 三层校准体系 + 按钢种分组 + 数据飞轮
1. 按钢种分组 K_cal:cal_coeffs.json 升级为嵌套结构,
{kcal: {model: {_default, Q235, ...}}, phys: {...}},
旧平铺格式首次加载时自动迁移。
2. 物理参数自适应:EA_R/K0/N_CONC 按钢种网格拟合
(7×5×3=105 组合),每次校准追加样本到
production_samples.jsonl,≥10 条后自动触发拟合。
3. 数据飞轮:新增 POST /retrain 端点,后台子进程跑
train_models.py --use-real-data 混入实绩重训
(10× 权重),完成后 ONNX 热重载,无需重启服务。
新增端点:
GET /calibration/samples 样本数统计
GET /calibration/phys-params 物理参数查询
POST /calibration/fit-phys/{key} 手动触发物理参数拟合
POST /retrain 启动重训
GET /retrain/status 重训进度
模型类签名变更:
TensionModel / QualityPredictionModel 新增 steel_grade 参数
AcidConsumptionModel 新增 fe_conc_avg 参数
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,260 @@
|
||||
"""
|
||||
工艺预测模型 — 灰箱物理模型 + ONNX 神经网络双栈
|
||||
|
||||
推理优先级:
|
||||
1. ONNX 模型(onnxruntime,若 pt_models/ 目录存在则加载)
|
||||
2. 物理灰箱模型(Arrhenius 解析解,始终可用)
|
||||
三层校准体系:
|
||||
1. K_cal — 按钢种乘法偏置(立即生效)
|
||||
2. PhysParams — EA_R / K0 / N_CONC 按钢种网格拟合(≥10 样本后自动触发)
|
||||
3. 数据飞轮 — 积累实绩后触发 ONNX 重训(POST /retrain 离线触发)
|
||||
|
||||
训练:运行 backend/train_models.py 重新生成 pt_models/*.onnx
|
||||
校准:K_cal 系数持久化在 cal_coeffs.json,两个栈都使用同一套 K_cal
|
||||
cal_coeffs.json 新结构:
|
||||
{
|
||||
"kcal": { "acid_speed": {"_default": 1.0, "Q235": 1.02}, ... },
|
||||
"phys": { "acid_speed": {"_default": {EA_R, K0, N_CONC}, "Q235": {...}}, ... },
|
||||
"history": [...]
|
||||
}
|
||||
production_samples.jsonl:每条一个 JSON,按 model + grade 索引。
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from typing import List, Dict, Any, Optional
|
||||
from loguru import logger
|
||||
|
||||
# ── 校准系数持久化 ────────────────────────────────────────────────────────────
|
||||
_CAL_FILE = Path(__file__).parent / "cal_coeffs.json"
|
||||
# ── 路径常量 ──────────────────────────────────────────────────────────────────
|
||||
_SVC_DIR = Path(__file__).parent
|
||||
_CAL_FILE = _SVC_DIR / "cal_coeffs.json"
|
||||
_SAMPLE_FILE = _SVC_DIR / "production_samples.jsonl"
|
||||
_PT_DIR = _SVC_DIR / "pt_models"
|
||||
|
||||
def _load_cal() -> Dict[str, float]:
|
||||
_DEFAULT_PHYS: Dict[str, float] = {"EA_R": 5413.0, "K0": 0.075, "N_CONC": 1.2}
|
||||
_K0_REF = 0.075 # quality 模型 K0 归一化基准
|
||||
_FIT_MIN_SAMPLES = 10 # 触发物理参数拟合的最少样本数
|
||||
|
||||
# ── Cal I/O ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_cal() -> Dict:
|
||||
try:
|
||||
with open(_CAL_FILE) as f:
|
||||
return json.load(f)
|
||||
d = json.load(f)
|
||||
if "kcal" not in d:
|
||||
_migrate_cal(d)
|
||||
with open(_CAL_FILE) as f:
|
||||
d = json.load(f)
|
||||
return d
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _save_cal(d: Dict[str, float]):
|
||||
def _migrate_cal(old: Dict):
|
||||
"""旧平铺格式 → 新嵌套格式(一次性迁移)"""
|
||||
_ZONES = ["inlet","s1_roller","acid_entry","acid1","acid2","acid3",
|
||||
"rinse","leveler","s2_roller","outlet"]
|
||||
new: Dict = {"kcal": {}, "phys": {}, "history": old.get("history", [])}
|
||||
for m in ("acid_speed", "quality"):
|
||||
new["kcal"][m] = {"_default": old.get(f"{m}_kcal", 1.0)}
|
||||
new["phys"][m] = {"_default": _DEFAULT_PHYS.copy()}
|
||||
for z in _ZONES:
|
||||
new["kcal"][f"tension_{z}"] = {"_default": old.get(f"tension_zone_{z}", 1.0)}
|
||||
with open(_CAL_FILE, "w") as f:
|
||||
json.dump(d, f, indent=2)
|
||||
json.dump(new, f, indent=2, ensure_ascii=False)
|
||||
logger.info("cal_coeffs.json: 已从旧格式迁移到新嵌套格式")
|
||||
|
||||
def _save_cal(d: Dict):
|
||||
with open(_CAL_FILE, "w") as f:
|
||||
json.dump(d, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _get_kcal(model_key: str, grade: str = "_default") -> float:
|
||||
d = _load_cal().get("kcal", {}).get(model_key, {})
|
||||
return d.get(grade, d.get("_default", 1.0))
|
||||
|
||||
def _set_kcal(model_key: str, grade: str, value: float):
|
||||
cal = _load_cal()
|
||||
cal.setdefault("kcal", {}).setdefault(model_key, {"_default": 1.0})
|
||||
cal["kcal"][model_key][grade] = round(value, 4)
|
||||
_save_cal(cal)
|
||||
|
||||
def _get_phys(model_key: str, grade: str = "_default") -> Dict:
|
||||
d = _load_cal().get("phys", {}).get(model_key, {})
|
||||
return {**_DEFAULT_PHYS, **d.get(grade, d.get("_default", {}))}
|
||||
|
||||
def _set_phys(model_key: str, grade: str, params: Dict):
|
||||
cal = _load_cal()
|
||||
cal.setdefault("phys", {}).setdefault(model_key, {"_default": _DEFAULT_PHYS.copy()})
|
||||
cal["phys"][model_key][grade] = {k: round(v, 6) for k, v in params.items()}
|
||||
_save_cal(cal)
|
||||
|
||||
# ── 生产样本 I/O ──────────────────────────────────────────────────────────────
|
||||
|
||||
def append_sample(record: Dict):
|
||||
"""追加一条生产实绩样本(含时间戳)到 JSONL 文件。"""
|
||||
record = {"ts": datetime.now().isoformat(timespec="seconds"), **record}
|
||||
with open(_SAMPLE_FILE, "a") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def get_samples(model: str, grade: str) -> List[Dict]:
|
||||
"""读取指定模型 + 钢种的样本,最多返回 200 条。"""
|
||||
if not _SAMPLE_FILE.exists():
|
||||
return []
|
||||
out = []
|
||||
with open(_SAMPLE_FILE) as f:
|
||||
for line in f:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
if r.get("model") == model and r.get("grade") == grade:
|
||||
out.append(r)
|
||||
except Exception:
|
||||
pass
|
||||
return out[-200:]
|
||||
|
||||
def get_sample_stats() -> Dict:
|
||||
"""返回各模型 + 钢种的样本数量汇总。"""
|
||||
if not _SAMPLE_FILE.exists():
|
||||
return {}
|
||||
stats: Dict[str, Dict[str, int]] = {}
|
||||
with open(_SAMPLE_FILE) as f:
|
||||
for line in f:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
m = r.get("model", "?")
|
||||
g = r.get("grade", "_default")
|
||||
stats.setdefault(m, {}).setdefault(g, 0)
|
||||
stats[m][g] += 1
|
||||
except Exception:
|
||||
pass
|
||||
return stats
|
||||
|
||||
# ── 模块级物理计算(供网格搜索和模型类共享)──────────────────────────────────
|
||||
|
||||
_TANK_LENGTH = 18.0
|
||||
_NUM_TANKS = 5
|
||||
_T_REF = 348.15
|
||||
_C_REF = 180.0
|
||||
_SCALE_RATE_FACTOR = 0.70 * 1.0 + 0.20 * 0.25 + 0.10 * 0.15
|
||||
|
||||
|
||||
def _acid_k_i(conc: float, temp_c: float, scale_weight: float,
|
||||
K0: float, EA_R: float, N_CONC: float, K_cal: float = 1.0) -> float:
|
||||
T_k = temp_c + 273.15
|
||||
arr = math.exp(-EA_R * (1.0/T_k - 1.0/_T_REF))
|
||||
c_f = max(conc / _C_REF, 0.01) ** N_CONC
|
||||
sc = (8.5 / max(scale_weight, 1.0)) ** 0.3
|
||||
return K0 * arr * c_f * _SCALE_RATE_FACTOR * sc * K_cal
|
||||
|
||||
|
||||
def _acid_compute_pi(v_mpm: float, conc_list, temp_list, scale_weight,
|
||||
K0, EA_R, N_CONC, K_cal=1.0):
|
||||
v_mps = v_mpm / 60.0
|
||||
pi, pp, rt = 0.0, [], []
|
||||
for i in range(_NUM_TANKS):
|
||||
t_i = _TANK_LENGTH / v_mps
|
||||
k_i = _acid_k_i(conc_list[i], temp_list[i], scale_weight, K0, EA_R, N_CONC, K_cal)
|
||||
pi = 100.0 - (100.0 - pi) * math.exp(-k_i * t_i)
|
||||
pp.append(round(pi, 2))
|
||||
rt.append(round(t_i, 1))
|
||||
return pi, pp, rt
|
||||
|
||||
|
||||
def _acid_max_speed(conc_list, temp_list, scale_weight, target_pi,
|
||||
K0, EA_R, N_CONC, K_cal=1.0) -> float:
|
||||
V_MIN, V_MAX = 20.0, 180.0
|
||||
if _acid_compute_pi(V_MIN, conc_list, temp_list, scale_weight, K0, EA_R, N_CONC, K_cal)[0] < target_pi:
|
||||
return V_MIN
|
||||
lo, hi, best = V_MIN, V_MAX, V_MIN
|
||||
while hi - lo >= 0.5:
|
||||
mid = (lo + hi) / 2.0
|
||||
if _acid_compute_pi(mid, conc_list, temp_list, scale_weight, K0, EA_R, N_CONC, K_cal)[0] >= target_pi:
|
||||
best = mid; lo = mid + 0.5
|
||||
else:
|
||||
hi = mid - 0.5
|
||||
return math.floor(best)
|
||||
|
||||
|
||||
def _quality_pi_raw(avg_speed: float, acid_conc_avg: float, acid_temp_avg: float,
|
||||
scale_weight: float, fe_conc_avg: float,
|
||||
K0: float, EA_R: float, N_CONC: float, K_cal: float = 1.0) -> float:
|
||||
T_k = acid_temp_avg + 273.15
|
||||
arr = math.exp(-EA_R * (1.0/T_k - 1.0/_T_REF))
|
||||
c_f = max(acid_conc_avg / _C_REF, 0.01) ** N_CONC
|
||||
fe_ih = 1.0 - max(0.0, (fe_conc_avg - 80.0) / 200.0) * 0.35
|
||||
sc = (8.5 / max(scale_weight, 1.0)) ** 0.3
|
||||
k0_r = K0 / _K0_REF
|
||||
exp_ = k0_r * 1.2 * arr * c_f * fe_ih * sc * _TANK_LENGTH * _NUM_TANKS / (avg_speed / 60.0)
|
||||
return min(max(100.0 * (1.0 - math.exp(-exp_ / 10.0)) * K_cal, 0.0), 100.0)
|
||||
|
||||
|
||||
# ── 物理参数网格拟合 ──────────────────────────────────────────────────────────
|
||||
|
||||
def fit_acid_phys_params(grade: str) -> Optional[Dict]:
|
||||
"""
|
||||
从 production_samples.jsonl 中读取指定钢种的酸洗速度样本,
|
||||
网格搜索最优 (K0, EA_R, N_CONC),≥10 条样本才触发。
|
||||
成功则写入 cal_coeffs.json 并返回新参数,否则返回 None。
|
||||
"""
|
||||
samples = get_samples("acid_speed", grade)
|
||||
if len(samples) < _FIT_MIN_SAMPLES:
|
||||
return None
|
||||
|
||||
cur = _get_phys("acid_speed", grade)
|
||||
K0_g = [cur["K0"] * f for f in (0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15)]
|
||||
EA_R_g = [cur["EA_R"] * f for f in (0.94, 0.97, 1.00, 1.03, 1.06)]
|
||||
NC_g = [cur["N_CONC"]* f for f in (0.90, 1.00, 1.10)]
|
||||
|
||||
best_mse, best = float("inf"), cur.copy()
|
||||
for K0 in K0_g:
|
||||
for EA_R in EA_R_g:
|
||||
for N_CONC in NC_g:
|
||||
mse = 0.0
|
||||
for s in samples:
|
||||
inp = s["inputs"] # [t, sw, c0..c5, t0..t5]
|
||||
pred = _acid_max_speed(
|
||||
inp[2:8], inp[8:14], inp[1],
|
||||
s.get("target_pi", 95.0), K0, EA_R, N_CONC
|
||||
)
|
||||
mse += (pred - s["actual_speed"]) ** 2
|
||||
mse /= len(samples)
|
||||
if mse < best_mse:
|
||||
best_mse = mse
|
||||
best = {"K0": K0, "EA_R": EA_R, "N_CONC": N_CONC}
|
||||
|
||||
_set_phys("acid_speed", grade, best)
|
||||
logger.info(f"acid_speed phys fit [{grade}]: RMSE={best_mse**0.5:.2f} m/min {best}")
|
||||
return best
|
||||
|
||||
|
||||
def fit_quality_phys_params(grade: str) -> Optional[Dict]:
|
||||
"""同上,针对质量预测模型。"""
|
||||
samples = get_samples("quality", grade)
|
||||
if len(samples) < _FIT_MIN_SAMPLES:
|
||||
return None
|
||||
|
||||
cur = _get_phys("quality", grade)
|
||||
grade_target = {"A1": 95.0, "A2": 85.0, "B1": 75.0, "B2": 65.0, "C": 50.0}
|
||||
K0_g = [cur["K0"] * f for f in (0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15)]
|
||||
EA_R_g = [cur["EA_R"] * f for f in (0.94, 0.97, 1.00, 1.03, 1.06)]
|
||||
NC_g = [cur["N_CONC"]* f for f in (0.90, 1.00, 1.10)]
|
||||
|
||||
best_mse, best = float("inf"), cur.copy()
|
||||
for K0 in K0_g:
|
||||
for EA_R in EA_R_g:
|
||||
for N_CONC in NC_g:
|
||||
mse = 0.0
|
||||
for s in samples:
|
||||
inp = s["inputs"] # [t, spd, conc, temp, sw, fe]
|
||||
t_pi = grade_target.get(s.get("actual_grade", "B1"), 75.0)
|
||||
pi = _quality_pi_raw(inp[1], inp[2], inp[3], inp[4], inp[5],
|
||||
K0, EA_R, N_CONC)
|
||||
mse += (pi - t_pi) ** 2
|
||||
mse /= len(samples)
|
||||
if mse < best_mse:
|
||||
best_mse = mse
|
||||
best = {"K0": K0, "EA_R": EA_R, "N_CONC": N_CONC}
|
||||
|
||||
_set_phys("quality", grade, best)
|
||||
logger.info(f"quality phys fit [{grade}]: RMSE={best_mse**0.5:.2f} {best}")
|
||||
return best
|
||||
|
||||
|
||||
# ── ONNX 推理层 ───────────────────────────────────────────────────────────────
|
||||
_PT_DIR = Path(__file__).parent / "pt_models"
|
||||
_scalers: Optional[Dict] = None
|
||||
_sess: Dict[str, Any] = {}
|
||||
|
||||
@@ -47,9 +270,7 @@ try:
|
||||
for _name in ("acid_speed", "tension", "quality"):
|
||||
_p = _PT_DIR / f"{_name}.onnx"
|
||||
if _p.exists():
|
||||
_sess[_name] = ort.InferenceSession(
|
||||
str(_p), providers=["CPUExecutionProvider"]
|
||||
)
|
||||
_sess[_name] = ort.InferenceSession(str(_p), providers=["CPUExecutionProvider"])
|
||||
if _sess:
|
||||
logger.info(f"PT models loaded: {list(_sess.keys())}")
|
||||
except ImportError:
|
||||
@@ -57,23 +278,40 @@ except ImportError:
|
||||
|
||||
|
||||
def _pt_infer(name: str, x_raw: List[float]) -> Optional[List[float]]:
|
||||
"""标准化 → ONNX 推理 → 反标准化,返回输出向量;失败返回 None。"""
|
||||
"""标准化 → ONNX 推理 → 反标准化,失败返回 None。"""
|
||||
if name not in _sess or _scalers is None:
|
||||
return None
|
||||
try:
|
||||
sc = _scalers[name]
|
||||
xm = _np.array(sc["X_mean"], dtype=_np.float32)
|
||||
xs = _np.array(sc["X_std"], dtype=_np.float32)
|
||||
ym = _np.array(sc["y_mean"], dtype=_np.float32)
|
||||
ys = _np.array(sc["y_std"], dtype=_np.float32)
|
||||
x = (_np.array(x_raw, dtype=_np.float32) - xm) / xs
|
||||
raw = _sess[name].run(None, {"input": x.reshape(1, -1)})[0][0]
|
||||
sc = _scalers[name]
|
||||
xm = _np.array(sc["X_mean"], dtype=_np.float32)
|
||||
xs = _np.array(sc["X_std"], dtype=_np.float32)
|
||||
ym = _np.array(sc["y_mean"], dtype=_np.float32)
|
||||
ys = _np.array(sc["y_std"], dtype=_np.float32)
|
||||
x = (_np.array(x_raw, dtype=_np.float32) - xm) / xs
|
||||
raw = _sess[name].run(None, {"input": x.reshape(1, -1)})[0][0]
|
||||
return (raw * ys + ym).tolist()
|
||||
except Exception as e:
|
||||
logger.warning(f"PT infer {name} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def reload_onnx():
|
||||
"""重训后调用,热重载 ONNX 模型文件。"""
|
||||
global _scalers, _sess
|
||||
try:
|
||||
sp = _PT_DIR / "scalers.json"
|
||||
if sp.exists():
|
||||
with open(sp) as f:
|
||||
_scalers = json.load(f)
|
||||
for name in ("acid_speed", "tension", "quality"):
|
||||
p = _PT_DIR / f"{name}.onnx"
|
||||
if p.exists():
|
||||
_sess[name] = ort.InferenceSession(str(p), providers=["CPUExecutionProvider"])
|
||||
logger.info(f"ONNX 热重载完成: {list(_sess.keys())}")
|
||||
except Exception as e:
|
||||
logger.error(f"ONNX 热重载失败: {e}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. 酸洗速度模型
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -81,27 +319,20 @@ class AcidSpeedModel:
|
||||
"""
|
||||
灰箱: Arrhenius 动力学 + 二分搜索
|
||||
PT栈: 14 维输入 → 最大速度 (m/min)
|
||||
校准: K_cal 按钢种 + 物理参数 (EA_R/K0/N_CONC) 按钢种网格拟合
|
||||
输入: [thickness, scale_weight, conc×6, temp×6]
|
||||
"""
|
||||
TANK_LENGTH = 18.0
|
||||
NUM_TANKS = 6
|
||||
K0 = 0.075
|
||||
EA_R = 5413.0
|
||||
T_REF = 348.15
|
||||
C_REF = 180.0
|
||||
N_CONC = 1.2
|
||||
V_MIN = 20.0
|
||||
V_MAX = 180.0
|
||||
SCALE_RATE_FACTOR = 0.70 * 1.0 + 0.20 * 0.25 + 0.10 * 0.15
|
||||
CAL_KEY = "acid_speed_kcal"
|
||||
CAL_KEY = "acid_speed"
|
||||
V_MIN = 20.0
|
||||
V_MAX = 180.0
|
||||
|
||||
def __init__(self, thickness, width, steel_grade,
|
||||
acid_conc_list, acid_temp_list,
|
||||
scale_weight=8.5, target_pi=95.0):
|
||||
if len(acid_conc_list) != self.NUM_TANKS:
|
||||
raise ValueError(f"acid_conc_list 需要 {self.NUM_TANKS} 个元素")
|
||||
if len(acid_temp_list) != self.NUM_TANKS:
|
||||
raise ValueError(f"acid_temp_list 需要 {self.NUM_TANKS} 个元素")
|
||||
if len(acid_conc_list) != _NUM_TANKS:
|
||||
raise ValueError(f"acid_conc_list 需要 {_NUM_TANKS} 个元素")
|
||||
if len(acid_temp_list) != _NUM_TANKS:
|
||||
raise ValueError(f"acid_temp_list 需要 {_NUM_TANKS} 个元素")
|
||||
self.thickness = thickness
|
||||
self.width = width
|
||||
self.steel_grade = steel_grade
|
||||
@@ -109,29 +340,19 @@ class AcidSpeedModel:
|
||||
self.acid_temp_list = acid_temp_list
|
||||
self.scale_weight = scale_weight
|
||||
self.target_pi = target_pi
|
||||
self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
|
||||
|
||||
def _k_i(self, conc, temp_c):
|
||||
T_k = temp_c + 273.15
|
||||
arrhenius = math.exp(-self.EA_R * (1.0/T_k - 1.0/self.T_REF))
|
||||
c_factor = max(conc/self.C_REF, 0.01) ** self.N_CONC
|
||||
scale_corr = (8.5 / max(self.scale_weight, 1.0)) ** 0.3
|
||||
return self.K0 * arrhenius * c_factor * self.SCALE_RATE_FACTOR * scale_corr * self.K_cal
|
||||
self.K_cal = _get_kcal(self.CAL_KEY, steel_grade)
|
||||
phys = _get_phys(self.CAL_KEY, steel_grade)
|
||||
self.K0 = phys["K0"]
|
||||
self.EA_R = phys["EA_R"]
|
||||
self.N_CONC= phys["N_CONC"]
|
||||
|
||||
def _compute_pi(self, v_mpm):
|
||||
v_mps = v_mpm / 60.0
|
||||
pi = 0.0
|
||||
pp, rt = [], []
|
||||
for i in range(self.NUM_TANKS):
|
||||
t_i = self.TANK_LENGTH / v_mps
|
||||
k_i = self._k_i(self.acid_conc_list[i], self.acid_temp_list[i])
|
||||
pi = 100.0 - (100.0 - pi) * math.exp(-k_i * t_i)
|
||||
pp.append(round(pi, 2)); rt.append(round(t_i, 1))
|
||||
return pi, pp, rt
|
||||
return _acid_compute_pi(v_mpm, self.acid_conc_list, self.acid_temp_list,
|
||||
self.scale_weight, self.K0, self.EA_R, self.N_CONC, self.K_cal)
|
||||
|
||||
def _risk_level(self, speed, pi):
|
||||
avg_conc = sum(self.acid_conc_list)/len(self.acid_conc_list)
|
||||
avg_temp = sum(self.acid_temp_list)/len(self.acid_temp_list)
|
||||
avg_conc = sum(self.acid_conc_list) / len(self.acid_conc_list)
|
||||
avg_temp = sum(self.acid_temp_list) / len(self.acid_temp_list)
|
||||
s = 0
|
||||
if pi < 85: s += 3
|
||||
elif pi < 92: s += 1
|
||||
@@ -150,48 +371,66 @@ class AcidSpeedModel:
|
||||
"residence_time_per_tank": rt, "total_pi": round(pi, 2),
|
||||
"under_pickling_risk": self._risk_level(self.V_MIN, pi),
|
||||
"warning": "酸液条件不足,建议检查酸浓度和温度",
|
||||
"K_cal": self.K_cal, "source": "physics",
|
||||
"K_cal": self.K_cal, "phys_params": self._phys_dict(), "source": "physics",
|
||||
}
|
||||
lo, hi, best = self.V_MIN, self.V_MAX, self.V_MIN
|
||||
while hi - lo >= 0.5:
|
||||
mid = (lo + hi) / 2.0
|
||||
if self._compute_pi(mid)[0] >= self.target_pi:
|
||||
best = mid; lo = mid + 0.5
|
||||
else:
|
||||
hi = mid - 0.5
|
||||
best = math.floor(best)
|
||||
best = _acid_max_speed(self.acid_conc_list, self.acid_temp_list, self.scale_weight,
|
||||
self.target_pi, self.K0, self.EA_R, self.N_CONC, self.K_cal)
|
||||
pi, pp, rt = self._compute_pi(best)
|
||||
return {
|
||||
"max_speed": best, "pi_per_tank": pp,
|
||||
"residence_time_per_tank": rt, "total_pi": round(pi, 2),
|
||||
"under_pickling_risk": self._risk_level(best, pi),
|
||||
"warning": None, "K_cal": self.K_cal, "source": "physics",
|
||||
"warning": None, "K_cal": self.K_cal, "phys_params": self._phys_dict(), "source": "physics",
|
||||
}
|
||||
|
||||
def _phys_dict(self):
|
||||
return {"K0": self.K0, "EA_R": self.EA_R, "N_CONC": self.N_CONC}
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
x = [self.thickness, self.scale_weight] + self.acid_conc_list + self.acid_temp_list
|
||||
x = [self.thickness, self.scale_weight] + self.acid_conc_list + self.acid_temp_list
|
||||
pt = _pt_infer("acid_speed", x)
|
||||
if pt is not None:
|
||||
raw_speed = pt[0] * self.K_cal
|
||||
best = int(max(self.V_MIN, min(self.V_MAX, round(raw_speed))))
|
||||
raw = pt[0] * self.K_cal
|
||||
best = int(max(self.V_MIN, min(self.V_MAX, round(raw))))
|
||||
pi, pp, rt = self._compute_pi(best)
|
||||
return {
|
||||
"max_speed": best, "pi_per_tank": pp,
|
||||
"residence_time_per_tank": rt, "total_pi": round(pi, 2),
|
||||
"under_pickling_risk": self._risk_level(best, pi),
|
||||
"warning": None, "K_cal": self.K_cal, "source": "pt",
|
||||
"warning": None, "K_cal": self.K_cal, "phys_params": self._phys_dict(), "source": "pt",
|
||||
}
|
||||
return self._physics_result()
|
||||
|
||||
def calibrate(self, actual_max_speed, actual_quality_ok):
|
||||
def calibrate(self, actual_max_speed: float, actual_quality_ok: bool) -> float:
|
||||
"""
|
||||
更新当前钢种的 K_cal,保存样本,样本 ≥10 时自动触发物理参数拟合。
|
||||
返回新 K_cal。
|
||||
"""
|
||||
predicted = self.calculate()["max_speed"]
|
||||
if not actual_quality_ok:
|
||||
adj = 0.95
|
||||
else:
|
||||
ratio = actual_max_speed / max(predicted, 1.0)
|
||||
adj = max(0.7, min(1.3, 1.0 + 0.3*(ratio - 1.0)))
|
||||
adj = max(0.7, min(1.3, 1.0 + 0.3 * (ratio - 1.0)))
|
||||
self.K_cal = round(self.K_cal * adj, 4)
|
||||
cal = _load_cal(); cal[self.CAL_KEY] = self.K_cal; _save_cal(cal)
|
||||
_set_kcal(self.CAL_KEY, self.steel_grade, self.K_cal)
|
||||
|
||||
# 保存样本
|
||||
append_sample({
|
||||
"model": "acid_speed",
|
||||
"grade": self.steel_grade,
|
||||
"inputs": [self.thickness, self.scale_weight] + self.acid_conc_list + self.acid_temp_list,
|
||||
"target_pi": self.target_pi,
|
||||
"predicted_speed": predicted,
|
||||
"actual_speed": actual_max_speed,
|
||||
"quality_ok": actual_quality_ok,
|
||||
})
|
||||
|
||||
# 样本够了就触发物理参数拟合
|
||||
n = len(get_samples("acid_speed", self.steel_grade))
|
||||
if n >= _FIT_MIN_SAMPLES and n % 5 == 0:
|
||||
fit_acid_phys_params(self.steel_grade)
|
||||
|
||||
return self.K_cal
|
||||
|
||||
|
||||
@@ -202,6 +441,7 @@ class TensionModel:
|
||||
"""
|
||||
灰箱: T_base = coef × σ_yield × A,各区段比例系数
|
||||
PT栈: 4 维输入 → 10 区段张力 kN
|
||||
校准: 每区段 K_cal 按钢种分组
|
||||
输入: [thickness, width, yield_strength, tension_coef]
|
||||
"""
|
||||
ZONE_RATIOS = {
|
||||
@@ -218,15 +458,17 @@ class TensionModel:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _zone_cal_key(zone): return f"tension_zone_{zone}"
|
||||
def _zone_key(zone): return f"tension_{zone}"
|
||||
|
||||
def __init__(self, thickness, width, yield_strength, tension_coef=0.25):
|
||||
def __init__(self, thickness, width, yield_strength,
|
||||
tension_coef=0.25, steel_grade="_default"):
|
||||
self.thickness = thickness
|
||||
self.width = width
|
||||
self.yield_strength = yield_strength
|
||||
self.tension_coef = tension_coef
|
||||
cal = _load_cal()
|
||||
self.zone_kcal = {z: cal.get(self._zone_cal_key(z), 1.0) for z in self.ZONE_RATIOS}
|
||||
self.steel_grade = steel_grade
|
||||
self.zone_kcal = {z: _get_kcal(self._zone_key(z), steel_grade)
|
||||
for z in self.ZONE_RATIOS}
|
||||
|
||||
def _physics_zones(self, t_base_kn):
|
||||
zones = {}
|
||||
@@ -240,7 +482,7 @@ class TensionModel:
|
||||
return zones
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
cross = self.thickness * self.width
|
||||
cross = self.thickness * self.width
|
||||
t_base = self.tension_coef * self.yield_strength * cross / 1000.0
|
||||
|
||||
pt = _pt_infer("tension", [self.thickness, self.width, self.yield_strength, self.tension_coef])
|
||||
@@ -248,12 +490,12 @@ class TensionModel:
|
||||
zone_names = _scalers["tension"].get("zone_names", list(self.ZONE_RATIOS.keys()))
|
||||
zones = {}
|
||||
for i, zone in enumerate(zone_names):
|
||||
k = self.zone_kcal.get(zone, 1.0)
|
||||
k = self.zone_kcal.get(zone, 1.0)
|
||||
kn = round(max(0.1, pt[i]) * k, 2)
|
||||
zones[zone] = {
|
||||
"tension_kN": kn,
|
||||
"ratio": self.ZONE_RATIOS.get(zone, 1.0),
|
||||
"k_cal": k,
|
||||
"ratio": self.ZONE_RATIOS.get(zone, 1.0),
|
||||
"k_cal": k,
|
||||
"name_cn": self.ZONE_NAMES_CN.get(zone, zone),
|
||||
}
|
||||
source = "pt"
|
||||
@@ -271,20 +513,30 @@ class TensionModel:
|
||||
"cross_section_mm2": round(cross, 1),
|
||||
"zones": zones,
|
||||
"weld_speed_limit": 60.0,
|
||||
"weld_tension_kN": round(t_max * 0.60, 2),
|
||||
"accel_tension": accel_kn,
|
||||
"zone_kcal": self.zone_kcal,
|
||||
"weld_tension_kN": round(t_max * 0.60, 2),
|
||||
"accel_tension": accel_kn,
|
||||
"zone_kcal": self.zone_kcal,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
def calibrate(self, zone, measured_kn):
|
||||
def calibrate(self, zone: str, measured_kn: float) -> Dict:
|
||||
"""更新指定区段的 K_cal(按当前钢种)。"""
|
||||
if zone not in self.ZONE_RATIOS:
|
||||
raise ValueError(f"未知区段: {zone}")
|
||||
t_base = self.tension_coef * self.yield_strength * self.thickness * self.width / 1000.0
|
||||
pred = t_base * self.ZONE_RATIOS[zone] * self.zone_kcal[zone]
|
||||
adj = max(0.5, min(2.0, 1.0 + 0.4*(measured_kn/max(pred,0.1) - 1.0)))
|
||||
adj = max(0.5, min(2.0, 1.0 + 0.4 * (measured_kn / max(pred, 0.1) - 1.0)))
|
||||
self.zone_kcal[zone] = round(self.zone_kcal[zone] * adj, 4)
|
||||
cal = _load_cal(); cal[self._zone_cal_key(zone)] = self.zone_kcal[zone]; _save_cal(cal)
|
||||
_set_kcal(self._zone_key(zone), self.steel_grade, self.zone_kcal[zone])
|
||||
|
||||
append_sample({
|
||||
"model": "tension",
|
||||
"grade": self.steel_grade,
|
||||
"zone": zone,
|
||||
"inputs": [self.thickness, self.width, self.yield_strength, self.tension_coef],
|
||||
"predicted_kn": pred,
|
||||
"actual_kn": measured_kn,
|
||||
})
|
||||
return self.zone_kcal
|
||||
|
||||
|
||||
@@ -295,51 +547,49 @@ class QualityPredictionModel:
|
||||
"""
|
||||
灰箱: Arrhenius PI 计算 + 速度惩罚
|
||||
PT栈: 6 维输入 → [pi_score, surface_score]
|
||||
校准: K_cal 按钢种 + 物理参数按钢种网格拟合
|
||||
输入: [thickness, avg_speed, acid_conc_avg, acid_temp_avg, scale_weight, fe_conc_avg]
|
||||
"""
|
||||
EA_R = 5413.0
|
||||
T_REF = 348.15
|
||||
C_REF = 180.0
|
||||
N_CONC = 1.2
|
||||
CAL_KEY = "quality_kcal"
|
||||
CAL_KEY = "quality"
|
||||
|
||||
def __init__(self, thickness, avg_speed, acid_conc_avg, acid_temp_avg,
|
||||
scale_weight=8.5, fe_conc_avg=60.0):
|
||||
scale_weight=8.5, fe_conc_avg=60.0, steel_grade="_default"):
|
||||
self.thickness = thickness
|
||||
self.avg_speed = avg_speed
|
||||
self.acid_conc_avg = acid_conc_avg
|
||||
self.acid_temp_avg = acid_temp_avg
|
||||
self.scale_weight = scale_weight
|
||||
self.fe_conc_avg = fe_conc_avg
|
||||
self.K_cal = _load_cal().get(self.CAL_KEY, 1.0)
|
||||
self.steel_grade = steel_grade
|
||||
self.K_cal = _get_kcal(self.CAL_KEY, steel_grade)
|
||||
phys = _get_phys(self.CAL_KEY, steel_grade)
|
||||
self.K0 = phys["K0"]
|
||||
self.EA_R = phys["EA_R"]
|
||||
self.N_CONC= phys["N_CONC"]
|
||||
|
||||
def _pi(self):
|
||||
T_k = self.acid_temp_avg + 273.15
|
||||
arr = math.exp(-self.EA_R*(1.0/T_k - 1.0/self.T_REF))
|
||||
c_factor = max(self.acid_conc_avg/self.C_REF, 0.01)**self.N_CONC
|
||||
fe_inh = 1.0 - max(0.0, (self.fe_conc_avg-80.0)/200.0)*0.35
|
||||
scale_corr = (8.5/max(self.scale_weight,1.0))**0.3
|
||||
exposure = (1.2*arr*c_factor*fe_inh*scale_corr*18.0*6) / (self.avg_speed/60.0)
|
||||
return min(max(100.0*(1.0-math.exp(-exposure/10.0))*self.K_cal, 0), 100)
|
||||
def _pi(self) -> float:
|
||||
return _quality_pi_raw(self.avg_speed, self.acid_conc_avg, self.acid_temp_avg,
|
||||
self.scale_weight, self.fe_conc_avg,
|
||||
self.K0, self.EA_R, self.N_CONC, self.K_cal)
|
||||
|
||||
def _surface(self, pi):
|
||||
def _surface(self, pi: float) -> float:
|
||||
if self.avg_speed < 60:
|
||||
ss = 80.0
|
||||
elif self.avg_speed <= 140:
|
||||
ss = 80.0 + 15.0*(self.avg_speed-60)/80.0
|
||||
ss = 80.0 + 15.0 * (self.avg_speed - 60) / 80.0
|
||||
else:
|
||||
ss = 95.0 - 30.0*((self.avg_speed-140)/40.0)
|
||||
return min(max(pi*0.65 + ss*0.35, 0), 100)
|
||||
ss = 95.0 - 30.0 * ((self.avg_speed - 140) / 40.0)
|
||||
return min(max(pi * 0.65 + ss * 0.35, 0), 100)
|
||||
|
||||
def _grade(self, pi, suf):
|
||||
c = (pi+suf)/2.0
|
||||
def _grade(self, pi: float, suf: float) -> str:
|
||||
c = (pi + suf) / 2.0
|
||||
if c >= 90: return "A1"
|
||||
if c >= 80: return "A2"
|
||||
if c >= 70: return "B1"
|
||||
if c >= 60: return "B2"
|
||||
return "C"
|
||||
|
||||
def _recommendations(self, pi, suf):
|
||||
def _recommendations(self, pi: float, suf: float) -> List[str]:
|
||||
recs = []
|
||||
if self.fe_conc_avg > 80:
|
||||
recs.append(f"铁离子浓度偏高({self.fe_conc_avg:.0f} g/L),建议加速换酸")
|
||||
@@ -359,14 +609,17 @@ class QualityPredictionModel:
|
||||
recs.append("工艺参数在正常范围内,当前设定可继续保持")
|
||||
return recs
|
||||
|
||||
def _phys_dict(self):
|
||||
return {"K0": self.K0, "EA_R": self.EA_R, "N_CONC": self.N_CONC}
|
||||
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
x = [self.thickness, self.avg_speed, self.acid_conc_avg,
|
||||
self.acid_temp_avg, self.scale_weight, self.fe_conc_avg]
|
||||
pt = _pt_infer("quality", x)
|
||||
|
||||
if pt is not None:
|
||||
pi = round(float(min(max(pt[0]*self.K_cal, 0), 100)), 1)
|
||||
suf = round(float(min(max(pt[1]*self.K_cal, 0), 100)), 1)
|
||||
pi = round(float(min(max(pt[0] * self.K_cal, 0), 100)), 1)
|
||||
suf = round(float(min(max(pt[1] * self.K_cal, 0), 100)), 1)
|
||||
src = "pt"
|
||||
else:
|
||||
pi = round(self._pi(), 1)
|
||||
@@ -377,17 +630,35 @@ class QualityPredictionModel:
|
||||
"pi_score": pi, "surface_score": suf,
|
||||
"overall_grade": self._grade(pi, suf),
|
||||
"recommendations": self._recommendations(pi, suf),
|
||||
"K_cal": self.K_cal, "source": src,
|
||||
"K_cal": self.K_cal, "phys_params": self._phys_dict(), "source": src,
|
||||
}
|
||||
|
||||
def calibrate(self, actual_grade):
|
||||
def calibrate(self, actual_grade: str) -> float:
|
||||
"""
|
||||
更新当前钢种 K_cal,保存样本,样本 ≥10 时自动触发物理参数拟合。
|
||||
返回新 K_cal。
|
||||
"""
|
||||
grade_map = {"A1": 95, "A2": 85, "B1": 75, "B2": 65, "C": 50}
|
||||
target = grade_map.get(actual_grade, 75)
|
||||
res = self.calculate()
|
||||
pred = (res["pi_score"] + res["surface_score"]) / 2.0
|
||||
adj = max(0.7, min(1.3, 1.0 + 0.3*(target/max(pred,1.0) - 1.0)))
|
||||
adj = max(0.7, min(1.3, 1.0 + 0.3 * (target / max(pred, 1.0) - 1.0)))
|
||||
self.K_cal = round(self.K_cal * adj, 4)
|
||||
cal = _load_cal(); cal[self.CAL_KEY] = self.K_cal; _save_cal(cal)
|
||||
_set_kcal(self.CAL_KEY, self.steel_grade, self.K_cal)
|
||||
|
||||
append_sample({
|
||||
"model": "quality",
|
||||
"grade": self.steel_grade,
|
||||
"inputs": [self.thickness, self.avg_speed, self.acid_conc_avg,
|
||||
self.acid_temp_avg, self.scale_weight, self.fe_conc_avg],
|
||||
"predicted_grade": res["overall_grade"],
|
||||
"actual_grade": actual_grade,
|
||||
})
|
||||
|
||||
n = len(get_samples("quality", self.steel_grade))
|
||||
if n >= _FIT_MIN_SAMPLES and n % 5 == 0:
|
||||
fit_quality_phys_params(self.steel_grade)
|
||||
|
||||
return self.K_cal
|
||||
|
||||
|
||||
@@ -412,17 +683,17 @@ class AcidConsumptionModel:
|
||||
def calculate(self) -> Dict[str, Any]:
|
||||
wt = self.coil_weight_kg / 1000.0
|
||||
acid_base = self.ACID_WITH_REGEN if self.has_regen_station else self.ACID_WITHOUT_REGEN
|
||||
fe_factor = 1.0 + max(0.0, (self.fe_conc_avg-100.0)/100.0)*0.4
|
||||
fe_factor = 1.0 + max(0.0, (self.fe_conc_avg - 100.0) / 100.0) * 0.4
|
||||
acid_unit = round(acid_base * fe_factor, 3)
|
||||
return {
|
||||
"coil_weight_t": round(wt, 3),
|
||||
"acid_consumption_kg": round(acid_unit * wt, 2),
|
||||
"acid_unit_kg_per_t": acid_unit,
|
||||
"steam_consumption_kg": round(self.STEAM_UNIT * wt, 2),
|
||||
"steam_unit_kg_per_t": self.STEAM_UNIT,
|
||||
"power_consumption_kwh": round(self.POWER_UNIT * wt, 2),
|
||||
"power_unit_kwh_per_t": self.POWER_UNIT,
|
||||
"cooling_water_m3": round(self.COOLING_UNIT * wt, 3),
|
||||
"coil_weight_t": round(wt, 3),
|
||||
"acid_consumption_kg": round(acid_unit * wt, 2),
|
||||
"acid_unit_kg_per_t": acid_unit,
|
||||
"steam_consumption_kg": round(self.STEAM_UNIT * wt, 2),
|
||||
"steam_unit_kg_per_t": self.STEAM_UNIT,
|
||||
"power_consumption_kwh": round(self.POWER_UNIT * wt, 2),
|
||||
"power_unit_kwh_per_t": self.POWER_UNIT,
|
||||
"cooling_water_m3": round(self.COOLING_UNIT * wt, 3),
|
||||
"cooling_water_unit_m3_per_t": self.COOLING_UNIT,
|
||||
"fe_conc_factor": round(fe_factor, 3),
|
||||
"fe_conc_factor": round(fe_factor, 3),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user