# 작업지시서 — 측류SP쓰기-피드램프실행 (진단 재검토 v3)

> 대상: 구현 LLM / 개발자
> 작성 기준 코드: `src/Hc900Crawler`, `src/Infrastructure/Control`, `src/Core/Application/Feedforward`
> 전제: 이 저장소(hc900_ax)의 FF(Feedforward) 모듈 구조를 그대로 따른다.
> 진단일: 2026-06-04 → **v3 재검토: 진단 10건을 코드와 대조해 재분류(아래 "재검토 결과")**

---

## 진단 재검토 결과

원 진단(v2)은 코드와 대조 시 **실재 결함 1건 + 위험한 오처방 1건 + 사실오류/비이슈 2건 + "신규 구현을 결함으로 오분류" 6건**으로 정리된다. 아래 표가 정정본이며, 이후 본문은 이 분류를 따른다.

### (1) 실재 결함 — 반드시 수정

| # | 항목 | 정정 심각도 | 처리 |
|---|------|------------|------|
| D-3 | `WriteSp` L102 `"HC1"` 하드코딩. 실 컨트롤러 ID는 **C1~C4**이므로 `"HC1"`은 어느 설정에도 매칭 안 됨 → `GetClient`가 `"컨트롤러 HC1 설정 없음"` 예외 → **현재 측류 쓰기는 사실상 전부 실패 중일 개연성**. "멀티컨트롤러 미지원"이 아니라 "단일 쓰기조차 깨짐" | 🔴 HIGH | §5 A-1 |

### (2) 증상은 맞으나 처방이 위험 — 처방 교체

| # | 항목 | 판정 | 처리 |
|---|------|------|------|
| D-1 | `WriteGuard.Check`가 `AdvisoryOnly`면 버튼 쓰기도 차단 — 증상은 사실. **그러나 v2 처방("WriteGuard 우회, SpMin/Max·Commanded·SpNodeId만 검증")은 채택 불가**: `Valid`(stale/무효)·`Grade==C`(저신뢰)·`Transient`(과도상태) 가드를 통째로 제거 → 라이브 컬럼 수동쓰기에서 **가장 중요한 안전검증을 버리는 HIGH급 회귀**. 진짜 원인은 auto-write/manual-write가 `AdvisoryOnly` 한 플래그에 **결합**된 것 | 🔴 HIGH(오처방) | §3·§5 A-1 = **가드 분리(manualOverride)** |

### (3) 사실오류·비이슈 — 철회

| # | 항목 | 판정 |
|---|------|------|
| D-2 | "warning 4줄 하드코딩" → **사실오류**. 무조건 추가는 **2줄**(L49 steam ceiling, L73 energyLoop)뿐이고 나머지는 조건부. 게다가 이는 버그가 아니라 **미구현 제약을 표시하는 의도적 TODO 마커**. → **진단 철회**(원하면 선택적 정리만, §6 부기) |
| D-10 | "`ex.Message` 감사로그 → 비밀번호/토큰 누출" → **위협 부재**. 이 경로 예외는 Modbus/gRPC 전송오류(host:port)일 뿐, 토큰은 쓰기 호출 전 검증되고 gRPC로 전달되지 않음. 메시지를 가리면 **제어쓰기 실패 원인 추적성만 손상**. → **진단 철회** |

### (4) "신규 구현(스펙)"을 결함으로 오분류 — 진단 아님, 그냥 작업 항목

> 아래는 이 작업지시서가 "만들라고 지시한 것 그 자체"다. 아직 짓지 않은 집의 벽이 없다고 HIGH를 매긴 격 → **진단표에서 제외, 해당 작업 절로 이동.**

