refactor: 태그 대소문자 register-map 기준 대문자로 전역 통일

- 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
This commit is contained in:
windpacer
2026-06-04 09:43:37 +09:00
parent 4348fb49f8
commit daeb5316a2
13 changed files with 114 additions and 134 deletions

View File

@@ -1,15 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
xlsx의 StatusPoint DescriptorState0~7 → hc900.tag_metadata 로드 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 openpyxl
import psycopg2 import psycopg2
import argparse
from pathlib import Path from pathlib import Path
XLSX_PATH = Path(__file__).parent.parent / "docs" / "Sinam_Tag_all.xlsx" XLSX_PATH = Path(__file__).parent.parent / "docs" / "Sinam_Tag_all.xlsx"
DB_DSN = "host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres" 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) wb = openpyxl.load_workbook(XLSX_PATH, read_only=True, data_only=True)
ws = wb['Sheet1'] ws = wb['Sheet1']
rows = list(ws.iter_rows(values_only=True)) rows = list(ws.iter_rows(values_only=True))
@@ -26,7 +32,8 @@ for row in rows[2:]:
if cls != 'StatusPoint' or not name: if cls != 'StatusPoint' or not name:
continue continue
base_tag = name.lower() # 원형(ItemName) 유지 — 소문자 변환 금지
base_tag = name
for i in range(8): for i in range(8):
key = f'DescriptorState{i}' key = f'DescriptorState{i}'
@@ -36,13 +43,13 @@ for row in rows[2:]:
if val is None or val == '': if val is None or val == '':
continue continue
cur.execute(""" cur.execute("""
INSERT INTO hc900.tag_metadata (base_tag, attribute, value) INSERT INTO hc900.tag_metadata (base_tag, attribute, value, controller_id)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()
""", (base_tag, f'state{i}', str(val))) """, (base_tag, f'state{i}', str(val), args.controller))
inserted += 1 inserted += 1
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
print(f"완료: {inserted}개 상태 레이블 저장") print(f"완료: {inserted}개 상태 레이블 저장 (controller={args.controller})")

View File

