Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.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

20 KiB
Raw Permalink Blame History

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개)

  1. src/Infrastructure/Control/CrossCorrLagEstimator.cs신규 블록
  2. src/Core/Application/Feedforward/FeedforwardModels.csPvSnapshot.SteamOp init 프로퍼티
  3. src/Infrastructure/Control/FeedforwardSupervisor.cs — 스팀 OP 읽기(.op는 .pv 아님)
  4. src/Infrastructure/Control/FeedforwardEngine.csColumnState 필드 + ApplyThetaSuggestion + Tick 배선
  5. src/Web/wwwroot/js/ff.js — θ 제안 표시 (Controller는 §0에서 이미 thetaSuggest* 노출 — 변경 없음)
  6. src/Web/wwwroot/css/ff.css — θ 행 스타일
  7. 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_configtheta_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이 흔히 깨먹는 지점)

  1. config θ에 대입 금지cfg.ThetaUpSec = ... 같은 코드 절대 금지. StreamAdvisory.ThetaSuggest*(제안)에만 쓴다.
  2. SteamOpTag은 .opSample()(=.pv 강제) 쓰지 말고 SampleExact()로. 실측 태그 접미사 확인.
  3. WO-2 선행 필수BuildTemps/PvSnapshot.Temps/ColumnState.PRef*가 없으면 앵커가 안 맞는다. WO-2 먼저.
  4. positional record 금지PvSnapshot.SteamOp는 init 프로퍼티로(생성자 인자 추가 금지). 생성은 new PvSnapshot(...) { Temps=.., SteamOp=.. }.
  5. 테스트는 estimator를 직접 호출(엔진 경유 X) — Δ를 직접 Push. recomputeEvery=1로 즉시 계산.
  6. 첫 제안까지 시간 — maxLag*2 샘플 누적 전엔 null(정상). 실운전 ~1시간. 조급해하지 말 것.