| # | 항목 | 실제 위치 |
|---|------|-----------|
| D-4 | `AutoWriteAsync` L141 `"HC1"` — 코드사실은 맞으나 **이 작업은 `AdvisoryOnly=true` 유지로 AutoWrite 미실행**(死경로). HIGH 아님, 일관성 후속정리 | §6 부기 |
| D-5 | `FeedSpNodeId` 등 DB 컬럼 없음 = **신규 필드 추가 지시**(§6 B-1) |  |
| D-6 | MapConfig 신규 필드 노출 = D-5의 귀결(§6 B-1) |  |
| D-7 | `ffCard` 쓰기 버튼 없음 = **작업 A 그 자체**(§5 A-2) |  |
| D-8 | 배지 "읽기 전용" 변경 = 작업 A 일부(§5 A-2) |  |
| D-9 | 램프 실행기/저장소/엔드포인트 없음 = **작업 B 전체**(§6) |  |

---

## 0. 목표 (한 줄)

1. **측류추출 권장 유량(Advisory)** 화면에서 **버튼 클릭 시에만** 해당 스트림의 권장 SP를 실제 HC900 컨트롤러 SP로 쓴다. (자동 쓰기 아님 — 운전원 명시적 인가)
2. **FEED 목표 변경 시** 단번에 점프시키지 않고, **램프 계산기 결과(rate·ceiling)에 따라 FEED SP를 천천히 단계적으로 올린다.** 버튼으로 시작/취소.

핵심 원칙: **두 기능 모두 "운전원이 버튼을 눌러야만" 실제 쓰기가 시작된다.** 백그라운드 자동쓰기(`AdvisoryOnly=false` 경로)는 이 작업에서 사용하지 않으며 기본값 그대로 둔다.

---

## 1. 현재 코드 자산 (재사용 — 새로 만들지 말 것)

### 1-A. 측류 SP 수동 쓰기 — **백엔드 이미 존재**

- `src/Hc900Crawler/Controllers/FeedforwardController.cs`
  - `POST api/ff/write/{columnId}/{streamKey}` → `WriteSp(...)` (L78~114)
  - 동작: `X-Kb-Token` 인증 → advisory/config/stream 조회 → `SpNodeId` 필수 검사 → `body.value ?? adv.RecommendedSp` → **`_writeGuard.Check(...)`** → `_writeClient.WriteTagAsync("HC1", sc.SpNodeId, spVal)` → 감사로그.
  - **요청 바디**: `WriteSpBody { double? value }` (L310). value 비우면 권장값 사용.
- `src/Infrastructure/Control/FeedforwardWriteGuard.cs` — `Check()`:
  - Commanded 스트림만 / `AdvisoryOnly`면 차단 / 권장SP 존재 / `Valid` / Grade≠C / `Transient` 아님 / `SpMin~SpMax` 범위 / NaN·Inf 아님.
- `src/Infrastructure/Hc900/Hc900WriteService.cs` — `WriteTagAsync(controllerId, tagName, value)` → gRPC `WriteTag`.
- 감사: `IFeedforwardAuditService.LogAsync(FfActionLogEntry(...))`, 조회 `GET api/ff/audit`.

> **따라서 측류 SP 쓰기는 "UI 버튼 + 소수 백엔드 보정"만 하면 된다. 새 엔드포인트 불필요.**

### 1-B. UI (현재 읽기 전용)

- `src/Hc900Crawler/wwwroot/panes/ff.html` — `<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>`, 대시보드 컨테이너 `#ff-dash`.
- `src/Hc900Crawler/wwwroot/js/ff.js`
  - `ffLoadDash()` (L145) → `GET /api/ff/advisory` → `ffCard(c)` 렌더.
  - `ffCard()` (L165) 스트림 행 렌더 (L166~182): `권장 SP` 칸은 `s.recommendedSp` 표시만, **버튼 없음**.
  - 인증 헬퍼: `ffApi(method, path, body)` (L9) — `X-Kb-Token` 자동 첨부, 401 처리. 읽기는 `api(...)` 사용.
  - 폴링: `ffInit()`에서 `setInterval(ffLoadDash, 3000)`.
- 람프 계산기 UI: `#ff-ramp` 패널 + `ffRampCompute()` (L42) → `GET /api/ff/ramp-advisor`. **계산 표시만, 실행 없음.**

### 1-C. 피드 램프 — **계산기만 존재, 실행기 없음**

