From daeb5316a2786656cca43f5fae1f5e21dff6ecbe Mon Sep 17 00:00:00 2001 From: windpacer Date: Thu, 4 Jun 2026 09:43:37 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=83=9C=EA=B7=B8=20=EB=8C=80?= =?UTF-8?q?=EC=86=8C=EB=AC=B8=EC=9E=90=20register-map=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EB=8C=80=EB=AC=B8=EC=9E=90=EB=A1=9C=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedforwardSupervisor: PvTag() ToUpperInvariant + empty FeedTag 가드 - FeedforwardConfigStore: 모든 ToLowerInvariant() 제거 - FeedRampAdvisorService: ToLowerInvariant 제거 + StringComparison.OrdinalIgnoreCase - SimOverrideStore: ToLowerInvariant 제거 - Hc900RealtimeService: HealthCheck SERVING 판정, mapping 없는 태그 대소문자 유지 - PidExtractorService: ToLowerInvariant → OrdinalIgnoreCase 비교 - Hc900Entities: 주석 업데이트 (대문자 표준) - load_state_labels.py: 소문자 변환 금지, controller_id 파라미터 추가 - Hc900Controllers: 대소문자 무시 정렬 - write.js: .MODE → AutoManState/RemLocSPState/SP_SelectState/TuneSetState - setup.js/html: 중복 함수 제거, C5 컨트롤러 placeholder --- scripts/load_state_labels.py | 21 ++++-- .../Services/PidExtractorService.cs | 18 ++--- src/Core/Domain/Entities/Hc900Entities.cs | 4 +- .../Controllers/Hc900Controllers.cs | 19 +++++- src/Hc900Crawler/wwwroot/js/setup.js | 66 ------------------- src/Hc900Crawler/wwwroot/js/write.js | 11 +++- src/Hc900Crawler/wwwroot/panes/setup.html | 12 ++-- src/Hc900Crawler/wwwroot/setup.html | 4 +- .../Control/FeedRampAdvisorService.cs | 5 +- .../Control/FeedforwardConfigStore.cs | 44 ++++++------- .../Control/FeedforwardSupervisor.cs | 24 ++++--- .../Control/SimOverrideStore.cs | 4 +- .../Hc900/Hc900RealtimeService.cs | 16 ++++- 13 files changed, 114 insertions(+), 134 deletions(-) diff --git a/scripts/load_state_labels.py b/scripts/load_state_labels.py index 8aab97b..a0f35f9 100644 --- a/scripts/load_state_labels.py +++ b/scripts/load_state_labels.py @@ -1,15 +1,21 @@ #!/usr/bin/env python3 """ xlsx의 StatusPoint DescriptorState0~7 → hc900.tag_metadata 로드 -실행: python3 scripts/load_state_labels.py +실행: python3 scripts/load_state_labels.py [--controller C1] +태그명은 Experion ItemName 원형 대소문자 유지 (ToLower 금지). """ import openpyxl import psycopg2 +import argparse from pathlib import Path XLSX_PATH = Path(__file__).parent.parent / "docs" / "Sinam_Tag_all.xlsx" DB_DSN = "host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres" +parser = argparse.ArgumentParser(description='Load StatusPoint state labels into tag_metadata') +parser.add_argument('--controller', default='HC1', help='Controller ID (default: HC1)') +args = parser.parse_args() + wb = openpyxl.load_workbook(XLSX_PATH, read_only=True, data_only=True) ws = wb['Sheet1'] rows = list(ws.iter_rows(values_only=True)) @@ -26,7 +32,8 @@ for row in rows[2:]: if cls != 'StatusPoint' or not name: continue - base_tag = name.lower() + # 원형(ItemName) 유지 — 소문자 변환 금지 + base_tag = name for i in range(8): key = f'DescriptorState{i}' @@ -36,13 +43,13 @@ for row in rows[2:]: if val is None or val == '': continue cur.execute(""" - INSERT INTO hc900.tag_metadata (base_tag, attribute, value) - VALUES (%s, %s, %s) - ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value - """, (base_tag, f'state{i}', str(val))) + INSERT INTO hc900.tag_metadata (base_tag, attribute, value, controller_id) + VALUES (%s, %s, %s, %s) + ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW() + """, (base_tag, f'state{i}', str(val), args.controller)) inserted += 1 conn.commit() cur.close() conn.close() -print(f"완료: {inserted}개 상태 레이블 저장") +print(f"완료: {inserted}개 상태 레이블 저장 (controller={args.controller})") diff --git a/src/Core/Application/Services/PidExtractorService.cs b/src/Core/Application/Services/PidExtractorService.cs index 7ef93ae..fe8e302 100644 --- a/src/Core/Application/Services/PidExtractorService.cs +++ b/src/Core/Application/Services/PidExtractorService.cs @@ -378,10 +378,10 @@ public class PidExtractorService : IPidExtractorService private async Task FindFallbackTagAsync(string tagNo) { - var normalized = tagNo.Split('.')[0].ToLowerInvariant(); + var baseTag = tagNo.Split('.')[0]; return await _dbContext.RealtimePoints - .FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized - || t.TagName.ToLower().StartsWith(normalized + ".")); + .FirstOrDefaultAsync(t => t.TagName.Equals(baseTag, StringComparison.OrdinalIgnoreCase) + || t.TagName.StartsWith(baseTag + ".", StringComparison.OrdinalIgnoreCase)); } public async Task<(int Total, IEnumerable Items)> GetEquipmentAsync( @@ -425,15 +425,15 @@ public class PidExtractorService : IPidExtractorService // batch-load sub_area from tag_metadata if (items.Count > 0) { - var tagNos = items.Select(e => e.TagNo.ToLowerInvariant()).ToHashSet(); + var tagNos = items.Select(e => e.TagNo).ToHashSet(StringComparer.OrdinalIgnoreCase); var subAreas = await _dbContext.TagMetadata .Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area") .Select(m => new { m.BaseTag, m.Value }) .ToListAsync(); - var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value); + var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value, StringComparer.OrdinalIgnoreCase); foreach (var e in items) { - if (subMap.TryGetValue(e.TagNo.ToLowerInvariant(), out var sa)) + if (subMap.TryGetValue(e.TagNo, out var sa)) e.SubArea = sa; } } @@ -471,7 +471,7 @@ public class PidExtractorService : IPidExtractorService { var e = new PidEquipment { - TagNo = request.TagNo.ToLowerInvariant(), + TagNo = request.TagNo, EquipmentName = request.EquipmentName, InstrumentType = request.InstrumentType, Category = request.Category, @@ -499,7 +499,7 @@ public class PidExtractorService : IPidExtractorService { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return false; - if (request.TagNo != null) e.TagNo = request.TagNo.ToLowerInvariant(); + if (request.TagNo != null) e.TagNo = request.TagNo; if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName; if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType; if (request.Category != null) e.Category = request.Category; @@ -518,7 +518,7 @@ public class PidExtractorService : IPidExtractorService await _dbContext.SaveChangesAsync(); if (request.SubArea != null) { - var baseTag = e.TagNo.ToLowerInvariant(); + var baseTag = e.TagNo; if (string.IsNullOrWhiteSpace(request.SubArea)) await _dbContext.Database.ExecuteSqlRawAsync( "DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag); diff --git a/src/Core/Domain/Entities/Hc900Entities.cs b/src/Core/Domain/Entities/Hc900Entities.cs index f091e8f..5d1502f 100644 --- a/src/Core/Domain/Entities/Hc900Entities.cs +++ b/src/Core/Domain/Entities/Hc900Entities.cs @@ -186,12 +186,12 @@ public class Hc900MapEntry [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } - /// OPC UA 스타일 이름 (realtime_table 기준, e.g. "ficq-6101.pv") + /// 태그명 (realtime_table 기준) = hc900_tag 와 동일한 Experion ItemName, e.g. "FICQ-6101.PV". 대소문자 변환 없음. [Column("tagname")] [Required] public string TagName { get; set; } = string.Empty; - /// HC900 레지스터 태그명 (register-map.json 기준, e.g. "FICQ3101.PV") + /// HC900 게이트웨이 register-map 태그명 (= tagname), e.g. "FICQ-6101.PV" [Column("hc900_tag")] [Required] public string Hc900Tag { get; set; } = string.Empty; diff --git a/src/Hc900Crawler/Controllers/Hc900Controllers.cs b/src/Hc900Crawler/Controllers/Hc900Controllers.cs index f142ef8..b402b00 100644 --- a/src/Hc900Crawler/Controllers/Hc900Controllers.cs +++ b/src/Hc900Crawler/Controllers/Hc900Controllers.cs @@ -247,7 +247,24 @@ public class Hc900TagManagerController : ControllerBase if (!string.IsNullOrEmpty(search)) q = q.Where(x => x.TagName.Contains(search) || x.Hc900Tag.Contains(search)); - var entries = await q.OrderBy(x => x.LoopNo).ThenBy(x => x.TagName).ToListAsync(); + var entries = await q.ToListAsync(); + entries.Sort((a, b) => + { + var ca = char.ToUpper(a.TagName[0]); + var cb = char.ToUpper(b.TagName[0]); + if (ca != cb) return ca.CompareTo(cb); + + var dotA = a.TagName.LastIndexOf('.'); + var dotB = b.TagName.LastIndexOf('.'); + var baseA = dotA >= 0 ? a.TagName[..dotA] : a.TagName; + var baseB = dotB >= 0 ? b.TagName[..dotB] : b.TagName; + var propA = dotA >= 0 ? a.TagName[(dotA + 1)..] : ""; + var propB = dotB >= 0 ? b.TagName[(dotB + 1)..] : ""; + + var cmp = string.Compare(baseA, baseB, StringComparison.OrdinalIgnoreCase); + if (cmp != 0) return cmp; + return string.Compare(propA, propB, StringComparison.OrdinalIgnoreCase); + }); var tagNames = entries.Select(e => e.TagName).ToList(); var liveValues = await _ctx.RealtimePoints diff --git a/src/Hc900Crawler/wwwroot/js/setup.js b/src/Hc900Crawler/wwwroot/js/setup.js index bf60eca..e9633d9 100644 --- a/src/Hc900Crawler/wwwroot/js/setup.js +++ b/src/Hc900Crawler/wwwroot/js/setup.js @@ -239,73 +239,7 @@ async function addController() { document.getElementById('new-regmap').value = ''; } } -async function saveCtrl(id) { - const cfg = await _setupApi('GET', '/config'); - const ctrl = _ctrls(cfg).find(c => (c.Id ?? c.id) === id); - if (!ctrl) return; - const ipEl = document.getElementById('ip-' + id); - if (ipEl && ipEl.value.trim()) ctrl.controllerIp = ipEl.value.trim(); - const mbEl = document.getElementById('mbport-' + id); - if (mbEl) ctrl.controllerPort = parseInt(mbEl.value) || 502; - const grEl = document.getElementById('grpc-' + id); - if (grEl) ctrl.grpcPort = parseInt(grEl.value) || 50051; - const poEl = document.getElementById('poll-' + id); - if (poEl) ctrl.pollIntervalMs = parseInt(poEl.value) || 1000; - const rmEl = document.getElementById('regmap-' + id); - if (rmEl && rmEl.value.trim()) ctrl.registerMapPath = rmEl.value.trim(); - const enEl = document.getElementById('enabled-' + id); - if (enEl) ctrl.enabled = enEl.checked; - const r = await _setupApi('POST', '/config', cfg); - _setupMsg('msg-' + id, r.success, r.message); -} -async function deleteCtrl(id) { - if (!confirm(`Delete controller ${id}?`)) return; - const cfg = await _setupApi('GET', '/config'); - const arr = _ctrls(cfg).filter(c => (c.Id ?? c.id) !== id); - if (arr.length === _ctrls(cfg).length) return; - cfg.Controllers = cfg.Controllers ?? cfg.controllers; - cfg.controllers = arr; - const r = await _setupApi('POST', '/config', cfg); - _setupMsg('add-msg', r.success, r.message); - if (r.success) loadAll(); -} - -async function saveSharedConfig() { - const cfg = await _setupApi('GET', '/config'); - cfg.Shared = cfg.Shared ?? cfg.shared ?? {}; - cfg.shared = cfg.Shared; - cfg.shared.binaryPath = document.getElementById('cfg-bin').value.trim(); - cfg.shared.ldLibraryPath = document.getElementById('cfg-ld').value.trim(); - cfg.shared.logDir = document.getElementById('cfg-logdir').value.trim(); - const r = await _setupApi('POST', '/config', cfg); - _setupMsg('shared-msg', r.success, r.message); -} - -async function addController() { - const cfg = await _setupApi('GET', '/config'); - const controllers = cfg.Controllers ?? cfg.controllers ?? []; - controllers.push({ - id: document.getElementById('new-id').value.trim() || 'HCX', - name: document.getElementById('new-name').value.trim(), - controllerIp: document.getElementById('new-ip').value.trim(), - controllerPort: parseInt(document.getElementById('new-port').value) || 502, - grpcPort: parseInt(document.getElementById('new-grpc').value) || 50051, - pollIntervalMs: parseInt(document.getElementById('new-poll').value) || 1000, - registerMapPath: document.getElementById('new-regmap').value.trim(), - enabled: true, - }); - cfg.controllers = controllers; - const r = await _setupApi('POST', '/config', cfg); - _setupMsg('add-msg', r.success, r.message); - if (r.success) { - loadAll(); - document.getElementById('new-id').value = ''; - document.getElementById('new-name').value = ''; - document.getElementById('new-ip').value = ''; - document.getElementById('new-regmap').value = ''; - } -} async function refreshLog() { const sel = document.getElementById('log-ctrl-select'); diff --git a/src/Hc900Crawler/wwwroot/js/write.js b/src/Hc900Crawler/wwwroot/js/write.js index b0556d3..1818243 100644 --- a/src/Hc900Crawler/wwwroot/js/write.js +++ b/src/Hc900Crawler/wwwroot/js/write.js @@ -43,10 +43,17 @@ async function wrSetMode() { const mode = document.getElementById('wr-mode').value; if (!tagName) return log('wr-log', [{ c: 'err', t: '❌ 태그명을 입력하세요.' }]); + // 모드별 R/W 태그 매핑 (register-map의 .MD(읽기전용)이 아님) + // Auto/Manual → AutoManState, LSP/RSP → RemLocSPState, SP1/SP2 → SP_SelectState, Tune → TuneSetState + const modeMap = { '0': 'AutoManState', '1': 'AutoManState', '2': 'RemLocSPState', '3': 'SP_SelectState', '4': 'TuneSetState' }; + const valueMap = { '0': 0, '1': 1, '2': 1, '3': 1, '4': 1 }; // Auto=1, RSP=1, RSP/LSP=1, Tune=1 + const modeTag = modeMap[mode] ?? 'AutoManState'; + const writeValue = mode === '0' ? 0 : 1; // 0=Manual/LSP, 1=Auto/RSP + try { - const d = await api('POST', '/api/gateway/write', { controllerId, tagName: tagName + '.MODE', value: parseInt(mode) }); + const d = await api('POST', '/api/gateway/write', { controllerId, tagName: tagName + '.' + modeTag, value: writeValue }); log('wr-log', [ - { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + (d.error ? ' → ' + esc(d.error) : '') }, + { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + ' (' + modeTag + ')' + (d.error ? ' → ' + esc(d.error) : '') }, ]); } catch (e) { log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); diff --git a/src/Hc900Crawler/wwwroot/panes/setup.html b/src/Hc900Crawler/wwwroot/panes/setup.html index 39379f6..6077dcd 100644 --- a/src/Hc900Crawler/wwwroot/panes/setup.html +++ b/src/Hc900Crawler/wwwroot/panes/setup.html @@ -16,11 +16,11 @@ .setup-dot.yellow { background:#cc3; box-shadow:0 0 6px #cc3; } .setup-field { display:flex; align-items:center; gap:8px; margin-bottom:6px; } -.setup-field label { width:110px; font-size:12px; color:#888; flex-shrink:0; } +.setup-field label { width:70px; font-size:12px; color:#888; flex-shrink:0; } .setup-field input, .setup-field select { flex:1; background:#111; border:1px solid #444; color:#eee; padding:5px 8px; border-radius:3px; font-family:monospace; font-size:12px; } .setup-field input:focus { outline:none; border-color:#4af; } -.setup-field input.slim { width:90px; flex:none; } +.setup-field input.slim { width:64px; flex:none; } .setup-field .val { font-size:12px; color:#bbb; } .setup-btn { padding:5px 12px; border:none; border-radius:3px; cursor:pointer; font-family:monospace; @@ -104,9 +104,9 @@

