Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md
windpacer 7c26aa7361 feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료
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
2026-05-31 20:30:06 +09:00

16 KiB
Raw Blame History

WO-2 (P-2 PCT/차온 모니터) — 완전코드 작업지시서

대상 실행자: 본 LLM보다 능력이 낮은 LLM. 추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙한다. 선행 완료 전제(검증됨): §0(모델·DDL·ConfigStore·Controller 공통확장)과 WO-1(P-5)은 이미 머지됨. 즉 ColumnConfig.TempTags/SensitiveTrayTag/DTdP/PRef, PvSnapshot.Temps, AdvisoryResult.Temps, TempPoint, ff_column_config.temp_tags/dtdp/p_ref/sensitive_tray_tag 컬럼은 이미 존재한다(다시 만들지 말 것). 불변식: 본 WO는 advisory(모니터) — 제어 레지스터 쓰기 0건. PCT는 표시·WO-5 입력일 뿐 권장SP에 영향 없음.

목적

죽은 코드 TempCorrection.PressureCompensated엔진에 배선하고, 컬럼 온도 프로파일을 압력보정온도(PCT) 로 산출해 AdvisoryResult.Temps에 담아 대시보드에 표시한다. 진공노이즈(≈0.5°C/torr, spec §14.1) 제거. DiffTemp 블록도 추가(WO-5에서 소비).

변경 파일 (총 6개 — 전부 기존 파일 수정, 신규 파일 1개=테스트)

  1. src/Infrastructure/Control/ComputationBlocks.csDiffTemp 추가
  2. src/Infrastructure/Control/FeedforwardEngine.csColumnState 필드 + BuildTemps + Tick 배선
  3. src/Infrastructure/Control/FeedforwardSupervisor.csBuildSnapshotAsync에 온도 읽기
  4. src/Web/Controllers/FeedforwardController.csMapColumntemps 노출(NaN→null)
  5. src/Web/wwwroot/js/ff.js — 카드에 온도행
  6. src/Web/wwwroot/css/ff.css — 온도행 스타일
  7. tests/ExperionCrawler.Tests/FeedforwardTempTests.cs신규 테스트

STEP 1 — ComputationBlocks.cs : DiffTemp 추가

파일: src/Infrastructure/Control/ComputationBlocks.cs

찾기 (파일 맨 끝의 TempCorrection 클래스 전체):

public static class TempCorrection
{
    public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
        => tMeas - dTdP * (p - pRef);
}

바꾸기 (그 뒤에 DiffTemp 추가 — TempCorrection은 그대로 두고 아래 블록을 이어붙임):

public static class TempCorrection
{
    public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
        => tMeas - dTdP * (p - pRef);
}

/// <summary>차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3.
/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator).</summary>
public static class DiffTemp
{
    /// <summary>두 트레이 차온 (상단 - 하단).</summary>
    public static double Delta(double tHi, double tLo) => tHi - tLo;

    /// <summary>이중차온(곡률) — 프론트 위치 민감.</summary>
    public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC);
}

STEP 2 — FeedforwardEngine.cs : 상태필드 + PCT 산출 배선

파일: src/Infrastructure/Control/FeedforwardEngine.cs

2.1 ColumnState에 PRef 시드 상태 추가

찾기:

    public double        SettleTimerSec { get; set; }
    public bool          Initialized { get; set; }
    public Dictionary<string, StreamState> Streams { get; } = new();

바꾸기:

    public double        SettleTimerSec { get; set; }
    public bool          Initialized { get; set; }
    // WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드)
    public bool          PRefSeeded { get; set; }
    public double        PRefValue  { get; set; } = double.NaN;
    public Dictionary<string, StreamState> Streams { get; } = new();

2.2 Tick 말미에서 PCT 산출 → AdvisoryResult.Temps

찾기 (Tick 메서드의 마지막 return):

        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
            transient, treason, ff, outs, vloss, yield, mbState);
    }

바꾸기:

        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 };
    }

    // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ───────────
    private static IReadOnlyList<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st)
    {
        if (pv.Temps is null || pv.Temps.Count == 0) return null;

        bool   havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value);
        double pNow      = havePress ? pv.Pressure!.Value : double.NaN;

        // 기준 압력: cfg.PRef 우선, NaN이면 최초 정상압력으로 시드(컬럼상태에 보존)
        double pRef = cfg.PRef;
        if (double.IsNaN(pRef))
        {
            if (!st.PRefSeeded && havePress) { st.PRefValue = pNow; st.PRefSeeded = true; }
            pRef = st.PRefSeeded ? st.PRefValue : double.NaN;
        }

        var list = new List<TempPoint>(pv.Temps.Count);
        foreach (var t in pv.Temps)
        {
            bool   good = t.Good && Num.IsFinite(t.Value);
            double raw  = good ? t.Value : double.NaN;
            double pct  = raw;
            // dTdP==0(생온도) 또는 압력/기준 불가 시 PCT=raw(보정 안 함)
            if (good && cfg.DTdP != 0.0 && havePress && Num.IsFinite(pRef))
                pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP);
            list.Add(new TempPoint(t.Tag, raw, pct, good));
        }
        return list;
    }