- `src/Infrastructure/Control/FeedRampCalculator.cs` — `Compute(...)` 순수함수. 산출: `CurrentFeed`, `ClampedTarget`, `Ceiling(value,binding)`, `RampRate(value=kg/hr·min, binding)`, `RampTimeMin`, `Steam`, `Hold`, `Warnings`.
- `src/Infrastructure/Control/FeedRampAdvisorService.cs` — 라이브값 수집 후 `Compute` 호출 (읽기 전용).
- `src/Hc900Crawler/Controllers/FeedforwardController.cs` `GET api/ff/ramp-advisor` (L154).
- DTO: `src/Core/Application/Feedforward/FeedRampModels.cs` (`FeedRampAdvisory`).
- **없는 것**: ① Feed SP를 쓸 태그(ColumnConfig에 `FeedTag`=PV만 있고 SP 없음) ② 시간에 따라 SP를 단계적으로 써 나가는 상태머신/백그라운드 실행기 ③ 시작/취소 버튼·상태 표시.

### 1-D. 설정 모델

- `src/Core/Application/Feedforward/FeedforwardModels.cs`
  - `ColumnConfig`: `FeedTag`(PV), `Streams[]`, `ScanSec`, `StaleSec`, `Enabled`, `AdvisoryOnly`(기본 true) 등. **Feed SP 노드/태그 없음.**
  - `StreamConfig`: `SpNodeId`(쓰기 대상 태그명), `SpMin/SpMax`, `RateUpPerMin/RateDnPerMin`, `TargetCoeff`, `Role`.
- 저장: `IFeedforwardConfigStore.SaveColumnAsync/LoadAllAsync` (`FeedforwardConfigStore.cs`).

### 1-E. 컨트롤러 라우팅(중요)

- 현재 쓰기는 **`"HC1"` 하드코딩** (FeedforwardController.cs L102, FeedforwardSupervisor.cs L141).
- 멀티컨트롤러 구조: 컨트롤러당 게이트웨이 1개(C1~C4), `ControllerGrpcClientPool.GetClient(controllerId)`로 라우팅. → **컬럼이 어느 컨트롤러에 속하는지** 결정해야 한다 (§4 확인필요 참고).

---

## 2. 범위

| # | 작업 | 신규/수정 |
|---|------|-----------|
| A | 측류 권장 SP **쓰기 버튼** (스트림별) | UI 수정 + 백엔드 소폭 |
| B | **FEED 램프 실행기** (시작·단계쓰기·취소·상태) | 신규 서비스 + 설정필드 + 엔드포인트 + UI |

비범위: 자동 연속제어, 루프 모드 자동전환, OPC UA 복원.

---

## 3. 안전 / 도메인 요구사항 (반드시 준수)

> 실제 공정 컨트롤러에 쓰는 작업이다. 아래는 협상 불가.

1. **명시적 인가**: 모든 실쓰기는 버튼 → `confirm()` 다이얼로그 → 1회 실행. 폴링/타이머가 스스로 첫 쓰기를 시작하면 안 됨. (램프는 시작 후 단계 진행은 자동, 그러나 "시작"은 버튼.)
2. **WriteGuard 통과 필수**: 측류 쓰기는 기존 `FeedforwardWriteGuard.Check` 통과한 경우만. 차단 사유는 사용자에게 표시.
3. **범위 클램프**: 모든 쓰기 값은 `[SpMin, SpMax]`(측류) / `[FeedSpMin, FeedSpMax]`(피드) 내로 강제 클램프. 범위 밖이면 쓰기 거부 + 사유 표시.
4. **레이트 리밋**: 동일 대상 최소 간격(측류: 기존 endpoint는 즉시 1회라 OK / 피드 램프: `≥ ScanSec*2`, 권장 5~15s) 미만 재쓰기 금지.
5. **피드 불량 시 HOLD**: Feed PV가 bad/stale/≤0이면 램프 즉시 정지(다음 단계 안 씀), 상태에 HOLD 표시. (`FeedRampCalculator`가 이미 `Hold=true` 반환 — 이를 실행기에서 존중.)
6. **단조 진행**: 업램프 중 계산된 `ClampedTarget`이 ceiling 하락으로 줄어들면, 현재 SP보다 낮은 값으로 갑자기 점프 금지 — 단계 크기는 `RampRate × Δt`로 제한.
7. **모드 전제(반드시 확인)**: HC900 루프가 **외부 SP를 수용하는 모드**가 아니면 SP 쓰기는 무효/무시될 수 있다. (이 저장소 메모리 `mode-write-mechanism`: 루프 mode auto/man·LSP/RSP=CASC는 별도 0xBA/0xBC 레지스터로 씀.) → §4 확인필요. 최소한 쓰기 전 현재 모드를 읽어 경고하거나, 모드 불일치 시 차단할 것.
8. **감사로그**: 측류·피드 모든 실쓰기·차단은 `IFeedforwardAuditService.LogAsync`로 남긴다(operator, value, result, 사유). 쓰기 실패 시 전송오류 메시지는 **그대로 보존**(추적성 — D-10 철회: 이 경로에 토큰/비밀번호는 흐르지 않음).
9. **취소 즉시성**: 램프 취소 버튼은 다음 단계 쓰기를 즉시 멈춘다(현재 SP 유지, 되돌리지 않음).