➕ Add Controller

- + - +
@@ -116,13 +116,13 @@ - +
- +
diff --git a/src/Hc900Crawler/wwwroot/setup.html b/src/Hc900Crawler/wwwroot/setup.html index 688eebf..20a8217 100644 --- a/src/Hc900Crawler/wwwroot/setup.html +++ b/src/Hc900Crawler/wwwroot/setup.html @@ -30,11 +30,11 @@ h2 { color: #4af; margin: 0 0 20px; } /* ── Fields ── */ .field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } -.field label { width: 110px; font-size: 12px; color: #888; flex-shrink: 0; } +.field label { width: 70px; font-size: 12px; color: #888; flex-shrink: 0; } .field input, .field select { flex: 1; background: #111; border: 1px solid #444; color: #eee; padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; } .field input:focus { outline: none; border-color: #4af; } -.field input.slim { width: 90px; flex: none; } +.field input.slim { width: 64px; flex: none; } .field .val { font-size: 12px; color: #bbb; } /* ── Buttons ── */ diff --git a/src/Infrastructure/Control/FeedRampAdvisorService.cs b/src/Infrastructure/Control/FeedRampAdvisorService.cs index 0a67a43..b743032 100644 --- a/src/Infrastructure/Control/FeedRampAdvisorService.cs +++ b/src/Infrastructure/Control/FeedRampAdvisorService.cs @@ -25,7 +25,7 @@ public sealed class FeedRampAdvisorService if (cfg is null) return null; static string PvTag(string baseTag) - { var t = baseTag.ToLowerInvariant(); return t.EndsWith(".pv") ? t : t + ".pv"; } + { return baseTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase) ? baseTag : baseTag + ".pv"; } string picaTag = PvTag(cfg.PressureTag ?? "pica-6111"); const string piTag = "pi-6111b.pv", tiTag = "ti-6103.pv", steamTag = "ficq-6115.pv", opTag = "tica-6111a.op"; @@ -34,11 +34,10 @@ public sealed class FeedRampAdvisorService tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); var rows = (await _db.GetRealtimeRecordsByTagNamesAsync(tags)) - .ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); + .ToDictionary(r => r.TagName, r => r); bool TryRead(string tag, out double v) { - tag = tag.ToLowerInvariant(); if (_sim.Enabled && _sim.TryGet(tag, out v)) return true; // override(신선) if (rows.TryGetValue(tag, out var r) && r.LiveValue is not null && double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out v) diff --git a/src/Infrastructure/Control/FeedforwardConfigStore.cs b/src/Infrastructure/Control/FeedforwardConfigStore.cs index 117f633..0338a09 100644 --- a/src/Infrastructure/Control/FeedforwardConfigStore.cs +++ b/src/Infrastructure/Control/FeedforwardConfigStore.cs @@ -44,14 +44,12 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore var levelTags = rd.IsDBNull(5) ? Array.Empty() : rd.GetString(5) - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(t => t.ToLowerInvariant()).ToArray(); + .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) - .Select(t => t.ToLowerInvariant()).ToArray(); + : rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var cfg = new ColumnConfig { @@ -59,8 +57,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore Name = rd.GetString(1), Enabled = rd.GetBoolean(2), AdvisoryOnly = rd.GetBoolean(30), - FeedTag = rd.GetString(3).ToLowerInvariant(), - PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(), + FeedTag = rd.GetString(3), + PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4), LevelTags = levelTags, ScanSec = rd.GetDouble(6), FeedFilterTauSec = rd.GetDouble(7), @@ -72,10 +70,10 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore ProductKey = rd.GetString(13), Streams = Array.Empty(), TempTags = tempTags, - SensitiveTrayTag = rd.IsDBNull(15) ? null : rd.GetString(15).ToLowerInvariant(), + 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).ToLowerInvariant(), + SteamOpTag = rd.IsDBNull(18) ? null : rd.GetString(18), ThetaAutoTune = rd.GetBoolean(19), BiasMaWindowSec = rd.GetDouble(20), RecoveryEnabled = rd.GetBoolean(21), @@ -85,7 +83,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore RecoverySettleSec = rd.GetDouble(25), ReturnRampSec = rd.GetDouble(26), FeedRecoverySp = rd.GetDouble(27), - DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28).ToLowerInvariant(), + DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28), DeltaPFloodLimit = rd.GetDouble(29), TempHighLimit = rd.GetDouble(31), }; @@ -111,9 +109,9 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore entry.streams.Add(new StreamConfig { Key = rd.GetString(1), - FlowTag = rd.GetString(2).ToLowerInvariant(), + 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).ToLowerInvariant(), + 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), @@ -176,22 +174,22 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore @deltaPTag,@deltaPFlood,@tempHigh) RETURNING id """; - P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); - P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant()); + 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?.ToLowerInvariant() ?? 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?.ToLowerInvariant() ?? DBNull.Value); + 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?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@tempHigh",cfg.TempHighLimit); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); @@ -215,21 +213,21 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore WHERE id=@id """; P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); - P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); + P(cmd,"@feed",cfg.FeedTag); P(cmd,"@pres",(object?)cfg.PressureTag); P(cmd,"@advisory",cfg.AdvisoryOnly); - P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); + 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?.ToLowerInvariant() ?? 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?.ToLowerInvariant() ?? DBNull.Value); + 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?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@tempHigh",cfg.TempHighLimit); await cmd.ExecuteNonQueryAsync(ct); @@ -253,8 +251,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore 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.ToLowerInvariant()); - P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec); + 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()); diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index 12f5dce..afbc056 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -176,17 +176,25 @@ public sealed class FeedforwardSupervisor : BackgroundService { string PvTag(string baseTag) { - var t = baseTag.ToLowerInvariant(); - return t.EndsWith(".pv") ? t : t + ".pv"; + if (string.IsNullOrWhiteSpace(baseTag)) return baseTag; // 빈 태그는 그대로(호출부가 처리) + var result = baseTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase) ? baseTag : baseTag + ".pv"; + return result.ToUpperInvariant(); // register map 기준 대문자 통일 } var feedTag = PvTag(cfg.FeedTag); + if (string.IsNullOrWhiteSpace(feedTag)) + { + _logger.LogWarning("[FF] FeedTag 비어 있음 — column {Id} 처리 불가", cfg.Id); + return new PvSnapshot( + new TagSample("", double.NaN, Good: false, DateTime.MinValue), + null, Array.Empty(), new Dictionary()); + } var tags = new List { feedTag }; if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag)); tags.AddRange(cfg.LevelTags.Select(PvTag)); tags.AddRange(cfg.Streams.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!))); tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일 - if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로) + if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToUpperInvariant()); // WO-3 스팀 OP(.op) if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv) // WP6: DeltaPTag 미지정 시 PressureTag로부터 탑저압 파생 @@ -194,8 +202,8 @@ public sealed class FeedforwardSupervisor : BackgroundService if (cfg.DeltaPTag is null && cfg.PressureTag is not null) { // pica-{n} → pi-{n}b (C-6111 명명 규약) - var p = cfg.PressureTag.ToLowerInvariant(); - if (p.StartsWith("pica-")) + var p = cfg.PressureTag; + if (p.StartsWith("pica-", StringComparison.OrdinalIgnoreCase)) { var num = p["pica-".Length..]; derivedBottomTag = "pi-" + num + "b"; @@ -204,14 +212,14 @@ public sealed class FeedforwardSupervisor : BackgroundService } var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags)) - .ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); + .ToDictionary(r => r.TagName, r => r); TagSample Sample(string baseTag) { var tag = PvTag(baseTag); if (_sim.Enabled && _sim.TryGet(tag, out var sov)) // WP0 확장: override 우선(신선 처리) return new TagSample(tag, sov, Good: true, DateTime.UtcNow); - if (rows.TryGetValue(tag.ToLowerInvariant(), out var r) + 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; @@ -223,7 +231,7 @@ public sealed class FeedforwardSupervisor : BackgroundService // WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음 TagSample SampleExact(string rawTag) { - var tag = rawTag.ToLowerInvariant(); + var tag = rawTag.ToUpperInvariant(); // register map 기준 대문자 통일 if (_sim.Enabled && _sim.TryGet(tag, out var sov)) // WP0 확장: override 우선 return new TagSample(tag, sov, Good: true, DateTime.UtcNow); if (rows.TryGetValue(tag, out var r) diff --git a/src/Infrastructure/Control/SimOverrideStore.cs b/src/Infrastructure/Control/SimOverrideStore.cs index a00ea09..e381588 100644 --- a/src/Infrastructure/Control/SimOverrideStore.cs +++ b/src/Infrastructure/Control/SimOverrideStore.cs @@ -17,7 +17,7 @@ public sealed class SimOverrideStore : ISimOverrideStore public void SetMany(bool enabled, IReadOnlyDictionary values) { - foreach (var kv in values) _values[kv.Key.ToLowerInvariant()] = kv.Value; + foreach (var kv in values) _values[kv.Key] = kv.Value; _enabled = enabled; } @@ -27,7 +27,7 @@ public sealed class SimOverrideStore : ISimOverrideStore _enabled = false; } - public bool TryGet(string tag, out double value) => _values.TryGetValue(tag.ToLowerInvariant(), out value); + public bool TryGet(string tag, out double value) => _values.TryGetValue(tag, out value); public IReadOnlyDictionary Snapshot() => new Dictionary(_values); } diff --git a/src/Infrastructure/Hc900/Hc900RealtimeService.cs b/src/Infrastructure/Hc900/Hc900RealtimeService.cs index d8ee5d5..1f96640 100644 --- a/src/Infrastructure/Hc900/Hc900RealtimeService.cs +++ b/src/Infrastructure/Hc900/Hc900RealtimeService.cs @@ -76,10 +76,19 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService ? await client.ReadTagsAsync(new ReadTagsRequest { TagNames = { tagNames } }) : client.ReadTags(new ReadTagsRequest()); - _connected[controllerId] = true; + // 프로세스가 살아 있으면 ReadTags는 (캐시 반환으로) 성공하므로 그것만으로는 + // 연결 판정이 안 된다. 실제 Modbus 연결 여부는 게이트웨이 HealthCheck(SERVING)로 판정. + try + { + var health = await client.HealthCheckAsync(new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(2)); + _connected[controllerId] = + health.Status == HealthCheckResponse.Types.ServingStatus.Serving; + } + catch { _connected[controllerId] = false; } _lastPollAt[controllerId] = DateTime.UtcNow; - if (resp.Values.Count > 0) + if (_connected[controllerId] && resp.Values.Count > 0) await BatchUpdateRealtimeTableAsync(controllerId, resp.Values, mapping, ct); // poll count는 gRPC ReadTags + DB write 모두 성공한 후에만 증가 @@ -201,7 +210,8 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService } else { - var tagname = tv.TagName.ToLowerInvariant(); + // No mapping row → use the gateway tag as-is (= the register/Experion name). + var tagname = tv.TagName; rows.Add((tagname, FormatValue(tv, tagname), ts)); } }