feat: 6-1차 컬럼 학습형 제어 오프라인 분석 (생산맵·shadow·startup)

신암정유 6-1차 측류 솔벤트 컬럼(C-6111) 실데이터로 오퍼레이터 모방 제어 분석:

- 데이터 추출기 tag_frame() (field_hist WIDE 포맷 디코드) + 운전모드 분류
- ① 생산맵: 스팀유량=f(피드,제품,목표T_C) 운전점 GBM R²0.99 (steam/feed≈0.73)
- shadow 백테스트: in-envelope 오퍼레이터 OP 94% 모방, OOD 게이트→폴백
- 롤링 재학습: 새 로드레짐 적응 (5월 OP MAE 3.9→1.2%)
- 캠페인내 트림: 컬럼 자기제어, 피드백 미미 → 전향 맵이 제어 지배
- ② START-UP 절차: 레시피 + 제품컷인 트리거(reb-A 84.5℃, ΔT 2℃, 조건기반)

문서: 설계·진행 플랜 + 남은 작업(형제확장·shutdown·assist·live포팅) 작업지시서.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-05 17:33:17 +09:00
parent 62a9631fe6
commit 1bc46b1eb0
8 changed files with 1430 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
"""
C-6111 (6-1차 측류 정제 컬럼) 데이터 추출 + 운전모드 1차 특성 분석.
field_hist DB(shinam 실데이터, WIDE 포맷)에서 ptlist/mapping/tblist로 태그를 디코드해
tidy DataFrame을 만든다. 재사용 가능한 tag_frame() 추출기 포함.
근거: docs/학습형제어-오퍼레이터모방-플랜.md §15(디코드), §16(C-6111 토폴로지).
"""
import sys
import psycopg
import pandas as pd
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
ASSET = "/ASSETS/P6"
# C-6111 역할별 태그 (ff_column_config/ff_stream_config + 사용자 도메인, 플랜 §16.1)
ROLES = {
"feed": "FICQ-6101.PV", # 피드(주 외란)
"steam_op": "TICA-6111A.OP", # 리보일러 스팀 밸브(조작/OP)
"steam_flow": "FIQ-6115.PV", # 실제 스팀 유량
"reb_temp": "TICA-6111A.PV", # 리보일러 온도(A, 최고온)
"T_B": "TI-6111B.PV", # 피드존
"T_C": "TI-6111C.PV", # 민감단(제품 추출 트레이 근처)
"T_D": "TI-6111D.PV", # 탑상(최저온)
"feed_preheat": "TI-6103.PV", # 원료 예열
"vacuum": "PICA-6111.PV", # 진공압력
"dp": "PI-6111B.PV", # 컬럼 차압
"product": "FICQ-6118.PV", # 측류 제품 P
"reflux": "FICQ-6113.PV", # 리플럭스 R
"light": "FICQ-6114.PV", # 경질분 제거 D
"heavy": "FICQ-6116.PV", # 중질분 제거 B
"reb_level": "LI-6111.PV", # 리보일러 레벨
"reflux_drum": "LICA-6113.PV", # 리플럭스 드럼 레벨
}
def resolve(conn, shorttags, asset=ASSET):
"""shortptname 목록 -> {tag: (tblname, colnum)}"""
with conn.cursor() as cur:
cur.execute("""
SELECT p.shortptname, t.tblname, m.oit
FROM ptlist p JOIN mapping m ON m.pid=p.pid JOIN tblist t ON t.tid=m.tid
WHERE p.asset=%s AND p.shortptname = ANY(%s)
""", (asset, list(shorttags)))
out = {}
for short, tbl, oit in cur.fetchall():
out[short] = (tbl, int(oit))
return out
def tag_frame(conn, role_map, asset=ASSET):
"""{role: shorttag} -> dtat 인덱스 DataFrame(컬럼=role). 테이블별 1쿼리 후 merge."""
loc = resolve(conn, role_map.values(), asset)
missing = [r for r, t in role_map.items() if t not in loc]
if missing:
print(f"[warn] 미해결 태그: {[(r, role_map[r]) for r in missing]}", file=sys.stderr)
# 테이블별 그룹
by_tbl = {}
for role, short in role_map.items():
if short not in loc:
continue
tbl, col = loc[short]
by_tbl.setdefault(tbl, []).append((role, col))
df = None
for tbl, cols in by_tbl.items():
sel = ", ".join([f'col{c:02d} AS "{role}"' for role, c in cols])
q = f"SELECT dtat, {sel} FROM {tbl}"
part = pd.read_sql(q, conn)
df = part if df is None else df.merge(part, on="dtat", how="outer")
return df.sort_values("dtat").reset_index(drop=True)
def classify_phases(df):
"""1차 운전모드 분류 (임계 기반, §16.3-2). 추후 정교화."""
import numpy as np
reb, vac, steam, prod = df["reb_temp"], df["vacuum"], df["steam_op"], df["product"]
hot_vac = (reb > 60) & (vac < 200) & (steam > 5) # 컬럼 가동(hot+진공)
# 온도 추세(60분=120샘플 기울기)로 startup/shutdown 구분
slope = reb.diff().rolling(120, min_periods=10, center=True).mean()
mode = np.where(
hot_vac,
np.where(prod < 80, "LINEOUT", "PROD"), # 제품≈0 → 전환류/라인아웃
np.where(slope > 0.02, "STARTUP",
np.where(slope < -0.02, "SHUTDOWN", "STOPPED")))
return pd.Series(mode, index=df.index, name="mode")
def plot_timeline(df, png):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
d = df.iloc[::30].copy() # 15분 다운샘플
colors = {"PROD": "#2ca02c", "LINEOUT": "#ff7f0e", "STARTUP": "#1f77b4",
"SHUTDOWN": "#d62728", "STOPPED": "#7f7f7f"}
fig, ax = plt.subplots(5, 1, figsize=(16, 12), sharex=True)
ax[0].plot(d.dtat, d.reb_temp, lw=.5, label="reb_temp(A)")
ax[0].plot(d.dtat, d.T_C, lw=.5, label="T_C(민감단)")
ax[0].plot(d.dtat, d.T_D, lw=.5, label="T_D(탑상)")
ax[0].set_ylabel("온도"); ax[0].legend(loc="upper right", fontsize=7)
ax[1].plot(d.dtat, d.feed, lw=.5, color="purple"); ax[1].set_ylabel("feed FICQ-6101")
ax[2].plot(d.dtat, d["product"], lw=.5, color="orange"); ax[2].set_ylabel("측류제품 6118")
ax[3].plot(d.dtat, d.steam_flow, lw=.5, color="red")
ax[3].plot(d.dtat, d.steam_op * 10, lw=.5, color="brown", alpha=.5, label="OP×10")
ax[3].set_ylabel("스팀유량/OP"); ax[3].legend(loc="upper right", fontsize=7)
ax[4].plot(d.dtat, d.vacuum, lw=.5, color="teal"); ax[4].set_ylabel("진공 PICA-6111")
ax[4].set_ylim(100, 130)
# 모드 배경 음영
for a in ax:
for m, c in colors.items():
seg = d[d["mode"] == m]
a.scatter(seg.dtat, [a.get_ylim()[0]] * len(seg), c=c, s=2, marker="|")
fig.suptitle("C-6111 (6-1차) 전체기간 — 운전모드별 (하단 컬러바)")
fig.tight_layout()
fig.savefig(png, dpi=90)
print(f"플롯 저장: {png}")
def main():
with psycopg.connect(DSN) as conn:
df = tag_frame(conn, ROLES)
print(f"행수={len(df)} 기간={df.dtat.min()} ~ {df.dtat.max()}")
print("\n=== 핵심 신호 분포 (운전모드 임계 설정용) ===")
show = ["feed", "reb_temp", "vacuum", "product", "reflux", "steam_op",
"steam_flow", "T_C", "T_D", "dp"]
desc = df[show].describe(percentiles=[.01, .05, .25, .5, .75, .95, .99]).T
print(desc[["min", "1%", "5%", "50%", "95%", "99%", "max"]].round(2).to_string())
df["mode"] = classify_phases(df)
print("\n=== 운전모드 분포 (30초 샘플 기준) ===")
vc = df["mode"].value_counts()
for m, n in vc.items():
print(f" {m:9s} {n:7d} {100*n/len(df):5.1f}% ≈ {n*30/3600:7.1f} h")
out = "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_data.pkl"
df.to_pickle(out)
plot_timeline(df, "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_timeline.png")
print(f"저장: {out}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,132 @@
"""
C-6111 ① 생산 정상상태 맵 (플랜 §16.3-5).
PROD 구간에서:
1) 밸브특성: OP(TICA-6111A.OP) ↔ 스팀유량(FIQ-6115) — stiction/비선형/게인
2) 정상상태 세그먼트 추출
3) 회귀: 스팀유량 = f(피드, 리플럭스, 제품, 진공, ΔT…) + 피처중요도 + 시간분할 검증
"오퍼레이터 스팀이 가용변수로 얼마나 설명되나" (FIT/MAE)
선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함).
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import r2_score, mean_absolute_error
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
TARGET = "steam_flow" # FIQ-6115 (에너지). 비교용으로 steam_op도 출력
# 깨끗한 인과입력만 (reflux/dp/dT는 스팀의 결과·동시조작 → 순환참조라 제외)
FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"]
OP_RESAMPLE = "6h" # 운전점 집계 (정상상태 내부 변동 적음 → 캠페인/로드레벨 단위 학습)
def load():
df = pd.read_pickle(BASE + "c6111_data.pkl")
df = df[df["mode"] == "PROD"].copy()
# 엔지니어링 피처: 온도 구배(분리도)
df["dT_AC"] = df["reb_temp"] - df["T_C"]
df["dT_CD"] = df["T_C"] - df["T_D"]
# 기본 정합성: 유량/유효범위 (센서 음수노이즈·결측 제거)
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
& df[FEATURES + [TARGET, "steam_op"]].notna().all(axis=1)]
return df.sort_values("dtat").reset_index(drop=True)
def valve_char(df):
"""OP(밸브%) ↔ 스팀유량(FIQ-6115) 특성."""
op, fl = df["steam_op"].values, df["steam_flow"].values
# 선형게인
a = np.polyfit(op, fl, 1)
# 상승/하강 방향별(히스테리시스 ~ stiction 신호): OP 변화방향으로 분리
dop = np.diff(df["steam_op"].values, prepend=df["steam_op"].values[0])
up, dn = dop > 0.05, dop < -0.05
# OP 빈(bin)별 유량 평균 — 같은 OP에서 상승/하강 유량차 = 히스테리시스
bins = np.arange(np.floor(op.min()), np.ceil(op.max()) + 1, 1.0)
rows = []
for lo, hi in zip(bins[:-1], bins[1:]):
m = (op >= lo) & (op < hi)
if m.sum() < 20:
continue
fu = fl[m & up].mean() if (m & up).sum() > 5 else np.nan
fd = fl[m & dn].mean() if (m & dn).sum() > 5 else np.nan
rows.append((lo + .5, fl[m].mean(), fu, fd, m.sum()))
hb = pd.DataFrame(rows, columns=["op", "flow", "flow_up", "flow_dn", "n"])
hyst = (hb["flow_dn"] - hb["flow_up"]).abs().mean()
print(f"[밸브] 선형 flow ≈ {a[0]:.1f}·OP + {a[1]:.1f} "
f"(OP {op.min():.0f}~{op.max():.0f}%, flow {fl.min():.0f}~{fl.max():.0f})")
print(f"[밸브] 상승/하강 평균 유량차(히스테리시스≈stiction) = {hyst:.1f} "
f"(유량 스팬의 {100*hyst/(fl.max()-fl.min()):.1f}%)")
return hb, a
def regress(df):
from sklearn.model_selection import train_test_split
# 운전점 집계: 정상상태 내부 변동이 거의 없어(98% steady) 점단위 학습 불가.
# 6h 중앙값 = 캠페인/로드레벨 단위 운전점 → 진짜 f(부하) 신호.
ops = (df.set_index("dtat").resample(OP_RESAMPLE).median(numeric_only=True)
.dropna(subset=[TARGET, "feed"]))
ops = ops[ops["feed"] > 50]
print(f"\n[운전점] PROD {len(df)}행 → {OP_RESAMPLE} 운전점 {len(ops)}")
X, y = ops[FEATURES].values, ops[TARGET].values
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.3, random_state=0)
# 베이스라인: 피드만 (steam/feed 비율제어가 얼마나 설명?)
lb = LinearRegression().fit(Xtr[:, :1], ytr)
r2_feed = r2_score(yte, lb.predict(Xte[:, :1]))
sc = StandardScaler().fit(Xtr)
lin = LinearRegression().fit(sc.transform(Xtr), ytr)
gbm = GradientBoostingRegressor(n_estimators=200, max_depth=2,
learning_rate=0.05, random_state=0).fit(Xtr, ytr)
span = y.max() - y.min()
for name, pred in [("Linear", lin.predict(sc.transform(Xte))),
("GBM", gbm.predict(Xte))]:
print(f"[모델 {name:7s}] test R²(FIT)={r2_score(yte,pred):.3f} "
f"MAE={mean_absolute_error(yte,pred):.1f} (스팬의 {100*mean_absolute_error(yte,pred)/span:.1f}%)")
print(f"[베이스라인 피드단독] test R²={r2_feed:.3f} "
f"steam/feed비 중앙값={(ops[TARGET]/ops['feed']).median():.3f}")
print("\n[피처 중요도]")
coef = pd.Series(lin.coef_, index=FEATURES) # 표준화 → 상대중요도
imp = pd.Series(gbm.feature_importances_, index=FEATURES)
tbl = pd.DataFrame({"lin_std계수": coef.round(1),
"GBM중요도": imp.round(3)}).sort_values("GBM중요도", ascending=False)
print(tbl.to_string())
return ops, gbm, Xte, yte, gbm.predict(Xte), imp
def plots(hb, ops, yte, pred, imp):
fig, ax = plt.subplots(1, 4, figsize=(22, 5))
ax[0].scatter(hb["op"], hb["flow"], s=20, c="k", label="mean")
ax[0].plot(hb["op"], hb["flow_up"], "b.-", ms=4, label="OP rising")
ax[0].plot(hb["op"], hb["flow_dn"], "r.-", ms=4, label="OP falling")
ax[0].set_xlabel("steam OP %"); ax[0].set_ylabel("steam flow FIQ-6115")
ax[0].set_title("Valve char (hysteresis=stiction)"); ax[0].legend()
ax[1].scatter(ops["feed"], ops[TARGET], s=10, alpha=.5)
ax[1].set_xlabel("feed FICQ-6101"); ax[1].set_ylabel("steam flow")
ax[1].set_title("steam vs feed (operating points)")
ax[2].scatter(yte, pred, s=12, alpha=.5)
lim = [min(yte.min(), pred.min()), max(yte.max(), pred.max())]
ax[2].plot(lim, lim, "r--"); ax[2].set_xlabel("actual steam flow")
ax[2].set_ylabel("predicted (GBM)"); ax[2].set_title("Predicted vs Actual (test ops)")
imp.sort_values().plot.barh(ax=ax[3]); ax[3].set_title("GBM feature importance")
fig.tight_layout(); fig.savefig(BASE + "c6111_prodmap.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_prodmap.png")
def main():
df = load()
print(f"PROD 정합데이터 {len(df)}")
hb, a = valve_char(df)
ops, gbm, Xte, yte, pred, imp = regress(df)
plots(hb, ops, yte, pred, imp)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,78 @@
"""
C-6111 롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모 (플랜 §16.7-(1)).
held-out 5월을 하루씩 전진하며 '그 날 이전 전체 이력(expanding window)'으로 매일 재학습→그 날 예측.
정적 모델(2~4월 고정)의 +4% 외삽 바이어스가 모델이 5월 저부하 데이터를 흡수하며
사라지는지(적응 곡선) + OOD 비율이 떨어지는지 확인. 입력 평활은 인과(trailing).
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.metrics import mean_absolute_error
from c6111_shadow import SteamPredictor, FEATURES, BASE, SMOOTH
HELDOUT_START = "2026-05-01"
RETRAIN_EVERY = "1D"
def main():
df = pd.read_pickle(BASE + "c6111_data.pkl")
df = df[df["mode"] == "PROD"].copy()
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
# 인과(trailing) 평활 — 미래누설 없음
for c in FEATURES:
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1).median()
ho = pd.Timestamp(HELDOUT_START)
days = pd.date_range(ho, df["dtat"].max(), freq=RETRAIN_EVERY)
# 정적 모델: 5월 이전 전체로 1회 학습
static = SteamPredictor().fit(df[df["dtat"] < ho])
slo, shi = (df[df["dtat"] < ho][FEATURES].quantile(0.01),
df[df["dtat"] < ho][FEATURES].quantile(0.99))
rows = []
for d0, d1 in zip(days[:-1], days[1:]):
day = df[(df["dtat"] >= d0) & (df["dtat"] < d1)]
if len(day) < 30:
continue
train = df[df["dtat"] < d0] # expanding: 그 날 이전 전체
roll = SteamPredictor().fit(train)
lo, hi = train[FEATURES].quantile(0.01), train[FEATURES].quantile(0.99)
Xs = day[[c + "_s" for c in FEATURES]].values
ao = day["steam_op"].values
po_r = roll.flow_to_op(roll.predict_flow(Xs))
po_s = static.flow_to_op(static.predict_flow(Xs))
ood_r = (~((day[FEATURES] >= lo) & (day[FEATURES] <= hi)).all(axis=1)).mean()
rows.append(dict(day=d0,
mae_roll=mean_absolute_error(ao, po_r),
mae_static=mean_absolute_error(ao, po_s),
w2_roll=np.mean(np.abs(po_r - ao) <= 2) * 100,
w2_static=np.mean(np.abs(po_s - ao) <= 2) * 100,
ood_roll=ood_r * 100))
r = pd.DataFrame(rows)
print(f"=== 5월 held-out, 일별 walk-forward 재학습 ({len(r)}일) ===")
print(f"정적 모델 : OP MAE {r.mae_static.mean():.2f}% |Δ|≤2% {r.w2_static.mean():.1f}%")
print(f"롤링 모델 : OP MAE {r.mae_roll.mean():.2f}% |Δ|≤2% {r.w2_roll.mean():.1f}%")
print(f"롤링 OOD 비율: 첫주 {r.head(7).ood_roll.mean():.0f}% → 마지막주 {r.tail(7).ood_roll.mean():.0f}%")
print("\n일별(요약):")
print(r[["day", "mae_static", "mae_roll", "w2_roll", "ood_roll"]]
.assign(day=r.day.dt.strftime("%m-%d")).round(1).to_string(index=False))
fig, ax = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
ax[0].plot(r.day, r.mae_static, "r.-", label="static (Feb-Apr model)")
ax[0].plot(r.day, r.mae_roll, "g.-", label="rolling retrain")
ax[0].axhline(2, color="gray", ls=":", label="2% 허용")
ax[0].set_ylabel("OP MAE %"); ax[0].legend(); ax[0].set_title("Rolling vs static — adaptation over May")
ax[1].plot(r.day, r.ood_roll, "b.-"); ax[1].set_ylabel("rolling OOD %")
ax[1].set_title("OOD fraction (학습 envelope 밖) — 5월 데이터 흡수하며 감소")
fig.tight_layout(); fig.savefig(BASE + "c6111_rolling.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_rolling.png")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
"""
C-6111 Shadow 예측기 — 히스토리 리플레이 백테스트 (플랜 §7 shadow 진입).
학습기간 운전점으로 `스팀유량=f(피드,제품,목표T_C)` 학습 → held-out 미래기간을
매 시점 리플레이하여 예측 스팀→(밸브 역특성)→예측 OP 를 산출, **실제 오퍼레이터 OP와 비교**.
"이 예측기를 shadow로 돌렸다면 오퍼레이터 손과 얼마나 일치했나" 를 정직 검증.
선행: c6111_data.pkl. 포팅대상(추후 C# live shadow)은 동일 로직.
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import r2_score, mean_absolute_error
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
FEATURES = ["feed", "product", "T_C"] # 깨끗한 인과/목표 입력 (§16.6)
SMOOTH = 40 # 입력 평활 20분(운전점 성격 유지)
TRAIN_FRAC = 0.70 # 앞 70% 기간 학습, 뒤 30% held-out shadow
class SteamPredictor:
"""운전점 학습 + 밸브 역특성(flow→OP)."""
def fit(self, df_train):
ops = (df_train.set_index("dtat").resample("6h").median(numeric_only=True)
.dropna(subset=["steam_flow", "feed"]))
ops = ops[ops["feed"] > 50]
self.model = GradientBoostingRegressor(n_estimators=200, max_depth=2,
learning_rate=0.05, random_state=0)
self.model.fit(ops[FEATURES].values, ops["steam_flow"].values)
# 밸브 역특성: OP = poly(flow) (단조, 3차)
self.inv = np.polyfit(df_train["steam_flow"], df_train["steam_op"], 3)
return self
def predict_flow(self, X):
return self.model.predict(X)
def flow_to_op(self, flow):
return np.clip(np.polyval(self.inv, flow), 0, 100)
def main():
df = pd.read_pickle(BASE + "c6111_data.pkl")
df = df[df["mode"] == "PROD"].copy()
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
# 입력 평활 (실제 shadow도 노이즈 평활 사용)
for c in FEATURES:
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1, center=True).median()
cut = df["dtat"].quantile(TRAIN_FRAC)
tr, te = df[df["dtat"] <= cut], df[df["dtat"] > cut]
print(f"학습 {tr.dtat.min()}~{tr.dtat.max()} ({len(tr)}) "
f"shadow(held-out) {te.dtat.min()}~{te.dtat.max()} ({len(te)})")
pred = SteamPredictor().fit(tr)
# OOD(학습 운전envelope 밖) 게이트: 입력이 학습 1~99% 범위 밖이면 '저신뢰→오퍼레이터 폴백'
lo, hi = tr[FEATURES].quantile(0.01), tr[FEATURES].quantile(0.99)
print(f"학습 envelope: " + ", ".join(f"{c}[{lo[c]:.0f},{hi[c]:.1f}]" for c in FEATURES))
def in_env(d):
return ((d[FEATURES] >= lo) & (d[FEATURES] <= hi)).all(axis=1)
for name, d in [("학습기간", tr), ("★held-out shadow", te)]:
Xs = d[[c + "_s" for c in FEATURES]].values
pf = pred.predict_flow(Xs)
po = pred.flow_to_op(pf)
ao = d["steam_op"].values
env = in_env(d).values
within = np.mean(np.abs(po - ao) <= 2.0) * 100
print(f"\n[{name}] OOD(범위밖)={100*(~env).mean():.1f}%")
print(f" 전체 OP MAE={mean_absolute_error(ao,po):.2f} |Δ|≤2%={within:.1f}%")
if env.sum() > 50:
print(f" in-envelope OP MAE={mean_absolute_error(ao[env],po[env]):.2f} "
f"|Δ|≤2%={np.mean(np.abs(po[env]-ao[env])<=2)*100:.1f}% ← shadow가 신뢰구간에서 조언")
d = d.assign(pred_flow=pf, pred_op=po, ood=~env)
if name.startswith(""):
te = d
# 플롯: held-out 시계열 오버레이 + OP 비교 + 오차분포
fig, ax = plt.subplots(3, 1, figsize=(16, 11))
s = te.iloc[::20]
ax[0].plot(s.dtat, s["steam_flow"], lw=.6, label="actual steam flow")
ax[0].plot(s.dtat, s["pred_flow"], lw=.6, c="r", label="predicted")
ax[0].set_title("held-out shadow: steam flow actual vs predicted"); ax[0].legend(fontsize=8)
ax[1].plot(s.dtat, s["steam_op"], lw=.6, label="actual operator OP")
ax[1].plot(s.dtat, s["pred_op"], lw=.6, c="r", label="predicted OP")
ax[1].set_ylabel("OP %"); ax[1].set_title("operator OP vs shadow-predicted OP"); ax[1].legend(fontsize=8)
err = te["pred_op"] - te["steam_op"]
ax[2].hist(err, bins=80); ax[2].axvline(0, c="k", lw=.5)
ax[2].set_title(f"OP error (pred-actual): median {err.median():+.2f}%, std {err.std():.2f}%")
fig.tight_layout(); fig.savefig(BASE + "c6111_shadow.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_shadow.png")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,109 @@
"""
C-6111 ② START-UP 절차 학습 (플랜 §16.4 ②, few-shot).
startup 에피소드를 탐지→스팀투입 시점(t0)에 정렬→중첩, 절차를 해석가능 레시피로 추출:
단계 시퀀스(진공→스팀/승온→전환류 라인아웃→제품컷인→로드램프→생산),
각 단계 타이밍, 그리고 ★핵심 결정 "제품 컷인" 시점의 컬럼 상태(트리거)★.
블랙박스 정책 아님 — 안전·설명가능 우선.
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
def detect_cutins(df):
"""★제품 컷인★ 이벤트: product 0→>100 상향, 직전 30분 라인아웃(product<50)이고 hot(reb>75)."""
prod = df["product"].values
reb = df["reb_temp"].values
outs = []
i = 60
n = len(df)
while i < n:
if prod[i] > 100 and prod[i-1] <= 100:
pre = prod[max(0, i-60):i] # 직전 30분
if np.nanmedian(pre) < 50 and reb[i] > 75: # 라인아웃(제품off)+hot
outs.append(i)
i += 720
continue
i += 1
return outs
def milestones(df, ci):
"""제품 컷인 인덱스 ci 기준 절차 추출."""
tc = df["dtat"].iloc[ci]
# 역방향: 스팀투입(steam_op>10 연속 시작) — 컷인 직전 steam off→on
back = df.iloc[max(0, ci-1200):ci]
off = back[back["steam_op"] <= 10]
i_steam = off.index[-1] + 1 if len(off) else back.index[0]
# 리플럭스 확립(스팀투입 이후 reflux>100 첫)
aft = df.iloc[i_steam:ci]
r_on = aft[aft["reflux"] > 100]
i_refl = r_on.index[0] if len(r_on) else None
# 풀로드(컷인 이후 feed>250 첫)
fwd = df.iloc[ci:ci+1200]
f_on = fwd[fwd["feed"] > 250]
i_full = f_on.index[0] if len(f_on) else None
def mins(i):
return None if i is None else (df["dtat"].iloc[i]-tc).total_seconds()/60
r = df.iloc[ci]
return dict(cutin_time=tc,
steam_to_cutin=-mins(i_steam),
reflux_to_cutin=(-mins(i_refl) if i_refl is not None else None),
cutin_to_full=mins(i_full),
cutin_rebA=r["reb_temp"], cutin_TC=r["T_C"], cutin_TD=r["T_D"],
cutin_dT_AD=r["reb_temp"]-r["T_D"])
def main():
df = pd.read_pickle(BASE + "c6111_data.pkl").sort_values("dtat").reset_index(drop=True)
cutins = detect_cutins(df)
print(f"탐지된 ★제품 컷인★(진짜 startup) 이벤트: {len(cutins)}")
rows, windows = [], []
for ci in cutins:
w = df.iloc[max(0, ci-360):min(len(df), ci+360)].copy() # 컷인 ±3h
w["rel_min"] = (w["dtat"] - df["dtat"].iloc[ci]).dt.total_seconds()/60
windows.append(w)
rows.append(milestones(df, ci))
M = pd.DataFrame(rows)
pd.set_option("display.width", 220)
print("\n=== 제품컷인 기준 절차(분) + 컷인 시점 컬럼상태 ===")
cols = ["cutin_time", "steam_to_cutin", "reflux_to_cutin", "cutin_to_full",
"cutin_rebA", "cutin_TC", "cutin_dT_AD"]
show = M[cols].copy()
show["cutin_time"] = show["cutin_time"].dt.strftime("%m-%d %H:%M")
print(show.round(1).to_string(index=False))
print("\n=== 절차 레시피(중앙값) ===")
print(f" 스팀투입→제품컷인(전환류 라인아웃 길이): {M.steam_to_cutin.median():.0f}")
print(f" 리플럭스확립→제품컷인 : {M.reflux_to_cutin.median():.0f}")
print(f" 제품컷인→풀로드 : {M.cutin_to_full.median():.0f}")
print(f" ★제품컷인 트리거(컬럼상태): reb-A={M.cutin_rebA.median():.1f}±{M.cutin_rebA.std():.1f}℃, "
f"T_C={M.cutin_TC.median():.1f}±{M.cutin_TC.std():.2f}℃, ΔT(A-D)={M.cutin_dT_AD.median():.1f}")
fig, ax = plt.subplots(4, 1, figsize=(13, 11), sharex=True)
for k, w in enumerate(windows):
c = plt.cm.tab10(k)
ax[0].plot(w.rel_min, w.reb_temp, color=c, lw=.9, label=f"ep{k+1} {w.dtat.iloc[len(w)//2]:%m-%d}")
ax[0].plot(w.rel_min, w["T_D"], color=c, lw=.6, ls=":")
ax[1].plot(w.rel_min, w.steam_flow, color=c, lw=.9)
ax[2].plot(w.rel_min, w.reflux, color=c, lw=.9)
ax[2].plot(w.rel_min, w["product"], color=c, lw=.9, ls="--")
ax[3].plot(w.rel_min, w.feed, color=c, lw=.9)
ax[0].set_ylabel("reb_temp/T_D(:)"); ax[0].legend(fontsize=7)
ax[0].set_title("STARTUP aligned at PRODUCT CUT-IN (rel=0)")
ax[1].set_ylabel("steam flow"); ax[2].set_ylabel("reflux/product(--)")
ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-in")
for a in ax:
a.axvline(0, c="k", lw=.5)
fig.tight_layout(); fig.savefig(BASE + "c6111_startup.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_startup.png")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,92 @@
"""
C-6111 캠페인내 온도 트림 = gentle 피드백 추출 (플랜 §5, §16.8-(3)).
정상맵(전향)은 운전점 단위 steam=f(부하)만 잡는다. 캠페인 내부에서 오퍼레이터가
T_C 작은 편차에 스팀(OP)을 어떻게 미세조정하는지 = 피드백 정책을 데이터에서 추출:
- 부하 일정 구간만(전향/부하응답과 분리)
- 목표 T_C = 느린 인과 베이스라인, 오차 err = T_C - 목표
- OP 변경 이벤트의 트리거 오차(데드밴드), 변경크기 vs 오차(게인), 이벤트 간격(dwell)
→ §5 anti-hunting 제어(데드밴드+게인+dwell)의 현장 파라미터.
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
TGT_WIN = 360 # 목표 T_C 베이스라인 3h(인과 trailing)
LOAD_WIN = 120 # 부하 일정 판정 1h
LOAD_STD_MAX = 15 # feed rolling std 임계(저변동=캠페인 내부)
MOVE = 0.1 # OP 변경 인식 임계(%)
def main():
df = pd.read_pickle(BASE + "c6111_data.pkl")
df = df[df["mode"] == "PROD"].copy().sort_values("dtat").reset_index(drop=True)
df = df[(df["feed"] > 50) & (df["steam_op"] > 1)]
# 목표 T_C(느린 인과 베이스라인) 및 오차
df["tc_tgt"] = df["T_C"].rolling(TGT_WIN, min_periods=30).median()
df["err"] = df["T_C"] - df["tc_tgt"]
df["dop"] = df["steam_op"].diff()
# 부하 일정 구간(전향 부하응답 제외 → 순수 피드백 트림)
df["feed_std"] = df["feed"].rolling(LOAD_WIN, min_periods=30).std()
steady = df[(df["feed_std"] < LOAD_STD_MAX) & df["err"].notna()].copy()
print(f"PROD {len(df)} → 부하일정 정상구간 {len(steady)} ({100*len(steady)/len(df):.0f}%)")
# 제어 성능: 목표 대비 T_C 유지 밴드
e = steady["err"]
print(f"\n[T_C 유지] 오차 std={e.std():.3f}℃ p5/p95=[{e.quantile(.05):+.2f},{e.quantile(.95):+.2f}]℃")
# OP 변경 이벤트
mv = steady[steady["dop"].abs() > MOVE].copy()
# 직전 오차(트리거)
mv["err_trig"] = steady["err"].shift(1).loc[mv.index]
print(f"[OP 이동] 정상구간 {len(steady)}샘플 중 이동 {len(mv)}"
f"(평탄율 {100*(1-len(mv)/len(steady)):.1f}%)")
# 데드밴드: 이동시 |오차| vs 비이동시 |오차|
nomv = steady[steady["dop"].abs() <= MOVE]
print(f"[데드밴드] 이동시 |오차| median={mv['err_trig'].abs().median():.3f}"
f"비이동시 |오차| median={nomv['err'].abs().median():.3f}")
print(f" 이동의 75%는 |오차|>{mv['err_trig'].abs().quantile(.25):.3f}℃ 에서 발생")
# 피드백 게인: dOP vs 트리거오차 (음의 기울기 기대)
g = mv.dropna(subset=["err_trig"])
g = g[g["err_trig"].abs() < 2] # 이상치 제외
if len(g) > 30:
a = np.polyfit(g["err_trig"], g["dop"], 1)
r = np.corrcoef(g["err_trig"], g["dop"])[0, 1]
print(f"[피드백 게인] dOP ≈ {a[0]:+.2f}·오차 {a[1]:+.2f} (corr={r:+.2f}, "
f"음수=음의피드백: 온도↑→스팀↓)")
# dwell: 이동 간격(샘플)
dwell = np.diff(mv.index.values)
dwell = dwell[dwell > 0]
if len(dwell):
print(f"[dwell] 이동 간격 중앙 {np.median(dwell)*30/60:.0f}"
f"p25/p75=[{np.percentile(dwell,25)*30/60:.0f},{np.percentile(dwell,75)*30/60:.0f}]분")
# 플롯
fig, ax = plt.subplots(2, 2, figsize=(14, 9))
ax[0, 0].hist(e.dropna(), bins=100); ax[0, 0].axvline(0, c="k", lw=.5)
ax[0, 0].set_title(f"T_C error band (std {e.std():.3f}C)"); ax[0, 0].set_xlim(-1, 1)
ax[0, 1].hist(mv["err_trig"].abs().dropna(), bins=60, alpha=.6, density=True, label="at move")
ax[0, 1].hist(nomv["err"].abs().dropna(), bins=60, alpha=.6, density=True, label="no move")
ax[0, 1].set_title("deadband: |error| at move vs no-move"); ax[0, 1].set_xlim(0, 1); ax[0, 1].legend()
if len(g) > 30:
ax[1, 0].scatter(g["err_trig"], g["dop"], s=5, alpha=.3)
xs = np.linspace(g["err_trig"].min(), g["err_trig"].max(), 50)
ax[1, 0].plot(xs, np.polyval(a, xs), "r-")
ax[1, 0].set_xlabel("T_C error trigger"); ax[1, 0].set_ylabel("dOP")
ax[1, 0].set_title(f"feedback gain dOP/err = {a[0]:+.2f}")
if len(dwell):
ax[1, 1].hist(dwell * 30 / 60, bins=60); ax[1, 1].set_xlabel("dwell min")
ax[1, 1].set_title("dwell between OP moves"); ax[1, 1].set_xlim(0, 300)
fig.tight_layout(); fig.savefig(BASE + "c6111_trim.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_trim.png")
if __name__ == "__main__":
main()