### D-1(정정). WriteGuard 결합 분리 — 가드 **유지**, AdvisoryOnly만 분리

**증상(사실)**: `FeedforwardWriteGuard.Check` (L12~13)는 `cfg.AdvisoryOnly`가 true면 모든 쓰기를 차단한다. 그런데 이 작업은 auto-write를 막으려 `AdvisoryOnly=true`를 유지하므로 **버튼 수동쓰기까지 같이 막힌다**. 원인은 auto/manual 두 경로가 `AdvisoryOnly` **한 플래그에 결합**된 것.

**❌ 채택 금지(v2 오처방)**: "버튼 쓰기는 WriteGuard를 우회하고 SpMin/Max·Commanded·SpNodeId만 검증" — 이는 `Valid`(stale/무효 advisory)·`Grade==C`(저신뢰)·`Transient`(과도상태) 가드를 **제거**한다. 사람이 라이브 컬럼에 값을 미는 순간일수록 이 셋이 가장 중요하다(되돌릴 기회 1회). 우회는 §3.2와 정면충돌하는 안전회귀다.

**✅ 채택(결합 분리)**: 가드는 그대로 두고 `AdvisoryOnly` 의미만 분리한다. 택1:
- (권장) `WriteGuard.Check(cfg, adv, sc, column, bool manualOverride=false)` — `manualOverride==true`일 때 **`AdvisoryOnly` 체크 한 줄만** 건너뛰고 `Valid`/`Grade`/`Transient`/`SpMin~SpMax`/NaN·Inf는 **전부 유지**. `WriteSp`(버튼)만 `manualOverride: true`로 호출.
- (대안) auto-write 게이트를 별도 플래그 `AutoWriteEnabled`로 옮기고 `AdvisoryOnly`는 "자동쓰기 금지·버튼 허용"으로 재정의.

→ 결과: AdvisoryOnly 컬럼이라도 **버튼은 가능**하되, 과도·저신뢰·stale 상태에서는 여전히 차단된다.

---

## 4. 착수 전 확인 필요 (구현자가 사용자/현장에 질문)

이 항목들은 값/정책이 코드에 없으므로 **추정 금지, 확정 후 진행**:

- **C-1. 컨트롤러 ID 소스**: 컬럼→컨트롤러(C1~C4) 매핑을 어디서? (권장: `ColumnConfig.ControllerId` 필드 추가, 기본 `"C1"`. 현재 하드코딩 `"HC1"`이 실제 존재 ID인지 `config/gateway-config.json`과 대조.)
- **C-2. SP 태그 명명**: `SpNodeId`가 OPC UA식(`ns=3;s=ficq-6113.sp`)인지 HC900 태그명(`FICQ-6113.SP`, 레지스터맵 대문자)인지. 게이트웨이는 태그명을 그대로 받으므로 **HC900 태그명**이어야 함. 기존 설정값 점검·정규화 필요.
- **C-3. Feed SP 태그**: FEED 루프의 SP 쓰기 대상 태그명(예: `FICQ-6101.SP`)과 `FeedSpMin/Max`. register-map에서 access=RW 확인.
- **C-4. 루프 모드 전제(§3.7)**: SP 쓰기가 유효하려면 필요한 모드와, 모드 불일치 시 "차단" vs "경고 후 진행" 정책.
- **C-5. 램프 단계 주기/스텝**: 쓰기 간격(기본 10s 제안)과 한 컬럼만/다중 컬럼 동시 램프 허용 여부.

