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 _logger; public FeedforwardConfigStore(Hc900DbContext ctx, ILogger logger) { _ctx = ctx; _logger = logger; } public async Task> LoadAllAsync(CancellationToken ct = default) { var conn = _ctx.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); var cols = new Dictionary 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() : rd.GetString(5) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14); var tempTags = rawTempTags is null ? Array.Empty() : 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(), 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()); } } 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(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(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 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); } }