@@ -378,10 +378,10 @@ public class PidExtractorService : IPidExtractorService
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo) private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
{ {
var normalized = tagNo.Split('.')[0].ToLowerInvariant(); var baseTag = tagNo.Split('.')[0];
return await _dbContext.RealtimePoints return await _dbContext.RealtimePoints
.FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized .FirstOrDefaultAsync(t => t.TagName.Equals(baseTag, StringComparison.OrdinalIgnoreCase)
|| t.TagName.ToLower().StartsWith(normalized + ".")); || t.TagName.StartsWith(baseTag + ".", StringComparison.OrdinalIgnoreCase));
} }
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync( public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
@@ -425,15 +425,15 @@ public class PidExtractorService : IPidExtractorService
// batch-load sub_area from tag_metadata // batch-load sub_area from tag_metadata
if (items.Count > 0) 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 var subAreas = await _dbContext.TagMetadata
.Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area") .Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area")
.Select(m => new { m.BaseTag, m.Value }) .Select(m => new { m.BaseTag, m.Value })
.ToListAsync(); .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) 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; e.SubArea = sa;
} }
} }
@@ -471,7 +471,7 @@ public class PidExtractorService : IPidExtractorService
{ {
var e = new PidEquipment var e = new PidEquipment
{ {
TagNo = request.TagNo.ToLowerInvariant(), TagNo = request.TagNo,
EquipmentName = request.EquipmentName, EquipmentName = request.EquipmentName,
InstrumentType = request.InstrumentType, InstrumentType = request.InstrumentType,
Category = request.Category, Category = request.Category,
@@ -499,7 +499,7 @@ public class PidExtractorService : IPidExtractorService
{ {
var e = await _dbContext.PidEquipment.FindAsync(id); var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return false; 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.EquipmentName != null) e.EquipmentName = request.EquipmentName;
if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType; if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType;
if (request.Category != null) e.Category = request.Category; if (request.Category != null) e.Category = request.Category;
@@ -518,7 +518,7 @@ public class PidExtractorService : IPidExtractorService
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
if (request.SubArea != null) if (request.SubArea != null)
{ {
var baseTag = e.TagNo.ToLowerInvariant(); var baseTag = e.TagNo;
if (string.IsNullOrWhiteSpace(request.SubArea)) if (string.IsNullOrWhiteSpace(request.SubArea))
await _dbContext.Database.ExecuteSqlRawAsync( await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag); "DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);

View File

@@ -186,12 +186,12 @@ public class Hc900MapEntry
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; } public int Id { get; set; }
/// <summary>OPC UA 스타일 이름 (realtime_table 기준, e.g. "ficq-6101.pv")</summary> /// <summary>태그명 (realtime_table 기준) = hc900_tag 와 동일한 Experion ItemName, e.g. "FICQ-6101.PV". 대소문자 변환 없음.</summary>
[Column("tagname")] [Column("tagname")]
[Required] [Required]
public string TagName { get; set; } = string.Empty; public string TagName { get; set; } = string.Empty;
/// <summary>HC900 레지스터 태그명 (register-map.json 기준, e.g. "FICQ3101.PV")</summary> /// <summary>HC900 게이트웨이 register-map 태그명 (= tagname), e.g. "FICQ-6101.PV"</summary>
[Column("hc900_tag")] [Column("hc900_tag")]
[Required] [Required]
public string Hc900Tag { get; set; } = string.Empty; public string Hc900Tag { get; set; } = string.Empty;

View File

@@ -247,7 +247,24 @@ public class Hc900TagManagerController : ControllerBase
if (!string.IsNullOrEmpty(search)) if (!string.IsNullOrEmpty(search))
q = q.Where(x => x.TagName.Contains(search) || x.Hc900Tag.Contains(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 tagNames = entries.Select(e => e.TagName).ToList();
var liveValues = await _ctx.RealtimePoints var liveValues = await _ctx.RealtimePoints

View File

@@ -239,73 +239,7 @@ async function addController() {
document.getElementById('new-regmap').value = ''; 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() { async function refreshLog() {
const sel = document.getElementById('log-ctrl-select'); const sel = document.getElementById('log-ctrl-select');

View File

@@ -43,10 +43,17 @@ async function wrSetMode() {
const mode = document.getElementById('wr-mode').value; const mode = document.getElementById('wr-mode').value;
if (!tagName) return log('wr-log', [{ c: 'err', t: '❌ 태그명을 입력하세요.' }]); 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 { 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', [ 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) { } catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]); log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);

View File

@@ -16,11 +16,11 @@
.setup-dot.yellow { background:#cc3; box-shadow:0 0 6px #cc3; } .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 { 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; .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; } border-radius:3px; font-family:monospace; font-size:12px; }
.setup-field input:focus { outline:none; border-color:#4af; } .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-field .val { font-size:12px; color:#bbb; }
.setup-btn { padding:5px 12px; border:none; border-radius:3px; cursor:pointer; font-family:monospace; .setup-btn { padding:5px 12px; border:none; border-radius:3px; cursor:pointer; font-family:monospace;
@@ -104,9 +104,9 @@
<h3> Add Controller</h3> <h3> Add Controller</h3>
<div class="setup-field"> <div class="setup-field">
<label>Controller ID</label> <label>Controller ID</label>
<input id="new-id" type="text" placeholder="HC2" style="width:80px;flex:none"> <input id="new-id" type="text" placeholder="C5" style="width:80px;flex:none">
<label style="width:auto">Name</label> <label style="width:auto">Name</label>
<input id="new-name" type="text" placeholder="Column 2" style="flex:1"> <input id="new-name" type="text" placeholder="HC900 C5 Controller" style="flex:1">
</div> </div>
<div class="setup-field"> <div class="setup-field">
<label>IP</label> <label>IP</label>
@@ -116,13 +116,13 @@
<label>Modbus Port</label> <label>Modbus Port</label>
<input id="new-port" type="number" value="502" class="slim"> <input id="new-port" type="number" value="502" class="slim">
<label style="width:auto">gRPC Port</label> <label style="width:auto">gRPC Port</label>
<input id="new-grpc" type="number" value="50052" class="slim"> <input id="new-grpc" type="number" value="50055" class="slim">
<label style="width:auto">Poll (ms)</label> <label style="width:auto">Poll (ms)</label>
<input id="new-poll" type="number" value="1000" class="slim"> <input id="new-poll" type="number" value="1000" class="slim">
</div> </div>
<div class="setup-field"> <div class="setup-field">
<label>Register Map</label> <label>Register Map</label>
<input id="new-regmap" type="text" placeholder="docs/register-map.json"> <input id="new-regmap" type="text" placeholder="docs/register-map-c5.json">
</div> </div>
<div class="setup-btn-row"> <div class="setup-btn-row">
<button class="setup-btn success" onclick="addController()"> Add</button> <button class="setup-btn success" onclick="addController()"> Add</button>

View File

@@ -30,11 +30,11 @@ h2 { color: #4af; margin: 0 0 20px; }
/* ── Fields ── */ /* ── Fields ── */
.field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .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; .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; } border-radius: 3px; font-family: monospace; font-size: 12px; }
.field input:focus { outline: none; border-color: #4af; } .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; } .field .val { font-size: 12px; color: #bbb; }
/* ── Buttons ── */ /* ── Buttons ── */

View File

@@ -25,7 +25,7 @@ public sealed class FeedRampAdvisorService
if (cfg is null) return null; if (cfg is null) return null;
static string PvTag(string baseTag) 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"); 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"; 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))); tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
var rows = (await _db.GetRealtimeRecordsByTagNamesAsync(tags)) 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) bool TryRead(string tag, out double v)
{ {
tag = tag.ToLowerInvariant();
if (_sim.Enabled && _sim.TryGet(tag, out v)) return true; // override(신선) if (_sim.Enabled && _sim.TryGet(tag, out v)) return true; // override(신선)
if (rows.TryGetValue(tag, out var r) && r.LiveValue is not null if (rows.TryGetValue(tag, out var r) && r.LiveValue is not null
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out v) && double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out v)

View File

@@ -44,14 +44,12 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
var levelTags = rd.IsDBNull(5) var levelTags = rd.IsDBNull(5)
? Array.Empty<string>() ? Array.Empty<string>()
: rd.GetString(5) : rd.GetString(5)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
.Select(t => t.ToLowerInvariant()).ToArray();
var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14); var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14);
var tempTags = rawTempTags is null var tempTags = rawTempTags is null
? Array.Empty<string>() ? Array.Empty<string>()
: rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
.Select(t => t.ToLowerInvariant()).ToArray();
var cfg = new ColumnConfig var cfg = new ColumnConfig
{ {
@@ -59,8 +57,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
Name = rd.GetString(1), Name = rd.GetString(1),
Enabled = rd.GetBoolean(2), Enabled = rd.GetBoolean(2),
AdvisoryOnly = rd.GetBoolean(30), AdvisoryOnly = rd.GetBoolean(30),
FeedTag = rd.GetString(3).ToLowerInvariant(), FeedTag = rd.GetString(3),
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(), PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4),
LevelTags = levelTags, LevelTags = levelTags,
ScanSec = rd.GetDouble(6), ScanSec = rd.GetDouble(6),
FeedFilterTauSec = rd.GetDouble(7), FeedFilterTauSec = rd.GetDouble(7),
@@ -72,10 +70,10 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
ProductKey = rd.GetString(13), ProductKey = rd.GetString(13),
Streams = Array.Empty<StreamConfig>(), Streams = Array.Empty<StreamConfig>(),
TempTags = tempTags, TempTags = tempTags,
SensitiveTrayTag = rd.IsDBNull(15) ? null : rd.GetString(15).ToLowerInvariant(), SensitiveTrayTag = rd.IsDBNull(15) ? null : rd.GetString(15),
DTdP = rd.GetDouble(16), DTdP = rd.GetDouble(16),
PRef = rd.IsDBNull(17) ? double.NaN : rd.GetDouble(17), 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), ThetaAutoTune = rd.GetBoolean(19),
BiasMaWindowSec = rd.GetDouble(20), BiasMaWindowSec = rd.GetDouble(20),
RecoveryEnabled = rd.GetBoolean(21), RecoveryEnabled = rd.GetBoolean(21),
@@ -85,7 +83,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
RecoverySettleSec = rd.GetDouble(25), RecoverySettleSec = rd.GetDouble(25),
ReturnRampSec = rd.GetDouble(26), ReturnRampSec = rd.GetDouble(26),
FeedRecoverySp = rd.GetDouble(27), 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), DeltaPFloodLimit = rd.GetDouble(29),
TempHighLimit = rd.GetDouble(31), TempHighLimit = rd.GetDouble(31),
}; };
@@ -111,9 +109,9 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
entry.streams.Add(new StreamConfig entry.streams.Add(new StreamConfig
{ {
Key = rd.GetString(1), Key = rd.GetString(1),
FlowTag = rd.GetString(2).ToLowerInvariant(), FlowTag = rd.GetString(2),
Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor, Role = Enum.TryParse<StreamRole>(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), IsReflux = rd.GetBoolean(15),
RecoverySp = rd.IsDBNull(16) ? double.NaN : rd.GetDouble(16), RecoverySp = rd.IsDBNull(16) ? double.NaN : rd.GetDouble(16),
SpNodeId = rd.IsDBNull(17) ? null : rd.GetString(17), SpNodeId = rd.IsDBNull(17) ? null : rd.GetString(17),
@@ -176,22 +174,22 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
@deltaPTag,@deltaPFlood,@tempHigh) @deltaPTag,@deltaPFlood,@tempHigh)
RETURNING id RETURNING id
"""; """;
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag);
P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag); P(cmd,"@lvl",levelTags);
P(cmd,"@advisory",cfg.AdvisoryOnly); P(cmd,"@advisory",cfg.AdvisoryOnly);
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); 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,"@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,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value); 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,"@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,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm); P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec); P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec); P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
P(cmd,"@feedRecSp",cfg.FeedRecoverySp); 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,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit); P(cmd,"@tempHigh",cfg.TempHighLimit);
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
@@ -215,21 +213,21 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
WHERE id=@id WHERE id=@id
"""; """;
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); 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,"@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,"@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,"@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,"@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,"@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,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm); P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec); P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec); P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
P(cmd,"@feedRecSp",cfg.FeedRecoverySp); 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,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit); P(cmd,"@tempHigh",cfg.TempHighLimit);
await cmd.ExecuteNonQueryAsync(ct); 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, VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag,
@isReflux,@recSp,@spNode) @isReflux,@recSp,@spNode)
"""; """;
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant()); 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?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec); 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,"@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,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
P(ins,"@grade",s.Grade.ToString()); P(ins,"@grade",s.Grade.ToString());