> 확인 전에는 **DRY-RUN 모드(실쓰기 대신 로그만)**로 개발/검증할 것 (§7).

---

## 5. 작업 A — 측류 권장 SP 쓰기 버튼

### A-1. 백엔드 보정 (최소)

파일: `src/Hc900Crawler/Controllers/FeedforwardController.cs`

- **D-3 수정**: L102 `WriteTagAsync("HC1", ...)`의 컨트롤러 ID를 **C-1 결정값**으로 교체 (`cfg.ControllerId` 또는 설정). 하드코딩 제거. (현재 `"HC1"`은 매칭 안 돼 쓰기 자체가 실패할 개연성 — 우선순위 높음.)
- **D-1(정정) 적용 — WriteGuard 우회 ❌ / 분리 ✅**: `WriteGuard.Check`에 `manualOverride` 인자를 추가하고 `WriteSp`는 `manualOverride: true`로 호출한다. 이때 **건너뛰는 것은 `AdvisoryOnly` 체크 한 줄뿐**이며 아래는 **반드시 유지**:
  - `s.Role == Commanded`
  - `adv.Valid` (stale/무효 advisory 차단)
  - `adv.Grade != C` (저신뢰 차단)
  - `!column.Transient` (과도상태 차단)
  - `spVal ∈ [sc.SpMin, sc.SpMax]` (범위밖 차단/클램프)
  - `spVal` NaN/Inf 아님
- 응답에 WriteGuard 차단 사유가 명확히 내려가는지 확인(현재 `BadRequest{error}` OK).
- (선택) 쓰기 전 현재 SP 읽어 응답에 `previousSp` 포함(UI 표시용) — `IHc900GatewayService`/`ListTags`/realtime로 조회.

> 엔드포인트 시그니처·검증 로직은 그대로 재사용. **새 엔드포인트 만들지 말 것.**

### A-2. UI — 버튼 추가

파일: `src/Hc900Crawler/wwwroot/js/ff.js` (`ffCard` 스트림 행, L166~182)

- 조건: `s.role === 'Commanded'` **그리고** `s.recommendedSp != null` **그리고** 컬럼이 advisory 유효(`!c.transient`, `s.valid`, `s.grade !== 'C'`)일 때만 행에 버튼 노출. (가드와 동일 조건 — 안 되는 버튼은 비활성/숨김.)
- 버튼: `권장 SP` 칸 옆 또는 신뢰칸에 `<button class="btn sm" onclick="ffWriteSp(${c.columnId},'${s.key}',${s.recommendedSp})">SP 반영</button>`.
- 핸들러 `ffWriteSp(columnId, key, sp)`:
  1. `confirm(\`${key} 스트림 SP를 ${sp} 로 컨트롤러에 쓰시겠습니까?\`)` — 취소 시 중단.
  2. `await ffApi('POST', \`/api/ff/write/${columnId}/${encodeURIComponent(key)}\`, {})` (value 비움 → 서버가 권장값 사용. 또는 `{value: sp}` 명시.)
  3. 성공: 토스트/메시지 표시 후 `ffLoadDash()`로 갱신(기존 `lastWriteSp/lastWriteError`가 표에 반영됨 — L171).
  4. 실패(특히 401): `ffApi`가 던지는 메시지 표시("RAG 관리 탭 로그인 필요" 포함).
- **D-8 수정**: HTML(`ff.html` L3~4) 헤더 배지 문구 "읽기 전용" → "권장값 · **버튼으로 인가 시 쓰기**" 로 수정.

### A-3. 인증

- 쓰기는 `X-Kb-Token` 필요(기존). 토큰 없으면 401 → UI에서 로그인 안내. 버튼을 토큰 보유시에만 활성화할지(`ffToken()` 체크)는 UX 선택.