Hold(FEED BAD) 경로는 Temps=null 유지(컬럼 정지 상황이라 모니터 생략). 의도적 단순화.


STEP 3 — FeedforwardSupervisor.cs : 온도 PV 읽기

파일: src/Infrastructure/Control/FeedforwardSupervisor.cs

3.1 읽을 태그 목록에 TempTags 추가

⚠️ 현재 파일엔 LevelTags 줄과 FlowTag 줄 사이에 스트림 LevelTag 줄이 끼어 있다. 그래서 아래는 단일 줄(FlowTag) 앵커로 잡는다(유일).

찾기:

        tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));

바꾸기:

        tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
        tags.AddRange(cfg.TempTags.Select(PvTag));   // WO-2 온도 프로파일

3.2 PvSnapshot에 Temps 채우기

찾기:

        var levels = cfg.LevelTags.Select(Sample).ToList();
        var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
        return new PvSnapshot(feed, press, levels, streams);

바꾸기:

        var levels = cfg.LevelTags.Select(Sample).ToList();
        var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
        var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
        return new PvSnapshot(feed, press, levels, streams) { Temps = temps };

Sample(baseTag).pv 부착·소문자·신선도(StaleSec) 판정을 이미 수행한다(기존 헬퍼 재사용). TempPoint.Tag에는 .pv 부착된 소문자 태그가 들어간다.


STEP 4 — FeedforwardController.cs : MapColumn에 temps 노출

파일: src/Web/Controllers/FeedforwardController.cs

찾기:

        frontPositionState = r.FrontPositionState,
        frontTrimAdvice  = r.FrontTrimAdvice,
        streams          = r.Streams.Select(s => new

바꾸기 (NaN→null 변환은 검증된 코드베이스의 camelCase/NaN 규칙):

        frontPositionState = r.FrontPositionState,
        frontTrimAdvice  = r.FrontTrimAdvice,
        temps            = r.Temps?.Select(t => new
        {
            tag  = t.Tag,
            raw  = double.IsNaN(t.Raw) ? (double?)null : t.Raw,
            pct  = double.IsNaN(t.Pct) ? (double?)null : t.Pct,
            good = t.Good
        }),
        streams          = r.Streams.Select(s => new

이유: System.Text.Json 기본 설정은 NaN 직렬화 시 예외. 기존 pv = double.IsNaN(...) ? null : ... 패턴과 동일하게 raw/pct를 가드한다.


STEP 5 — ff.js : 카드에 온도행

파일: src/Web/wwwroot/js/ff.js

찾기 (ffCard 함수의 mb 구성 ~ return):

  const mb = `물질수지: ${esc(c.massBalanceState)}` +
    (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
    (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
  return `

바꾸기:

  const mb = `물질수지: ${esc(c.massBalanceState)}` +
    (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
    (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
  const temps = (c.temps && c.temps.length)
    ? `<div class="ff-temps">${c.temps.map(t => `<span class="ff-temp${t.good?'':' ff-stale'}">${esc(t.tag)} ${t.raw==null?'':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` <small>PCT ${fmtVal(t.pct)}</small>`:''}</span>`).join(' · ')}</div>`
    : '';
  return `

찾기 (카드 return 내 mb div + 그 아래 note div — 현재 파일에는 mb가 ${esc(mb)}이고 바로 아래 ff-note 줄이 있다):

    <div class="ff-mb">${esc(mb)}</div>
    <div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고  인가는 운전원.</div>

바꾸기 (mb와 note 사이에 ${temps} 삽입):

    <div class="ff-mb">${esc(mb)}</div>
    ${temps}
    <div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고  인가는 운전원.</div>

STEP 6 — ff.css : 온도행 스타일

파일: src/Web/wwwroot/css/ff.css

파일 맨 끝에 추가:

/* WO-2 온도 프로파일(PCT) 모니터 행 */
.ff-temps{font-size:12px;color:var(--t2);margin-top:6px;display:flex;flex-wrap:wrap;gap:4px 10px}
.ff-temp{white-space:nowrap}
.ff-temp small{color:#7fd1ff}
.ff-temp.ff-stale{opacity:.45}

STEP 7 — 신규 테스트 FeedforwardTempTests.cs

신규 파일: tests/ExperionCrawler.Tests/FeedforwardTempTests.cs

using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;

namespace ExperionCrawler.Tests;

public class FeedforwardTempTests
{
    // ── 순수 블록 ────────────────────────────────────────────────
    [Fact]
    public void TempCorrection_compensates_pressure()
    {
        // P가 기준보다 높으면(진공 약화) PCT는 raw보다 낮아짐(dTdP>0)
        Assert.Equal(99.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.5), 6);
        // dTdP=0 → 보정 없음
        Assert.Equal(100.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.0), 6);
    }

    [Fact]
    public void DiffTemp_delta_and_double()
    {
        Assert.Equal(2.0, DiffTemp.Delta(81, 79), 6);
        Assert.Equal(0.0, DiffTemp.Double(82, 81, 80), 6);   // 등간격 → 곡률 0
        Assert.Equal(1.0, DiffTemp.Double(83, 81, 80), 6);   // (83-81)-(81-80)=1
    }

    // ── 엔진 배선 ────────────────────────────────────────────────
    private static ColumnConfig Cfg(double dtdp, double pRef) => new()
    {
        Id = 1, Name = "C-TEMP", Enabled = true, FeedTag = "f", ProductKey = "P",
        ScanSec = 2, DTdP = dtdp, PRef = pRef, PressureTag = "p",
        TempTags = new[] { "t1" },
        Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 } }
    };

    private static PvSnapshot Snap(double pressure, double temp) => new(
        new TagSample("f", 100, true, DateTime.UtcNow),
        new TagSample("p", pressure, true, DateTime.UtcNow),
        Array.Empty<TagSample>(),
        new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow)})
        { Temps = new[] { new TagSample("t1", temp, true, DateTime.UtcNow) } };

    [Fact]
    public void Engine_populates_pct_with_explicit_pref()
    {
        var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.5, pRef: 50), Snap(pressure: 52, temp: 100),
                                               new ColumnState(), DateTime.UtcNow);
        Assert.NotNull(res.Temps);
        var tp = res.Temps![0];
        Assert.Equal("t1", tp.Tag);
        Assert.Equal(100.0, tp.Raw, 6);
        Assert.Equal(99.0, tp.Pct, 6);   // 100 - 0.5*(52-50)
    }

    [Fact]
    public void Engine_seeds_pref_on_first_tick_when_nan()
    {
        var engine = new FeedforwardEngine();
        var st = new ColumnState();
        // tick1: pRef 미지정(NaN) → 첫 압력 50으로 시드 → PCT=raw(차이 0)
        var r1 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 50, temp: 100), st, DateTime.UtcNow);
        Assert.Equal(100.0, r1.Temps![0].Pct, 6);
        // tick2: 압력 54로 변동 → PCT = 100 - 0.5*(54-50) = 98
        var r2 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 54, temp: 100), st, DateTime.UtcNow);
        Assert.Equal(98.0, r2.Temps![0].Pct, 6);
    }

    [Fact]
    public void Engine_no_pct_when_dtdp_zero()
    {
        var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.0, pRef: 50), Snap(pressure: 80, temp: 100),
                                               new ColumnState(), DateTime.UtcNow);
        Assert.Equal(100.0, res.Temps![0].Pct, 6);   // 생온도 = raw
    }
}