View File

@@ -176,17 +176,25 @@ public sealed class FeedforwardSupervisor : BackgroundService
{ {
string PvTag(string baseTag) string PvTag(string baseTag)
{ {
var t = baseTag.ToLowerInvariant(); if (string.IsNullOrWhiteSpace(baseTag)) return baseTag; // 빈 태그는 그대로(호출부가 처리)
return t.EndsWith(".pv") ? t : t + ".pv"; var result = baseTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase) ? baseTag : baseTag + ".pv";
return result.ToUpperInvariant(); // register map 기준 대문자 통일
} }
var feedTag = PvTag(cfg.FeedTag); 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<TagSample>(), new Dictionary<string, TagSample>());
}
var tags = new List<string> { feedTag }; var tags = new List<string> { feedTag };
if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag)); if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag));
tags.AddRange(cfg.LevelTags.Select(PvTag)); 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.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!)));
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
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 그대로) 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) if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv)
// WP6: DeltaPTag 미지정 시 PressureTag로부터 탑저압 파생 // WP6: DeltaPTag 미지정 시 PressureTag로부터 탑저압 파생
@@ -194,8 +202,8 @@ public sealed class FeedforwardSupervisor : BackgroundService
if (cfg.DeltaPTag is null && cfg.PressureTag is not null) if (cfg.DeltaPTag is null && cfg.PressureTag is not null)
{ {
// pica-{n} → pi-{n}b (C-6111 명명 규약) // pica-{n} → pi-{n}b (C-6111 명명 규약)
var p = cfg.PressureTag.ToLowerInvariant(); var p = cfg.PressureTag;
if (p.StartsWith("pica-")) if (p.StartsWith("pica-", StringComparison.OrdinalIgnoreCase))
{ {
var num = p["pica-".Length..]; var num = p["pica-".Length..];
derivedBottomTag = "pi-" + num + "b"; derivedBottomTag = "pi-" + num + "b";
@@ -204,14 +212,14 @@ public sealed class FeedforwardSupervisor : BackgroundService
} }
var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags)) var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); .ToDictionary(r => r.TagName, r => r);
TagSample Sample(string baseTag) TagSample Sample(string baseTag)
{ {
var tag = PvTag(baseTag); var tag = PvTag(baseTag);
if (_sim.Enabled && _sim.TryGet(tag, out var sov)) // WP0 확장: override 우선(신선 처리) if (_sim.Enabled && _sim.TryGet(tag, out var sov)) // WP0 확장: override 우선(신선 처리)
return new TagSample(tag, sov, Good: true, DateTime.UtcNow); 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)) && double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
{ {
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec; bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
@@ -223,7 +231,7 @@ public sealed class FeedforwardSupervisor : BackgroundService
// WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음 // WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
TagSample SampleExact(string rawTag) 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 우선 if (_sim.Enabled && _sim.TryGet(tag, out var sov)) // WP0 확장: override 우선
return new TagSample(tag, sov, Good: true, DateTime.UtcNow); return new TagSample(tag, sov, Good: true, DateTime.UtcNow);
if (rows.TryGetValue(tag, out var r) if (rows.TryGetValue(tag, out var r)

View File

@@ -17,7 +17,7 @@ public sealed class SimOverrideStore : ISimOverrideStore
public void SetMany(bool enabled, IReadOnlyDictionary<string, double> values) public void SetMany(bool enabled, IReadOnlyDictionary<string, double> values)
{ {
foreach (var kv in values) _values[kv.Key.ToLowerInvariant()] = kv.Value; foreach (var kv in values) _values[kv.Key] = kv.Value;
_enabled = enabled; _enabled = enabled;
} }
@@ -27,7 +27,7 @@ public sealed class SimOverrideStore : ISimOverrideStore
_enabled = false; _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<string, double> Snapshot() => new Dictionary<string, double>(_values); public IReadOnlyDictionary<string, double> Snapshot() => new Dictionary<string, double>(_values);
} }

View File

@@ -76,10 +76,19 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService
? await client.ReadTagsAsync(new ReadTagsRequest { TagNames = { tagNames } }) ? await client.ReadTagsAsync(new ReadTagsRequest { TagNames = { tagNames } })
: client.ReadTags(new ReadTagsRequest()); : 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; _lastPollAt[controllerId] = DateTime.UtcNow;
if (resp.Values.Count > 0) if (_connected[controllerId] && resp.Values.Count > 0)
await BatchUpdateRealtimeTableAsync(controllerId, resp.Values, mapping, ct); await BatchUpdateRealtimeTableAsync(controllerId, resp.Values, mapping, ct);
// poll count는 gRPC ReadTags + DB write 모두 성공한 후에만 증가 // poll count는 gRPC ReadTags + DB write 모두 성공한 후에만 증가
@@ -201,7 +210,8 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService
} }
else 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)); rows.Add((tagname, FormatValue(tv, tagname), ts));
} }
} }