### A-4. 수용 기준 (A)

- [ ] Commanded·유효 스트림에만 "SP 반영" 버튼이 보인다.
- [ ] 클릭 → confirm → 실제 gRPC 쓰기 1회. **AdvisoryOnly 컬럼이라도 버튼 쓰기는 허용**되지만, **Transient/Grade C/Valid 무효/범위밖**이면 차단 메시지(가드 유지).
- [ ] 쓰기 결과가 표에 `쓰기됨/오류`로 표시(`lastWriteSp`).
- [ ] `GET api/ff/audit`에 `sp_write` 항목 기록.
- [ ] 폴링(3s)이 스스로 쓰기를 트리거하지 않음(버튼만).

---

## 6. 작업 B — FEED 램프 실행기

### B-1. 설정 모델 확장

파일: `src/Core/Application/Feedforward/FeedforwardModels.cs` (`ColumnConfig`)

- 추가:
  - `string? FeedSpNodeId` — FEED SP 쓰기 대상 HC900 태그명 (C-3). null이면 램프 실행 불가.
  - `double FeedSpMin`, `double FeedSpMax` — 클램프 한계.
  - (선택) `string? ControllerId` — C-1.
- **D-5 수정**: `FeedforwardConfigStore.cs` DB 컬럼 추가 필요:
  - `ff_column_config` 테이블에 `feed_sp_node_id`, `feed_sp_min`, `feed_sp_max`, `controller_id` 컬럼 INSERT.
  - `LoadAllAsync` SQL에 포함 + 매핑.
  - `SaveColumnAsync` INSERT/UPDATE에 포함.
- `FeedforwardController.MapConfig`(L125) 출력에 추가.
- **D-6 수정**: `MapConfig` 익명객체에 `feedSpNodeId`, `feedSpMin`, `feedSpMax`, `controllerId` 필드 추가.
- 설정 UI(`ff.js` 컬럼 에디터, `ffEditColumn`/`g('ff-f-...')` 영역): 위 필드 입력란 추가, `POST api/ff/config` 페이로드(L461 인근)에 포함.

### B-2. 램프 상태 저장소 (신규)

신규: `src/Infrastructure/Control/FeedRampJobStore.cs` (+ `Core/Application/Feedforward/IFeedRampStores.cs`에 인터페이스)

- 컬럼별 활성 램프 1개: `record FeedRampJob { int ColumnId; double TargetFeed; double LastWrittenSp; string State; string? Hold; DateTime StartedAt; DateTime LastStepAt; string Operator; string[] Warnings; }`
- `State`: `Idle | Ramping | Hold | Reached | Canceled`.
- `ConcurrentDictionary<int, FeedRampJob>`; `Start/Get/GetAll/Cancel/Update`.

### B-3. 램프 실행 서비스 (신규, BackgroundService)

신규: `src/Infrastructure/Control/FeedRampExecutorService.cs` — `FeedforwardSupervisor.cs`의 패턴(스코프 생성·주기 루프·rate-limit·감사) 참고.

- 주기 `Δt`(기본 10s) 루프. 각 활성 Job마다:
  1. `FeedRampAdvisorService.ComputeAsync`로 최신 `CurrentFeed/ClampedTarget/RampRate/Ceiling/Hold/Warnings` 획득.
  2. `Hold==true`(피드 불량) → Job.State=Hold, 쓰기 안 함, 다음 주기.
  3. 목표 도달(`|CurrentFeed - min(TargetFeed,Ceiling)| ≤ ε` 또는 LastWrittenSp가 ClampedTarget 도달) → State=Reached, 쓰기 종료.
  4. **단계 SP 계산**: `step = RampRate(kg/hr·min) × (elapsedMin since LastStepAt)`. `nextSp = clamp(LastWrittenSp + step, .., min(TargetFeed, Ceiling))`. (다운램프는 현재 계산기 미구현 — §3.6/calculator L87 경고; **B에서는 업램프만**, 다운 요청은 거부.)
  5. **가드**: `nextSp`를 `[FeedSpMin, FeedSpMax]` 클램프, NaN/Inf 거부, 직전 쓰기와 간격 `≥ ScanSec*2` 확인.
  6. `Hc900WriteService.WriteTagAsync(controllerId, cfg.FeedSpNodeId, nextSp)` → 성공 시 `LastWrittenSp=nextSp`, `LastStepAt=now`. 감사로그(`action="feed_ramp_write"`).
  7. 실패 → Warnings에 기록, State 유지(다음 주기 재시도, 단 연속 실패 N회 시 Hold).