STEP 8 — 검증 (반드시 실행하고 결과를 보고서에 첨부)

# 1) C# 빌드 — 경고0/에러0 이어야 함
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"

# 2) 테스트 — 기존 7 + 신규 5 = 12 통과 이어야 함
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"

기대 결과:

항목 기대
빌드 Build succeeded. 0 Warning(s) 0 Error(s)
테스트 Passed! - Failed: 0, Passed: 12
JS JS OK
쓰기 WRITE 0건 OK

런타임 확인(선택)

  • ff_column_configtemp_tags='ti-6111b,ti-6111c,ti-6111d', dtdp=0.5, p_ref=NULL(시드) 또는 실측값 설정.
  • Tab 18 진입 → 카드 하단에 ti-6111b ... PCT ... 행 표시. 진공(pica-6111) 흔들 때 raw는 출렁이나 PCT는 평탄(공통모드 상쇄).

감독자 Sign-off (검수 후 서명)

항목 상태 서명
DiffTemp 블록 + 단위테스트 windpacer 2026-05-31
엔진 BuildTemps 배선 (cfg.PRef 우선 / NaN 시드) windpacer 2026-05-31
dTdP=0 → PCT=raw (생온도 패스스루) windpacer 2026-05-31
Supervisor TempTags 읽기 + PvSnapshot.Temps windpacer 2026-05-31
Controller temps 노출 (NaN→null) windpacer 2026-05-31
ff.js 온도행 + node -c 통과 windpacer 2026-05-31
빌드 0/0 · 테스트 12/12 · 쓰기 0건 windpacer 2026-05-31

주의(약한 LLM이 흔히 깨먹는 지점)

  1. §0를 다시 만들지 말 것TempTags/PRef/Temps/TempPoint·DDL 컬럼은 이미 존재. 중복 추가 시 빌드 깨짐.
  2. positional record에 새 필드 추가 금지AdvisoryResult.Temps·PvSnapshot.Temps는 이미 init 프로퍼티. 생성은 new (...) { Temps = ... } 형태(이미 §0에서 추가됨).
  3. NaN을 그대로 JSON에 넣지 말 것 — Controller에서 raw/pct는 double.IsNaN(..) ? null : ...
  4. Sample() 재사용.pv 부착·소문자·신선도 판정이 이미 들어있으니 온도태그도 동일 헬퍼로.
  5. 테스트의 Snap{ Temps = ... }로 PvSnapshot 생성 — 엔진은 pv.Temps를 읽지 태그를 읽지 않는다(태그→PV는 Supervisor 책임).