Files
HC900-Crawler/src/Infrastructure/Control/FeedforwardConfigStore.cs
windpacer 7b21c35af6 feat: 민감단온도 전환복귀제어 + SteamAdvisor + FeedRamp 전면 구현
=== 민감단온도(T_C) 전환복귀제어 (작업플랜 구현) ===
- FeedforwardModels: TempLowLimit, TcReturnRebTarget/Band, TcReturnDeltaAdRef/Band 추가
- FeedforwardEngine: sigTLow (T_C 하한 트리거, -1e9=비활성) + 온도기반 복귀게이트(tcRecovered)
  -> Recovering→Returning 전이: mbRecovered(물질수지) OR tcRecovered(reb-A+ΔT+T_C)
- FeedRampCalculator: 하강 램프 전면 구현 (RateUpPerMin/RateDnPerMin 분리, θ_up/θ_dn 분기, floor clamp)
- FeedRampExecutorService: 하강 램프 step 방향 지원
- FeedforwardConfigStore: 신규 6개 컬럼 SELECT/INSERT/UPDATE
- Hc900DbContext: temp_low_limit, tc_return_reb_target/band, tc_return_delta_ad_ref/band
- FeedforwardController: API 노출 + feed-ramp start/cancel/status

=== SteamAdvisor ===
- SteamAdvisorController: steam map 로드/시각화/제품매칭/온도프로파일
- steam.js, steam.html: SteamAdvisor 전용 UI 패널

=== Feed Ramp 실행 ===
- FeedRampExecutorService: BG service (BackgroundService)
- FeedRampJobStore: in-memory job store
- FfTrackingStore: ramp tracking DB
- FeedforwardSupervisor/WriteGuard: SP 쓰기 advisory + rate-limit

=== 분석 스크립트 ===
- gen_temp_profiles.py: 컬럼 온도 프로파일 기준 산출 → c{prefix}_tempref.json
- export_plotdata.py: analysis 결과 plot data export
- gen_instrument_ranges.py: 계기 범위 생성
- c6111_extract.py: C-6111 추출/운전모드 분류
- run_column.py: 전체 분석 파이프라인

=== Web UI ===
- ff.js/ff.html/ff.css: 전환류 상태기계 UI, TagBrowser, config save
- fast.js: Fast 조작 패널
- trend.js, pb.js, llmchat.js: 각 패널 확장
2026-06-06 18:33:56 +09:00

326 lines
19 KiB
C#