- **DRY-RUN 플래그**: `appsettings`의 `Feedforward:FeedRampDryRun`(기본 true) — true면 실제 쓰기 대신 로그만. C-1~C-4 확정·현장 합의 후 false.
- `SimOverride` 활성 시 입력이 가짜이므로 **실쓰기 억제**(Supervisor L83·88 패턴과 동일).
- `Program.cs`에 `AddHostedService<FeedRampExecutorService>()` + `FeedRampJobStore` DI 등록.

### B-4. 엔드포인트 (신규, FeedforwardController)

- `POST api/ff/feed-ramp/{columnId}/start` body `{ double targetFeed }` — `X-Kb-Token` 필수. 검증: 컬럼 존재·`FeedSpNodeId` 설정·`targetFeed > currentFeed`(업램프)·DryRun 여부 응답. Job 생성(State=Ramping). 즉시 쓰기 금지(다음 실행기 주기에 첫 단계).
- `POST api/ff/feed-ramp/{columnId}/cancel` — Job.State=Canceled, 다음 단계 중단.
- `GET api/ff/feed-ramp/{columnId}` / `GET api/ff/feed-ramp` — 상태 조회(진행률·LastWrittenSp·target·ceiling·hold·warnings·dryRun).

### B-5. UI — 램프 시작/상태

파일: `ff.js` / `ff.html`

- 램프 계산기 패널(`#ff-ramp`, `ffRampCompute`)에 **"램프 시작"** 버튼 추가:
  - 클릭 → `confirm(\`FEED를 ${target}까지 ${rampTimeMin}분에 걸쳐 단계적으로 올립니다. 시작?\`)` → `POST .../start`.
  - DryRun이면 버튼 라벨/배지에 "모의(DryRun)" 표기.
- 컬럼 카드(`ffCard`)에 활성 램프 상태 줄 추가: `GET api/ff/feed-ramp` 결과로 `진행 ▶ FEED SP ${LastWrittenSp} → ${target} (ceiling ${ceiling}) [Hold/경고]` + **취소** 버튼.
- 폴링 주기에 램프 상태도 함께 갱신.

### B-6. 수용 기준 (B)

- [ ] `FeedSpNodeId` 미설정 컬럼은 램프 시작 불가(명확한 사유).
- [ ] 시작 버튼 → 실행기가 `RampRate` 보폭으로 FEED SP를 단계적으로 증가(단번 점프 없음). 로그/상태에서 단계 확인.
- [ ] Ceiling/SpMax에서 멈춤. `TargetFeed` 도달 시 State=Reached, 쓰기 종료.
- [ ] Feed PV bad/stale → 즉시 Hold, 쓰기 정지.
- [ ] 취소 → 즉시 정지(현재 SP 유지).
- [ ] DryRun=true에서 실제 쓰기 0건(로그만), false 전환 후에만 실쓰기.
- [ ] 모든 단계/차단 감사로그 기록.

### B-7(부기). D-2 철회 + D-4 후속정리 (선택)

- **D-2 철회**: "warning 4줄 하드코딩"은 사실오류 — `FeedRampCalculator.Compute`에서 무조건 추가되는 건 **2줄**(L49 steam ceiling, L73 energyLoop)이고, 이는 버그가 아니라 **미구현 제약(스팀·에너지루프 ceiling)을 명시하는 의도적 TODO 마커**다. 본 작업의 필수 항목 아님. 정 거슬리면 UI에서 `미산정` 계열 정보성 문구를 실제 경고와 분리 표시(별도 스타일)하는 정도의 선택적 정리만.
- **D-4 후속정리(선택)**: `FeedforwardSupervisor.AutoWriteAsync` L141의 `"HC1"`도 동일하게 컨트롤러 ID 파라미터화. 단 이 작업은 `AdvisoryOnly=true` 유지로 AutoWrite가 실행되지 않으므로 **死경로** — 본 작업의 차단 요소가 아니며 일관성 차원의 후속 정리.

