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:
2026-06-01 16:13:39 +08:00
parent b461f0d2f8
commit f5c59db92b
3 changed files with 705 additions and 228 deletions

View File

@@ -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),
}