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
613 lines
26 KiB
Markdown
613 lines
26 KiB
Markdown
# WO-6 (전환류 Total Reflux 평형복귀 모드) — 완전코드 작업지시서 ★
|
|
|
|
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
|
|
> **선행 완료 전제(필수)**: §0 + WO-1 + WO-2 + WO-3 + WO-4 + WO-5 머지 완료. 특히 **WO-4(VLossMa)·WO-5(FrontPositionState)** 가
|
|
> 트리거 입력이므로 반드시 선행. `ColumnMode`, `ColumnConfig`의 recovery 필드들, `AdvisoryResult.Mode/ModeReason`(§0)은 **이미 존재**.
|
|
> **불변식(매우 중요)**: 본 WO도 **제어 레지스터 쓰기 0건**. 전환류는 "권장 SP 오버라이드 + 모드 표시 + 운전원 ARM"까지만.
|
|
> **실제 SP 쓰기(F·P·D·B 차단, R 전량환류)는 전부 PhaseIII(WriteGuard) 경유.** 여기서 SP를 직접 쓰면 불변식 위반.
|
|
|
|
## 목적
|
|
컬럼 균형이 **심각히 붕괴**하면(다신호 트리거) **전환류 모드**를 권장: FEED·P·D·B 권장SP=0(또는 RecoverySp), R=전량환류(SpMax),
|
|
평형 회복까지 dwell 후 **램프 복귀**. 근거 `knowledge/PGMEA_측류추출운전방식_주의점.md §4.3`("측류 먼저 중단→환류↑ 재안정화→재개").
|
|
|
|
## 상태기계 (`AdvisoryResult.Mode`)
|
|
```
|
|
Normal ──(severe 지속 ImbalanceTriggerSec + !transient + (AutoArm||운전원ARM))──▶ Recovering
|
|
Recovering ──(평형 회복 RecoverySettleSec 연속)──▶ Returning ──(ReturnRampSec 경과)──▶ Normal
|
|
(어느 상태든 운전원 cancel → Normal)
|
|
```
|
|
**severe 다신호 트리거(OR, 가용 신호만)**: ① `|VLossMa|/F > ImbalanceTriggerFrac`(WO-4) ② WO-5 프론트 "상승/하강" 드리프트 ③ `ΔP > DeltaPFloodLimit`.
|
|
|
|
## 변경 파일 (총 8개)
|
|
1. `src/Core/Application/Feedforward/FeedforwardModels.cs` — `AdvisoryResult.FeedRecommendedSp`, `PvSnapshot.DeltaP`
|
|
2. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` 모드 타이머 + `ApplyRecovery` + Tick 배선
|
|
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — ΔP 읽기 + **ARM/Cancel API**(ColumnState 접근)
|
|
4. `src/Web/Program.cs` — Supervisor를 singleton+hosted로 (컨트롤러 주입용)
|
|
5. `src/Web/Controllers/FeedforwardController.cs` — `recovery/{id}/arm`·`cancel` + MapColumn에 `feedRecommendedSp`
|
|
6. `src/Web/wwwroot/js/ff.js` — 모드 뱃지 + ARM/취소 버튼
|
|
7. `src/Web/wwwroot/css/ff.css` — 모드 뱃지 스타일
|
|
8. `tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs` — **신규** 테스트
|
|
|
|
---
|
|
|
|
## STEP 1 — `FeedforwardModels.cs`
|
|
|
|
### 1.1 AdvisoryResult.FeedRecommendedSp 추가
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public string? FrontPositionState { get; init; }
|
|
public string? FrontTrimAdvice { get; init; }
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public string? FrontPositionState { get; init; }
|
|
public string? FrontTrimAdvice { get; init; }
|
|
public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
|
|
}
|
|
```
|
|
|
|
### 1.2 PvSnapshot.DeltaP 추가
|
|
|
|
> 전제: WO-3에서 `SteamOp`가 추가됨.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public IReadOnlyList<TagSample>? Temps { get; init; }
|
|
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public IReadOnlyList<TagSample>? Temps { get; init; }
|
|
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
|
|
public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 2 — `FeedforwardEngine.cs`
|
|
|
|
### 2.1 `ColumnState`에 모드/타이머/ARM 추가
|
|
|
|
> 전제: WO-5에서 `FrontInd`가 추가됨.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
|
|
// WO-6 전환류 상태기계
|
|
public ColumnMode Mode { get; set; } = ColumnMode.Normal;
|
|
public double ImbalanceTimerSec { get; set; }
|
|
public double RecoverySettleTimerSec { get; set; }
|
|
public double ReturnTimerSec { get; set; }
|
|
public bool OperatorArmed { get; set; } // 컨트롤러가 set
|
|
public bool OperatorCancel { get; set; } // 컨트롤러가 set(즉시 Normal)
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
### 2.2 Tick 배선 — return 직전, 프론트 다음
|
|
|
|
> 전제: WO-5 이후 return 영역.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
|
transient, treason, ff, outs, vloss, yield, mbState)
|
|
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim };
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
|
var (mode, modeReason, feedRecSp) = ApplyRecovery(
|
|
cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀
|
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
|
transient, treason, ff, outs, vloss, yield, mbState)
|
|
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
|
|
Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp };
|
|
```
|
|
|
|
### 2.3 `ApplyRecovery` 메서드 추가 (ApplyFront 바로 뒤)
|
|
|
|
> 전제: WO-5가 추가한 `ApplyFront`는 `return (state, trim);` + `}` 로 끝난다.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
|
|
return (state, trim);
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
|
|
return (state, trim);
|
|
}
|
|
|
|
// ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ──
|
|
private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
|
|
ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,
|
|
double? vLossMa, string? frontState, bool transient, ref List<StreamAdvisory> outs)
|
|
{
|
|
// 기능 off → 항상 Normal(상태 리셋)
|
|
if (!cfg.RecoveryEnabled)
|
|
{
|
|
st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.OperatorArmed = false; st.OperatorCancel = false;
|
|
return (ColumnMode.Normal, null, null);
|
|
}
|
|
// 운전원 수동 취소 → 즉시 Normal
|
|
if (st.OperatorCancel)
|
|
{
|
|
st.OperatorCancel = false; st.OperatorArmed = false;
|
|
st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.RecoverySettleTimerSec = 0; st.ReturnTimerSec = 0;
|
|
return (ColumnMode.Normal, "운전원 취소", null);
|
|
}
|
|
|
|
// 다신호 severe 판정 (가용 신호만 OR)
|
|
double frac = (vLossMa.HasValue && ff > 1e-6) ? Math.Abs(vLossMa.Value) / ff : 0.0;
|
|
bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac;
|
|
bool sigFront = frontState is not null && (frontState.Contains("상승") || frontState.Contains("하강"));
|
|
bool sigDp = pv.DeltaP is { Good: true } dp && Num.IsFinite(dp.Value) && dp.Value > cfg.DeltaPFloodLimit;
|
|
bool severe = sigVloss || sigFront || sigDp;
|
|
|
|
string SeverityText() =>
|
|
(sigVloss ? $"물질수지({frac:P0}) " : "") + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩" : "");
|
|
|
|
switch (st.Mode)
|
|
{
|
|
case ColumnMode.Normal:
|
|
if (!transient && severe) st.ImbalanceTimerSec += ts; else st.ImbalanceTimerSec = 0;
|
|
bool armed = cfg.RecoveryAutoArm || st.OperatorArmed;
|
|
if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec && armed)
|
|
{
|
|
st.Mode = ColumnMode.Recovering; st.OperatorArmed = false;
|
|
st.RecoverySettleTimerSec = 0;
|
|
return (ColumnMode.Recovering, $"전환류 진입: {SeverityText()}", OverrideRecovering(cfg, ref outs));
|
|
}
|
|
// ARM 대기 표시(자동무장 아님 + 임계 지속)
|
|
if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec)
|
|
return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null);
|
|
return (ColumnMode.Normal, null, null);
|
|
|
|
case ColumnMode.Recovering:
|
|
{
|
|
var feedRec = OverrideRecovering(cfg, ref outs);
|
|
// 평형 회복: severe 해제 + frac < Frac*0.5 연속
|
|
bool recovered = !severe && frac < cfg.ImbalanceTriggerFrac * 0.5;
|
|
if (recovered) st.RecoverySettleTimerSec += ts; else st.RecoverySettleTimerSec = 0;
|
|
if (st.RecoverySettleTimerSec >= cfg.RecoverySettleSec)
|
|
{
|
|
st.Mode = ColumnMode.Returning; st.ReturnTimerSec = 0;
|
|
return (ColumnMode.Returning, "평형 회복 — 복귀 램프 시작", null);
|
|
}
|
|
return (ColumnMode.Recovering, $"전환류 평형대기 {st.RecoverySettleTimerSec:F0}/{cfg.RecoverySettleSec:F0}s", feedRec);
|
|
}
|
|
|
|
case ColumnMode.Returning:
|
|
st.ReturnTimerSec += ts;
|
|
if (st.ReturnTimerSec >= cfg.ReturnRampSec)
|
|
{
|
|
st.Mode = ColumnMode.Normal;
|
|
return (ColumnMode.Normal, "복귀 완료", null);
|
|
}
|
|
// 램프 중엔 정상 권장값 그대로(RateLimiter가 자연 램프) + FEED는 정상 복원 표시(null)
|
|
return (ColumnMode.Returning, $"복귀 램프 {st.ReturnTimerSec:F0}/{cfg.ReturnRampSec:F0}s", null);
|
|
|
|
default:
|
|
st.Mode = ColumnMode.Normal;
|
|
return (ColumnMode.Normal, null, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Recovering 권장값 오버라이드: reflux=SpMax(전량), draw(P/D/B)=RecoverySp(NaN→0). FEED 권장 반환.</summary>
|
|
private static double? OverrideRecovering(ColumnConfig cfg, ref List<StreamAdvisory> outs)
|
|
{
|
|
outs = outs.Select(a =>
|
|
{
|
|
// reflux 스트림 식별: IsReflux 또는 RefluxFromProduct
|
|
var sc = cfg.Streams.FirstOrDefault(x => x.Key == a.Key);
|
|
bool isReflux = sc is not null && (sc.IsReflux || sc.RefluxFromProduct);
|
|
double? ov;
|
|
if (isReflux) ov = sc!.SpMax; // 전량 환류
|
|
else if (a.Role == StreamRole.Monitor) ov = a.RecommendedSp; // 모니터는 그대로
|
|
else ov = (sc is not null && !double.IsNaN(sc.RecoverySp)) ? sc.RecoverySp : 0.0; // draw 차단
|
|
return a with { RecommendedSp = ov, Valid = false, Note = "전환류 복귀 — 운전원 인가 필요" };
|
|
}).ToList();
|
|
return cfg.FeedRecoverySp; // FEED 권장(기본 0=차단)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 3 — `FeedforwardSupervisor.cs`
|
|
|
|
### 3.1 ΔP 읽기
|
|
|
|
**찾기**:
|
|
```csharp
|
|
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
|
|
if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv)
|
|
```
|
|
|
|
### 3.2 PvSnapshot에 DeltaP
|
|
|
|
> 전제: WO-3에서 return이 `{ Temps = temps, SteamOp = steam }` 형태다.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
|
|
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
|
|
var deltaP = cfg.DeltaPTag is not null ? Sample(cfg.DeltaPTag) : null;
|
|
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam, DeltaP = deltaP };
|
|
```
|
|
|
|
### 3.3 ARM/Cancel 공개 메서드 (클래스 맨 끝, ExecuteAsync 등과 같은 레벨)
|
|
|
|
> 전제: `_states`는 `private readonly Dictionary<int, ColumnState> _states`. `GetState`는 이미 있다.
|
|
|
|
**찾기** (파일에서 `GetState` 메서드 전체):
|
|
```csharp
|
|
private ColumnState GetState(int id)
|
|
{
|
|
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
|
|
return s;
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
private ColumnState GetState(int id)
|
|
{
|
|
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
|
|
return s;
|
|
}
|
|
|
|
// WO-6: 운전원 ARM/취소 (모드 판정용 플래그만 — 쓰기 아님). 다음 Tick에서 소비.
|
|
public bool Arm(int columnId) { lock (_states) { GetState(columnId).OperatorArmed = true; } return true; }
|
|
public bool Cancel(int columnId) { lock (_states) { GetState(columnId).OperatorCancel = true; } return true; }
|
|
```
|
|
> **동시성**: `_states`는 평소 Tick 루프(단일 스레드) 소유지만 ARM/Cancel은 HTTP 스레드에서 set한다. bool 단일 대입이라 사실상 안전하나 명시적 `lock`으로 보호. Tick 측 읽기는 다음 주기에 자연 반영(즉시성 불필요).
|
|
|
|
---
|
|
|
|
## STEP 4 — `Program.cs` : Supervisor를 singleton+hosted로
|
|
|
|
**파일**: `src/Web/Program.cs`
|
|
|
|
**찾기**:
|
|
```csharp
|
|
builder.Services.AddHostedService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
|
|
```
|
|
> 단일 인스턴스를 hosted(백그라운드)+injectable(컨트롤러)로 동시 노출. 인스턴스는 **1개만** 가동(틱 루프 1회).
|
|
|
|
---
|
|
|
|
## STEP 5 — `FeedforwardController.cs`
|
|
|
|
### 5.1 Supervisor 주입
|
|
|
|
**찾기**:
|
|
```csharp
|
|
private readonly IFeedforwardAdvisoryStore _store;
|
|
private readonly IFeedforwardConfigStore _config;
|
|
public FeedforwardController(
|
|
IFeedforwardAdvisoryStore store,
|
|
IFeedforwardConfigStore config)
|
|
{ _store = store; _config = config; }
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
private readonly IFeedforwardAdvisoryStore _store;
|
|
private readonly IFeedforwardConfigStore _config;
|
|
private readonly ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor _supervisor;
|
|
public FeedforwardController(
|
|
IFeedforwardAdvisoryStore store,
|
|
IFeedforwardConfigStore config,
|
|
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor)
|
|
{ _store = store; _config = config; _supervisor = supervisor; }
|
|
```
|
|
|
|
### 5.2 ARM/Cancel 엔드포인트 (DeleteConfig 메서드 다음)
|
|
|
|
**찾기**:
|
|
```csharp
|
|
[HttpDelete("config/{id:int}")]
|
|
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
|
{
|
|
await _config.DeleteColumnAsync(id, ct);
|
|
return Ok(new { success = true });
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
[HttpDelete("config/{id:int}")]
|
|
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
|
{
|
|
await _config.DeleteColumnAsync(id, ct);
|
|
return Ok(new { success = true });
|
|
}
|
|
|
|
// ── WO-6 전환류 ARM/취소 (쓰기 아님 — 모드 판정 플래그) ──
|
|
[HttpPost("recovery/{id:int}/arm")]
|
|
public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });
|
|
|
|
[HttpPost("recovery/{id:int}/cancel")]
|
|
public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });
|
|
```
|
|
|
|
### 5.3 MapColumn에 feedRecommendedSp 노출
|
|
|
|
**찾기**:
|
|
```csharp
|
|
mode = r.Mode.ToString(),
|
|
modeReason = r.ModeReason,
|
|
vLossMa = r.VLossMa,
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
mode = r.Mode.ToString(),
|
|
modeReason = r.ModeReason,
|
|
feedRecommendedSp = r.FeedRecommendedSp,
|
|
vLossMa = r.VLossMa,
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 6 — `ff.js` : 모드 뱃지 + ARM/취소
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
|
|
### 6.1 모드 뱃지/버튼 const (front const 다음, return 직전)
|
|
|
|
> 전제: WO-5가 `const front = ...` 를 추가했다.
|
|
|
|
**찾기**:
|
|
```javascript
|
|
const front = c.frontPositionState
|
|
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
|
|
: '';
|
|
return `
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
const front = c.frontPositionState
|
|
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
|
|
: '';
|
|
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
|
|
const modeBadge =
|
|
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
|
|
: c.mode === 'Returning' ? '<span class="ff-mode ff-mode-ret">복귀 램프 ●</span>'
|
|
: armWait ? '<span class="ff-mode ff-mode-arm">전환류 권장 ⚠</span>'
|
|
: '';
|
|
const recoveryCtl =
|
|
armWait ? `<button class="btn sm danger" onclick="ffArm(${c.columnId})">전환류 ARM</button>`
|
|
: (c.mode==='Recovering'||c.mode==='Returning') ? `<button class="btn sm" onclick="ffCancelRecovery(${c.columnId})">취소(정상복귀)</button>`
|
|
: '';
|
|
const modeLine = (modeBadge || c.modeReason)
|
|
? `<div class="ff-modeline">${modeBadge} <small>${esc(c.modeReason||'')}</small> ${recoveryCtl}</div>` : '';
|
|
return `
|
|
```
|
|
|
|
### 6.2 카드 헤더에 ${modeLine} 삽입
|
|
|
|
**찾기**:
|
|
```javascript
|
|
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
|
${banner}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
|
${modeLine}
|
|
${banner}
|
|
```
|
|
|
|
### 6.3 ARM/Cancel 호출 함수 (ffCard 함수 바로 위 또는 파일 끝에 추가)
|
|
|
|
**찾기**:
|
|
```javascript
|
|
function ffCard(c) {
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
function ffArm(id) {
|
|
if (!confirm(`컬럼 ${id} 전환류 모드를 ARM(가동) 하시겠습니까?\n드로우 중단·전량 환류가 권장됩니다(실제 쓰기는 별도 인가).`)) return;
|
|
ffApi('POST', `/api/ff/recovery/${id}/arm`).then(()=>ffLoadDash()).catch(()=>{});
|
|
}
|
|
function ffCancelRecovery(id) {
|
|
ffApi('POST', `/api/ff/recovery/${id}/cancel`).then(()=>ffLoadDash()).catch(()=>{});
|
|
}
|
|
function ffCard(c) {
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 7 — `ff.css`
|
|
|
|
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
|
|
```css
|
|
/* WO-6 전환류 모드 */
|
|
.ff-modeline{margin:4px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
.ff-mode{font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px}
|
|
.ff-mode-rec{background:#5a3000;color:#ffb74d}
|
|
.ff-mode-ret{background:#003a4d;color:#7fd1ff}
|
|
.ff-mode-arm{background:#5a0000;color:#ff8a80;animation:ffblink 1s step-start infinite}
|
|
@keyframes ffblink{50%{opacity:.4}}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 8 — 신규 테스트 `FeedforwardRecoveryTests.cs`
|
|
|
|
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs`
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using ExperionCrawler.Core.Application.Feedforward;
|
|
using ExperionCrawler.Infrastructure.Control;
|
|
using Xunit;
|
|
|
|
namespace ExperionCrawler.Tests;
|
|
|
|
public class FeedforwardRecoveryTests
|
|
{
|
|
// VLossMa 트리거가 빨리 잡히도록 작은 창/짧은 타이머
|
|
private static ColumnConfig Cfg(bool autoArm) => new()
|
|
{
|
|
Id = 1, Name = "C-REC", Enabled = true, FeedTag = "f", ProductKey = "P",
|
|
ScanSec = 2, BiasMaWindowSec = 4,
|
|
RecoveryEnabled = true, RecoveryAutoArm = autoArm,
|
|
ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4, // 2틱
|
|
RecoverySettleSec = 4, ReturnRampSec = 4, FeedRecoverySp = 0,
|
|
Streams = new[]
|
|
{
|
|
new StreamConfig { Key="P", FlowTag="p", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.95, SpMax=950 },
|
|
new StreamConfig { Key="R", FlowTag="r", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.80, SpMax=1100, RefluxFromProduct=true },
|
|
new StreamConfig { Key="D", FlowTag="d", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.02, SpMax=60 },
|
|
new StreamConfig { Key="B", FlowTag="b", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.03, SpMax=80 },
|
|
}
|
|
};
|
|
|
|
// 큰 V_loss(불균형): FEED 100인데 D+P+B 합이 작음 → vloss 큼
|
|
private static PvSnapshot Imbalanced() => new(
|
|
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
|
|
new Dictionary<string, TagSample> {
|
|
["P"]=new("p",30,true,DateTime.UtcNow), ["R"]=new("r",50,true,DateTime.UtcNow),
|
|
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); // vloss=100-35=65
|
|
|
|
private static PvSnapshot Balanced() => new(
|
|
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
|
|
new Dictionary<string, TagSample> {
|
|
["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow),
|
|
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); // vloss=0
|
|
|
|
[Fact]
|
|
public void AutoArm_enters_recovering_on_sustained_imbalance()
|
|
{
|
|
var engine = new FeedforwardEngine(); var st = new ColumnState();
|
|
AdvisoryResult res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
|
|
for (int i = 0; i < 6; i++) res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Recovering, res.Mode);
|
|
// 권장값 오버라이드: R(reflux)=SpMax, P/D/B=0, FEED=0
|
|
Assert.Equal(0.0, res.FeedRecommendedSp);
|
|
var r = res.Streams.First(s => s.Key == "R");
|
|
var p = res.Streams.First(s => s.Key == "P");
|
|
Assert.Equal(1100.0, r.RecommendedSp);
|
|
Assert.Equal(0.0, p.RecommendedSp);
|
|
Assert.False(p.Valid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ManualArm_required_when_autoArm_false()
|
|
{
|
|
var engine = new FeedforwardEngine(); var st = new ColumnState();
|
|
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Normal, st.Mode); // ARM 없으면 진입 안 함
|
|
st.OperatorArmed = true; // 운전원 ARM
|
|
var res = engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Recovering, res.Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public void Recovers_then_returns_to_normal()
|
|
{
|
|
var engine = new FeedforwardEngine(); var st = new ColumnState();
|
|
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Recovering, st.Mode);
|
|
// 균형 회복 입력 지속 → Returning → Normal
|
|
AdvisoryResult res = null!;
|
|
for (int i = 0; i < 10; i++) res = engine.Tick(Cfg(autoArm:true), Balanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Normal, res.Mode);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cancel_returns_to_normal_immediately()
|
|
{
|
|
var engine = new FeedforwardEngine(); var st = new ColumnState();
|
|
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Recovering, st.Mode);
|
|
st.OperatorCancel = true;
|
|
var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
|
|
Assert.Equal(ColumnMode.Normal, res.Mode);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 9 — 검증
|
|
|
|
```bash
|
|
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
|
|
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
|
|
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
|
|
# 쓰기 불변식 — 전환류도 advisory: FF 경로 쓰기 0건
|
|
grep -rnE "ExperionOpcWriteClient|Write.*Async|WriteTagAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
|
|
# Supervisor 단일 인스턴스 — AddHostedService<FeedforwardSupervisor>() 직접등록 없어야
|
|
grep -n "AddHostedService<.*FeedforwardSupervisor>" src/Web/Program.cs || echo "단일 인스턴스 OK"
|
|
```
|
|
|
|
**기대**: 빌드 0/0 · 테스트 **22/22**(WO-5까지 18 + 신규 4) · JS OK · 쓰기 0건 · 단일 인스턴스 OK.
|
|
|
|
### 런타임(선택)
|
|
- `recovery_enabled=TRUE`, `recovery_auto_arm=FALSE`, `imbalance_trigger_frac=0.1`, `imbalance_trigger_sec=600` 설정.
|
|
- 불균형 지속 → 카드에 "전환류 권장 ⚠ [전환류 ARM]" → 클릭 → "전환류 복귀중 ●", R=SpMax·P/D/B=0·FEED=0 권장 → 회복 후 "복귀 램프" → Normal.
|
|
|
|
---
|
|
|
|
## 감독자 Sign-off
|
|
| 항목 | 상태 | 서명 |
|
|
|:--|:--:|:--:|
|
|
| 다신호 트리거(VLossMa|프론트|ΔP) 지속+!transient | ✅ | windpacer 2026-05-31 |
|
|
| AutoArm=false면 운전원 ARM 없이 진입 안 함 | ✅ | windpacer 2026-05-31 |
|
|
| Recovering 오버라이드(R=SpMax, draw=0, FEED=0, Valid=false) | ✅ | windpacer 2026-05-31 |
|
|
| 회복→Returning→Normal 전이 | ✅ | windpacer 2026-05-31 |
|
|
| 운전원 cancel 즉시 Normal | ✅ | windpacer 2026-05-31 |
|
|
| **쓰기 0건**(전환류도 advisory — 실제 쓰기는 PhaseIII) | ✅ | windpacer 2026-05-31 |
|
|
| Supervisor 단일 인스턴스(틱 1회) | ✅ | windpacer 2026-05-31 |
|
|
| 빌드 0/0 · 테스트 22/22 | ✅ | windpacer 2026-05-31 |
|
|
|
|
## 주의(약한 LLM 함정) ★
|
|
1. **실제 SP 쓰기 절대 금지** — Recovering은 `StreamAdvisory.RecommendedSp` 숫자만 바꾼다(권장 표시). `ExperionOpcWriteClient` 호출 0건. 실제 차단/환류는 PhaseIII.
|
|
2. **트리거는 VLossMa(장기 MA)** — 순간 `vloss` 쓰지 말 것(오발동). WO-4 선행 필수.
|
|
3. **Supervisor DI** — STEP 4를 빼먹으면 컨트롤러 주입 실패(런타임 DI 예외). `AddHostedService<T>()` 직접등록은 제거.
|
|
4. **ARM/Cancel은 다음 Tick에 반영** — 즉시 모드 변경 아님(폴링으로 곧 보임). 정상.
|
|
5. positional record 인자추가 금지 — `FeedRecommendedSp`/`DeltaP`는 init 프로퍼티.
|
|
6. 테스트 타이머는 작게(ImbalanceTriggerSec=4=2틱) — 실 기본값(600s)으로 테스트하면 안 끝남.
|
|
</content>
|