---

## 7. 테스트 계획

1. **단위**: `FeedRampExecutorService` 단계 계산(보폭·클램프·Hold·도달) — `FeedRampCalculator`는 순수함수라 기존 패턴대로 모킹.
2. **시뮬레이터**: `test/modbus_sim.py`(port 5020) + `test/read_tags.py`로 SP 쓰기 반영 확인. 게이트웨이를 sim 대상으로 띄워 gRPC 경로 검증.
3. **SimOverride/DryRun**: 가짜 입력으로 UI·상태머신 흐름 검증(실쓰기 0건 확인).
4. **WriteGuard 경계**: Grade C / Transient / Valid 무효 / 범위밖에서 측류 버튼 **차단** 확인. **AdvisoryOnly 컬럼에서는 (다른 가드 통과 시) 버튼 쓰기 허용**됨을 확인(manualOverride 분리 동작).
5. **실컨트롤러(현장, C-1~C-4 확정 후)**: DryRun=false, 단일 스트림·소폭으로 1회 측류 쓰기 → 실제 SP 변화 확인 → 그 후 피드 램프 소구간 검증.

---

## 8. 산출물 체크리스트

- [ ] `ColumnConfig` + 저장소 + 설정 UI: `FeedSpNodeId`, `FeedSpMin/Max`, (`ControllerId`).
- [ ] `FeedRampJobStore` + `IFeedRampStores`.
- [ ] `FeedRampExecutorService`(BackgroundService) + `Program.cs` 등록 + DryRun 설정.
- [ ] FeedforwardController: feed-ramp start/cancel/status 3개 + 측류 쓰기 컨트롤러ID 보정 + **WriteGuard `manualOverride` 분리(우회 아님)**.
- [ ] `WriteGuard.Check` 시그니처에 `manualOverride` 추가 — `Valid`/`Grade`/`Transient`/범위 가드 유지.
- [ ] `ff.js`/`ff.html`: 측류 "SP 반영" 버튼, 램프 "시작/취소/상태" UI, 배지 문구.
- [ ] 감사로그 액션: `sp_write`(기존), `feed_ramp_write`(신규). 실패 메시지 보존(D-10 철회).
- [ ] 단위·sim 테스트.
- [ ] §4 확인필요 항목 모두 확정·문서화.
- [ ] (선택) D-4 AutoWriteAsync 컨트롤러ID 파라미터화. D-2는 철회(필수 아님).

---

## 부록 — 핵심 파일 인덱스

| 역할 | 경로 |
|---|---|
| 측류 쓰기 엔드포인트 | `src/Hc900Crawler/Controllers/FeedforwardController.cs` (WriteSp L78) |
| 쓰기 가드 | `src/Infrastructure/Control/FeedforwardWriteGuard.cs` |
| gRPC 쓰기 | `src/Infrastructure/Hc900/Hc900WriteService.cs` / `ControllerGrpcClientPool.cs` |
| 자동쓰기(참고·미사용) | `src/Infrastructure/Control/FeedforwardSupervisor.cs` (AutoWriteAsync L107) |
| 램프 계산기(순수함수) | `src/Infrastructure/Control/FeedRampCalculator.cs` |
| 램프 입력수집 | `src/Infrastructure/Control/FeedRampAdvisorService.cs` |
| 램프 DTO | `src/Core/Application/Feedforward/FeedRampModels.cs` |
| 설정 모델 | `src/Core/Application/Feedforward/FeedforwardModels.cs` |
| 설정 저장소 | `src/Infrastructure/Control/FeedforwardConfigStore.cs` |
| UI | `src/Hc900Crawler/wwwroot/panes/ff.html`, `wwwroot/js/ff.js` |
| 감사 | `IFeedforwardAuditService`, `GET api/ff/audit` |