using System.Data;
using System.Data.Common;
using Hc900Crawler.Core.Application.Feedforward;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Control;
public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
{
private readonly Hc900DbContext _ctx;
private readonly ILogger<FeedforwardConfigStore> _logger;
public FeedforwardConfigStore(Hc900DbContext ctx, ILogger<FeedforwardConfigStore> logger)
{ _ctx = ctx; _logger = logger; }
public async Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
var cols = new Dictionary<int, (ColumnConfig cfg, List<StreamConfig> streams)>();
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min,
press_filter_tau_sec, pressure_band, settle_sec,
stale_sec, product_key,
temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag,
theta_auto_tune, bias_ma_window_sec,
recovery_enabled, recovery_auto_arm,
imbalance_trigger_frac, imbalance_trigger_sec,
recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
delta_p_tag, delta_p_flood_limit,
advisory_only,
temp_high_limit, temp_low_limit,
tc_return_reb_target, tc_return_reb_band,
tc_return_delta_ad_ref, tc_return_delta_ad_band,
controller_id, feed_sp_node_id, feed_sp_min, feed_sp_max,
tc_return_tc_target, tc_return_tc_band
FROM ff_column_config
""";
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct))
{
var levelTags = rd.IsDBNull(5)
? Array.Empty<string>()
: rd.GetString(5)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14);
var tempTags = rawTempTags is null
? Array.Empty<string>()
: rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var cfg = new ColumnConfig
{
Id = rd.GetInt32(0),
Name = rd.GetString(1),
Enabled = rd.GetBoolean(2),
AdvisoryOnly = rd.GetBoolean(30),
FeedTag = rd.GetString(3),
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4),
LevelTags = levelTags,
ScanSec = rd.GetDouble(6),
FeedFilterTauSec = rd.GetDouble(7),
FeedMoveThresholdPerMin = rd.GetDouble(8),
PressFilterTauSec = rd.GetDouble(9),
PressureBand = rd.GetDouble(10),
SettleSec = rd.GetDouble(11),
StaleSec = rd.GetDouble(12),
ProductKey = rd.GetString(13),
Streams = Array.Empty<StreamConfig>(),
TempTags = tempTags,
SensitiveTrayTag = rd.IsDBNull(15) ? null : rd.GetString(15),
DTdP = rd.GetDouble(16),
PRef = rd.IsDBNull(17) ? double.NaN : rd.GetDouble(17),
SteamOpTag = rd.IsDBNull(18) ? null : rd.GetString(18),
ThetaAutoTune = rd.GetBoolean(19),
BiasMaWindowSec = rd.GetDouble(20),
RecoveryEnabled = rd.GetBoolean(21),
RecoveryAutoArm = rd.GetBoolean(22),
ImbalanceTriggerFrac = rd.GetDouble(23),
ImbalanceTriggerSec = rd.GetDouble(24),
RecoverySettleSec = rd.GetDouble(25),
ReturnRampSec = rd.GetDouble(26),
FeedRecoverySp = rd.GetDouble(27),
DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28),
DeltaPFloodLimit = rd.GetDouble(29),
TempHighLimit = rd.GetDouble(31),
TempLowLimit = rd.GetDouble(32),
TcReturnRebTarget = rd.IsDBNull(33) ? double.NaN : rd.GetDouble(33),
TcReturnRebBand = rd.GetDouble(34),
TcReturnDeltaAdRef = rd.IsDBNull(35) ? double.NaN : rd.GetDouble(35),
TcReturnDeltaAdBand = rd.GetDouble(36),
ControllerId = rd.IsDBNull(37) ? "C1" : rd.GetString(37),
FeedSpNodeId = rd.IsDBNull(38) ? null : rd.GetString(38),
FeedSpMin = rd.GetDouble(39),
FeedSpMax = rd.GetDouble(40),
TcReturnTcTarget = rd.IsDBNull(41) ? double.NaN : rd.GetDouble(41),
TcReturnTcBand = rd.GetDouble(42),
};
cols[cfg.Id] = (cfg, new List<StreamConfig>());
}
}
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec,
tau_sec, sp_min, sp_max, rate_up_per_min, rate_dn_per_min,
reflux_from_product, grade, level_tag,
is_reflux, recovery_sp, sp_node_id
FROM ff_stream_config
ORDER BY id
""";
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct))
{
int colId = rd.GetInt32(0);
if (!cols.TryGetValue(colId, out var entry)) continue;
entry.streams.Add(new StreamConfig
{
Key = rd.GetString(1),
FlowTag = rd.GetString(2),
Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor,
LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14),
IsReflux = rd.GetBoolean(15),
RecoverySp = rd.IsDBNull(16) ? double.NaN : rd.GetDouble(16),
SpNodeId = rd.IsDBNull(17) ? null : rd.GetString(17),
TargetCoeff = rd.GetDouble(4),
ThetaUpSec = rd.GetDouble(5),
ThetaDnSec = rd.GetDouble(6),
TauSec = rd.GetDouble(7),
SpMin = rd.GetDouble(8),
SpMax = rd.GetDouble(9),
RateUpPerMin = rd.GetDouble(10),
RateDnPerMin = rd.GetDouble(11),
RefluxFromProduct = rd.GetBoolean(12),
Grade = Enum.TryParse<Confidence>(rd.GetString(13), true, out var g) ? g : Confidence.A
});
}
}
return cols.Values.Select(e => e.cfg with { Streams = e.streams }).ToList();
}
private static DbParameter P(DbCommand cmd, string name, object? val)
{
var p = cmd.CreateParameter();
p.ParameterName = name;
p.Value = val ?? DBNull.Value;
cmd.Parameters.Add(p);
return p;
}
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
int id = cfg.Id;
var levelTags = string.Join(',', cfg.LevelTags);
if (id == 0)
{
await using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
INSERT INTO ff_column_config
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
pressure_band, settle_sec, stale_sec, product_key, advisory_only,
temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag,
theta_auto_tune, bias_ma_window_sec,
recovery_enabled, recovery_auto_arm,
imbalance_trigger_frac, imbalance_trigger_sec,
recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
delta_p_tag, delta_p_flood_limit, temp_high_limit, temp_low_limit,
tc_return_reb_target, tc_return_reb_band,
tc_return_delta_ad_ref, tc_return_delta_ad_band,
controller_id, feed_sp_node_id, feed_sp_min, feed_sp_max,
tc_return_tc_target, tc_return_tc_band)
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory,
@tempTags,@sensTray,@dtdp,@pRef,@steamOp,
@thetaAuto,@biasMaWin,
@recEn,@recAutoArm,
@imbFrac,@imbSec,
@recSettle,@retRamp,@feedRecSp,
@deltaPTag,@deltaPFlood,@tempHigh,@tempLow,
@tcRetRebTgt,@tcRetRebBand,
@tcRetDeltaAdRef,@tcRetDeltaAdBand,
@ctrlId,@feedSpNode,@feedSpMin,@feedSpMax,
@tcRetTcTgt,@tcRetTcBand)
RETURNING id
""";
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag);
P(cmd,"@pres",(object?)cfg.PressureTag); P(cmd,"@lvl",levelTags);
P(cmd,"@advisory",cfg.AdvisoryOnly);
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin);
P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec);
P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value);
P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag ?? DBNull.Value);
P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef);
P(cmd,"@steamOp",(object?)cfg.SteamOpTag ?? DBNull.Value);
P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value);
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit);
P(cmd,"@tempLow",cfg.TempLowLimit);
P(cmd,"@tcRetRebTgt",double.IsNaN(cfg.TcReturnRebTarget) ? DBNull.Value : (object)cfg.TcReturnRebTarget);
P(cmd,"@tcRetRebBand",cfg.TcReturnRebBand);
P(cmd,"@tcRetDeltaAdRef",double.IsNaN(cfg.TcReturnDeltaAdRef) ? DBNull.Value : (object)cfg.TcReturnDeltaAdRef);
P(cmd,"@tcRetDeltaAdBand",cfg.TcReturnDeltaAdBand);
P(cmd,"@ctrlId",string.IsNullOrWhiteSpace(cfg.ControllerId) ? "C1" : cfg.ControllerId);
P(cmd,"@feedSpNode",(object?)cfg.FeedSpNodeId ?? DBNull.Value);
P(cmd,"@feedSpMin",cfg.FeedSpMin); P(cmd,"@feedSpMax",cfg.FeedSpMax);
P(cmd,"@tcRetTcTgt",double.IsNaN(cfg.TcReturnTcTarget) ? DBNull.Value : (object)cfg.TcReturnTcTarget);
P(cmd,"@tcRetTcBand",cfg.TcReturnTcBand);
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
else
{
await using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE ff_column_config SET
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl,
scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt,
press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle,
stale_sec=@stale, product_key=@pk, advisory_only=@advisory,
temp_tags=@tempTags, sensitive_tray_tag=@sensTray, dtdp=@dtdp, p_ref=@pRef,
steam_op_tag=@steamOp, theta_auto_tune=@thetaAuto, bias_ma_window_sec=@biasMaWin,
recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm,
imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec,
recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp,
delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood,
temp_high_limit=@tempHigh, temp_low_limit=@tempLow,
tc_return_reb_target=@tcRetRebTgt, tc_return_reb_band=@tcRetRebBand,
tc_return_delta_ad_ref=@tcRetDeltaAdRef, tc_return_delta_ad_band=@tcRetDeltaAdBand,
controller_id=@ctrlId, feed_sp_node_id=@feedSpNode, feed_sp_min=@feedSpMin, feed_sp_max=@feedSpMax,
tc_return_tc_target=@tcRetTcTgt, tc_return_tc_band=@tcRetTcBand
WHERE id=@id
""";
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
P(cmd,"@feed",cfg.FeedTag); P(cmd,"@pres",(object?)cfg.PressureTag);
P(cmd,"@advisory",cfg.AdvisoryOnly);
P(cmd,"@lvl",levelTags); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec);
P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand);
P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value);
P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag ?? DBNull.Value);
P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef);
P(cmd,"@steamOp",(object?)cfg.SteamOpTag ?? DBNull.Value);
P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value);
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit);
P(cmd,"@tempLow",cfg.TempLowLimit);
P(cmd,"@tcRetRebTgt",double.IsNaN(cfg.TcReturnRebTarget) ? DBNull.Value : (object)cfg.TcReturnRebTarget);
P(cmd,"@tcRetRebBand",cfg.TcReturnRebBand);
P(cmd,"@tcRetDeltaAdRef",double.IsNaN(cfg.TcReturnDeltaAdRef) ? DBNull.Value : (object)cfg.TcReturnDeltaAdRef);
P(cmd,"@tcRetDeltaAdBand",cfg.TcReturnDeltaAdBand);
P(cmd,"@ctrlId",string.IsNullOrWhiteSpace(cfg.ControllerId) ? "C1" : cfg.ControllerId);
P(cmd,"@feedSpNode",(object?)cfg.FeedSpNodeId ?? DBNull.Value);
P(cmd,"@feedSpMin",cfg.FeedSpMin); P(cmd,"@feedSpMax",cfg.FeedSpMax);
P(cmd,"@tcRetTcTgt",double.IsNaN(cfg.TcReturnTcTarget) ? DBNull.Value : (object)cfg.TcReturnTcTarget);
P(cmd,"@tcRetTcBand",cfg.TcReturnTcBand);
await cmd.ExecuteNonQueryAsync(ct);
}
// 스트림 원자적 교체
await using (var del = conn.CreateCommand())
{
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
}
foreach (var s in cfg.Streams)
{
await using var ins = conn.CreateCommand();
ins.Transaction = tx;
ins.CommandText = """
INSERT INTO ff_stream_config
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag,
is_reflux, recovery_sp, sp_node_id)
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag,
@isReflux,@recSp,@spNode)
""";
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag);
P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec);
P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax);
P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
P(ins,"@grade",s.Grade.ToString());
P(ins,"@isReflux",s.IsReflux); P(ins,"@recSp",double.IsNaN(s.RecoverySp) ? DBNull.Value : (object)s.RecoverySp);
P(ins,"@spNode",(object?)s.SpNodeId ?? DBNull.Value);
await ins.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
return id;
}
public async Task DeleteColumnAsync(int columnId, CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM ff_column_config WHERE id=@id";
P(cmd,"@id",columnId);
await cmd.ExecuteNonQueryAsync(ct);
}
}