Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)
WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
total reflux recovery, config form expansion
20 KiB
WO-3 (P-1 θ 자동튜닝, passive 교차상관) — 완전코드 작업지시서
대상 실행자: 본 LLM보다 능력이 낮은 LLM. 추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙한다. 선행 완료 전제(필수): §0 + WO-1 + WO-2 머지 완료. 즉
ColumnConfig.SteamOpTag/ThetaAutoTune/SensitiveTrayTag,StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf(§0),BuildTemps/ColumnState.PRefSeeded/PRefValue(WO-2),AdvisoryResult.Temps·PvSnapshot.Temps는 이미 존재한다(다시 만들지 말 것). WO-2가 안 됐으면 WO-2 먼저. 불변식: advisory — 제어 레지스터 쓰기 0건. config의 θ는 절대 변경하지 않는다. 화면에 "제안"만 표시(운전원이 수동 반영).
목적
정상 운전 중 자연 외란으로 피드→온도(PCT) 전달지연 θ를 passive 교차상관으로 식별해 commanded 스트림에 제안한다.
spec §13.4: θ = argmax_τ ρ(ΔF(t), ΔPCT(t+τ)), 스팀 OP(TICA.OP)를 부분상관으로 제거해 폐루프 오염 회피(함정 ④).
외란 부족·신뢰 낮으면 제안 억제(null). seed θ가 전부 placeholder인 문제(PhaseI §5.8)를 데이터로 보정.
현실 경고(spec §13.2·§13.7): 단일점 생온도 SNR 낮음 → θ는 신뢰도 등급 붙은 추정치. 데모 온도는 인위생성이라 실플랜트 전 가동 스위치
ThetaAutoTune는 기본 false. 본 WO는 블록·배선·테스트까지 턴키로 두되 옵트인.
변경 파일 (총 6개)
src/Infrastructure/Control/CrossCorrLagEstimator.cs— 신규 블록src/Core/Application/Feedforward/FeedforwardModels.cs—PvSnapshot.SteamOpinit 프로퍼티src/Infrastructure/Control/FeedforwardSupervisor.cs— 스팀 OP 읽기(.op는 .pv 아님)src/Infrastructure/Control/FeedforwardEngine.cs—ColumnState필드 +ApplyThetaSuggestion+ Tick 배선src/Web/wwwroot/js/ff.js— θ 제안 표시 (Controller는 §0에서 이미thetaSuggest*노출 — 변경 없음)src/Web/wwwroot/css/ff.css— θ 행 스타일tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs— 신규 테스트
STEP 1 — 신규 파일 CrossCorrLagEstimator.cs
신규 파일: src/Infrastructure/Control/CrossCorrLagEstimator.cs
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ.
/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4).
/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환).
/// </summary>
public sealed class CrossCorrLagEstimator
{
private readonly int _maxLag; // 탐색할 최대 지연(샘플)
private readonly int _hist; // 보존 이력(샘플)
private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제)
private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수)
private readonly Queue<double> _f = new();
private readonly Queue<double> _r = new();
private readonly Queue<double> _s = new();
private int _sinceCompute;
private (double thetaUpSec, double thetaDnSec, double conf)? _last;
public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd, int recomputeEvery = 30)
{
_maxLag = Math.Max(1, maxLagSamples);
_hist = Math.Max(_maxLag * 2, historySamples);
_minStd = minSignalStd;
_recomputeEvery = Math.Max(1, recomputeEvery);
}
public (double thetaUpSec, double thetaDnSec, double conf)? Push(
double dFeed, double dResponse, double dSteam, double tsSec)
{
_f.Enqueue(dFeed); _r.Enqueue(dResponse); _s.Enqueue(dSteam);
while (_f.Count > _hist) { _f.Dequeue(); _r.Dequeue(); _s.Dequeue(); }
if (_f.Count < _maxLag * 2) return _last; // 외란 누적 부족 → 직전 결과(초기 null)
_sinceCompute++;
if (_last is not null && _sinceCompute < _recomputeEvery) return _last; // 캐시
_sinceCompute = 0;
var f = _f.ToArray(); var r = _r.ToArray(); var s = _s.ToArray();
int n = f.Length;
if (Std(f) < _minStd) { _last = null; return null; } // 피드 외란 없음 → 억제
// 부분상관: r에서 s의 동시점 선형성분 제거 (잔차)
double beta = Cov(r, s) / Math.Max(1e-12, Var(s));
var resid = new double[n];
for (int i = 0; i < n; i++) resid[i] = r[i] - beta * s[i];
// 방향별 θ (상승/하강 비대칭). 표본 부족 시 NaN.
var (tu, cu) = BestLag(f, resid, n, x => x > 0, tsSec);
var (td, cd) = BestLag(f, resid, n, x => x < 0, tsSec);
bool haveUp = !double.IsNaN(tu), haveDn = !double.IsNaN(td);
if (!haveUp && !haveDn) { _last = null; return null; }
if (!haveUp) { tu = td; cu = cd; }
if (!haveDn) { td = tu; cd = cu; }
double conf = Math.Min(cu, cd);
if (conf < 0.3) { _last = null; return null; } // 신뢰 부족 → 억제
_last = (tu, td, conf);
return _last;
}
/// <summary>mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0).</summary>
private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func<double, bool> mask, double tsSec)
{
int masked = 0;
for (int i = 0; i < n; i++) if (mask(f[i])) masked++;
if (masked < _maxLag) return (double.NaN, 0.0);
double bestRho = double.NegativeInfinity; int bestTau = 0;
for (int tau = 0; tau <= _maxLag; tau++)
{
double sfr = 0, sff = 0, srr = 0; int m = 0;
for (int i = 0; i + tau < n; i++)
{
if (!mask(f[i])) continue;
double a = f[i], b = resid[i + tau];
sfr += a * b; sff += a * a; srr += b * b; m++;
}
if (m < 3 || sff <= 0 || srr <= 0) continue;
double rho = sfr / Math.Sqrt(sff * srr); // Δ신호라 비중심 상관
if (rho > bestRho) { bestRho = rho; bestTau = tau; }
}
if (double.IsNegativeInfinity(bestRho)) return (double.NaN, 0.0);
return (bestTau * tsSec, Math.Max(0.0, bestRho));
}
private static double Mean(double[] a) { double s = 0; foreach (var x in a) s += x; return s / a.Length; }
private static double Var(double[] a) { double m = Mean(a), s = 0; foreach (var x in a) s += (x - m) * (x - m); return s / a.Length; }
private static double Std(double[] a) => Math.Sqrt(Var(a));
private static double Cov(double[] a, double[] b)
{ double ma = Mean(a), mb = Mean(b), s = 0; for (int i = 0; i < a.Length; i++) s += (a[i] - ma) * (b[i] - mb); return s / a.Length; }
}
STEP 2 — FeedforwardModels.cs : PvSnapshot.SteamOp 추가
파일: src/Core/Application/Feedforward/FeedforwardModels.cs
찾기 (WO-2가 추가한 PvSnapshot의 Temps 프로퍼티):
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
}
바꾸기:
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
}
STEP 3 — FeedforwardSupervisor.cs : 스팀 OP 읽기
⚠️
SteamOpTag은.OP(컨트롤러 출력)이지.pv가 아니다.Sample()/PvTag()는.pv를 강제 부착하므로 스팀엔 쓰면 안 된다. 아래처럼 태그를 그대로(소문자) 읽는 SampleExact를 추가한다.
3.1 읽을 태그 목록에 SteamOpTag 추가
찾기 (WO-2가 추가한 TempTags 줄):
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
바꾸기:
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
3.2 SampleExact 헬퍼 추가 (Sample 바로 뒤)
찾기 (기존 Sample 로컬함수의 닫는 부분):
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
바꾸기:
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
// WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
TagSample SampleExact(string rawTag)
{
var tag = rawTag.ToLowerInvariant();
if (rows.TryGetValue(tag, out var r)
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
{
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
return new TagSample(tag, v, Good: fresh, r.Timestamp);
}
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
3.3 PvSnapshot에 SteamOp 채우기
전제: WO-2에서 이 return은 이미
{ Temps = temps }형태다.
찾기:
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps };
바꾸기:
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
STEP 4 — FeedforwardEngine.cs : 상태필드 + θ 제안 배선
파일: src/Infrastructure/Control/FeedforwardEngine.cs
4.1 ColumnState에 θ 추정 상태 추가
전제: WO-2에서
PRefSeeded/PRefValue가 이미 추가됨.
찾기:
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
바꾸기:
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
// WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
public CrossCorrLagEstimator? ThetaEst { get; set; }
public double PrevFeedFiltered { get; set; } = double.NaN;
public double PrevRespPct { get; set; } = double.NaN;
public double PrevSteamOp { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
4.2 Tick 배선 — return 직전에 θ 제안 적용
전제: WO-2에서 return이
var temps = BuildTemps(...)+{ Temps = temps }형태다.
찾기:
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
바꾸기:
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
4.3 ApplyThetaSuggestion 메서드 추가 (BuildTemps 바로 뒤)
전제: WO-2가 추가한
BuildTemps메서드는return list;+}로 끝난다.
찾기 (BuildTemps의 마지막):
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
바꾸기:
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
// ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ──────
private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts,
IReadOnlyList<TempPoint>? temps, ref List<StreamAdvisory> outs)
{
if (!cfg.ThetaAutoTune) return; // 옵트인(기본 off)
if (temps is null || temps.Count == 0) return;
// 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
double respPct = double.NaN;
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
}
if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
if (double.IsNaN(respPct)) return;
double feedNow = st.FeedFilter.Value;
double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;
// 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow - st.PrevFeedFiltered : 0.0;
double dR = Num.IsFinite(st.PrevRespPct) ? respPct - st.PrevRespPct : 0.0;
double dS = Num.IsFinite(st.PrevSteamOp) ? steamNow - st.PrevSteamOp : 0.0;
st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;
st.ThetaEst ??= new CrossCorrLagEstimator(
maxLagSamples: Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))), // ~20분 지연 탐색
historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))), // ~1시간 이력
minSignalStd: 1e-9);
var est = st.ThetaEst.Push(dF, dR, dS, ts);
if (est is null) return;
var (tu, td, conf) = est.Value;
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
Controller 변경 없음: §0에서
MapColumn이 이미thetaSuggestUpSec/DnSec/Conf를 노출한다.
STEP 5 — ff.js : θ 제안 표시
파일: src/Web/wwwroot/js/ff.js
5.1 θ 제안 const 추가 (return 직전)
찾기:
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
바꾸기:
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
5.2 카드 본문에 ${theta} 삽입
전제: WO-2에서 mb 아래에
${temps}가 이미 들어가 있다.
찾기:
<div class="ff-mb">${esc(mb)}</div>
${temps}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
바꾸기:
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
STEP 6 — ff.css : θ 행 스타일
파일: src/Web/wwwroot/css/ff.css
파일 맨 끝에 추가:
/* WO-3 θ 자동튜닝 제안 행 */
.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px}
.ff-theta small{color:var(--t2)}
STEP 7 — 신규 테스트 FeedforwardThetaTests.cs
신규 파일: tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs
using System;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardThetaTests
{
// 알려진 지연(5 샘플)으로 응답이 피드를 따라가면 θ≈5*ts 로 식별되어야 함
[Fact]
public void Estimator_finds_known_lag()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-9, recomputeEvery: 1);
var feed = new System.Collections.Generic.List<double>();
(double thetaUpSec, double thetaDnSec, double conf)? last = null;
for (int t = 0; t < 400; t++)
{
double df = Math.Sin(t * 0.3); // 풍부한 양/음 외란
feed.Add(df);
double dr = t >= 5 ? feed[t - 5] : 0.0; // 응답 = 피드 5샘플 지연
last = est.Push(df, dr, 0.0, tsSec: 1.0); // 스팀 0
}
Assert.NotNull(last);
Assert.InRange(last!.Value.thetaUpSec, 4.0, 6.0);
Assert.InRange(last!.Value.thetaDnSec, 4.0, 6.0);
Assert.True(last!.Value.conf > 0.5);
}
// 피드 외란이 없으면(평탄) 제안 억제(null)
[Fact]
public void Estimator_suppresses_when_no_excitation()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-6, recomputeEvery: 1);
(double, double, double)? last = (0, 0, 0);
for (int t = 0; t < 200; t++) last = est.Push(0.0, 0.0, 0.0, 1.0); // Δ 전부 0
Assert.Null(last);
}
}
STEP 8 — 검증 (반드시 실행하고 결과 첨부)
# 1) 빌드
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
# 2) 테스트 — WO-2까지 12 + WO-3 신규 2 = 14
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
# 3) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 4) 쓰기 불변식(FF 경로 0건)
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
# 5) config θ 무변경 불변식 — 엔진이 cfg.Theta*를 쓰기(대입)하지 않는지
grep -nE "cfg\.(ThetaUpSec|ThetaDnSec)\s*=" src/Infrastructure/Control/*.cs || echo "config theta 무변경 OK"
기대 결과:
| 항목 | 기대 |
|---|---|
| 빌드 | 0 Warning(s) 0 Error(s) |
| 테스트 | Passed! - Failed: 0, Passed: 14 |
| JS | JS OK |
| 쓰기 | WRITE 0건 OK |
| config θ | config theta 무변경 OK |
런타임 확인(선택)
ff_column_config에theta_auto_tune=TRUE,steam_op_tag='tica-6111a.op',sensitive_tray_tag='ti-6111c',temp_tags='ti-6111b,ti-6111c,ti-6111d',dtdp=0.5설정.- 외란 충분히 누적(~1시간)된 뒤 카드에 "θ 제안 P ↑NNs ↓NNs conf 0.x" 표시. config θ는 그대로(제안만).
감독자 Sign-off
| 항목 | 상태 | 서명 |
|---|---|---|
| CrossCorrLagEstimator: 알려진 지연 식별 | ✅ | windpacer 2026-05-31 |
| 외란 부족/저신뢰 시 null 억제 | ✅ | windpacer 2026-05-31 |
| 부분상관으로 스팀 제거(폐루프 오염 회피) | ✅ | windpacer 2026-05-31 |
| SteamOpTag을 .pv 강제 없이 SampleExact로 읽음 | ✅ | windpacer 2026-05-31 |
| config θ 무변경(제안 전용) | ✅ | windpacer 2026-05-31 |
| ThetaAutoTune=false면 완전 무동작(옵트인) | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 14/14 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
주의(약한 LLM이 흔히 깨먹는 지점)
- config θ에 대입 금지 —
cfg.ThetaUpSec = ...같은 코드 절대 금지.StreamAdvisory.ThetaSuggest*(제안)에만 쓴다. - SteamOpTag은 .op —
Sample()(=.pv 강제) 쓰지 말고SampleExact()로. 실측 태그 접미사 확인. - WO-2 선행 필수 —
BuildTemps/PvSnapshot.Temps/ColumnState.PRef*가 없으면 앵커가 안 맞는다. WO-2 먼저. - positional record 금지 —
PvSnapshot.SteamOp는 init 프로퍼티로(생성자 인자 추가 금지). 생성은new PvSnapshot(...) { Temps=.., SteamOp=.. }. - 테스트는 estimator를 직접 호출(엔진 경유 X) — Δ를 직접 Push. recomputeEvery=1로 즉시 계산.
- 첫 제안까지 시간 — maxLag*2 샘플 누적 전엔 null(정상). 실운전 ~1시간. 조급해하지 말 것.