Initial commit: HC900 Crawler
Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL. 기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체. - industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버) - src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer) - mcp-server: Python FastMCP (RAG/NL2SQL/P&ID) - 다중 컨트롤러(N-Controller) 지원 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
271
scripts/build_c4_mapping.py
Normal file
271
scripts/build_c4_mapping.py
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
C4 컨트롤러 Experion 태그 → HC900 Modbus 레지스터 매핑 CSV 생성
|
||||
|
||||
입력:
|
||||
docs/C4-All-Modbus-Map.csv - HC900 C4 Modbus 레지스터 전체 맵
|
||||
docs/Sinam_Tag_all.xlsx - Experion 태그 목록 (SourceAddressPV/OP/MD 컬럼)
|
||||
|
||||
출력:
|
||||
docs/c4_tag_mapping.csv - hc900_map_master 적재용 매핑 테이블
|
||||
|
||||
전략:
|
||||
AnalogPoint (PID Loop) → 루프 번호로 표준 파라미터 세트 생성
|
||||
(PV / SP / OP / RSP / MODE / STATUS)
|
||||
StatusPoint (Signal Tag) → C4 TAG N → Signal Tag 주소 직접 매핑
|
||||
MATH_VAR (Variable) → C4 MATH_VAR N → Variable 주소 직접 매핑
|
||||
"""
|
||||
|
||||
import csv, re, openpyxl, argparse
|
||||
from pathlib import Path
|
||||
from collections import OrderedDict
|
||||
|
||||
# ── 경로 설정 ─────────────────────────────────────────────────────────────
|
||||
BASE = Path(__file__).parent.parent / 'docs'
|
||||
|
||||
# ── AnalogPoint 표준 파라미터 세트 ──────────────────────────────────────────
|
||||
# (tagname_suffix, hc900_suffix, offset_hex, dtype, access, param_type, description)
|
||||
ANALOG_PARAMS = [
|
||||
('pv', 'PV', 0x0000, 'float32', 'R', 'PV', 'Process Variable'),
|
||||
('sp', 'WSP', 0x0004, 'float32', 'R/W', 'SP', 'Working Set Point'),
|
||||
('op', 'Output', 0x0006, 'float32', 'R/W', 'OP', 'Output'),
|
||||
('rsp', 'RSP_SP2', 0x0002, 'float32', 'R/W', 'RSP', 'Remote Set Point (SP2)'),
|
||||
('lsp1', 'LSP1', 0x002A, 'float32', 'R/W', 'LSP1', 'Local SP 1'),
|
||||
('lsp2', 'LSP2', 0x002C, 'float32', 'R/W', 'LSP2', 'Local SP 2'),
|
||||
('dev', 'Deviation', 0x004A, 'float32', 'R', 'DEV', 'Deviation (SP-PV)'),
|
||||
('pv_lo', 'PV_low_range', 0x0016, 'float32', 'R', 'PV_LO', 'PV Low Range'),
|
||||
('pv_hi', 'PV_high_range', 0x0018, 'float32', 'R', 'PV_HI', 'PV High Range'),
|
||||
('sp_lo', 'SP_low_limit', 0x0034, 'float32', 'R/W', 'SP_LO', 'SP Low Limit'),
|
||||
('sp_hi', 'SP_high_limit', 0x0036, 'float32', 'R/W', 'SP_HI', 'SP High Limit'),
|
||||
('op_lo', 'Output_Low_Limit', 0x003A, 'float32', 'R/W', 'OP_LO', 'Output Low Limit'),
|
||||
('op_hi', 'Output_High_Limit', 0x003C, 'float32', 'R/W', 'OP_HI', 'Output High Limit'),
|
||||
('alm1', 'Alarm_1_SP1', 0x001A, 'float32', 'R/W', 'ALM1', 'Alarm 1 SP1'),
|
||||
('alm2', 'Alarm_2_SP1', 0x002E, 'float32', 'R/W', 'ALM2', 'Alarm 2 SP1'),
|
||||
('mode', 'Auto_Man_State', 0x00BA, 'uint16', 'R/W', 'MODE', 'Auto/Manual State (0=Man,1=Auto)'),
|
||||
('status', 'Loop_Status_Register', 0x00BE, 'uint16', 'R', 'STATUS', 'Loop Status Register'),
|
||||
]
|
||||
|
||||
# Experion 소스 파라미터명 → HC900 오프셋 (SourceAddressMD 처리용)
|
||||
EXP_PARAM_MAP = {
|
||||
'RESET1': (0x0010, 'Reset_1', 'float32', 'R/W', 'RESET1', 'Reset 1'),
|
||||
'RATE1': (0x0012, 'Rate_1', 'float32', 'R/W', 'RATE1', 'Rate 1'),
|
||||
'AMSTAT': (0x00BA, 'Auto_Man_State', 'uint16', 'R/W', 'MODE', 'Auto/Manual State'),
|
||||
'LOOPSTAT': (0x00BE, 'Loop_Status_Register', 'uint16', 'R', 'STATUS', 'Loop Status Register'),
|
||||
'PV': (0x0000, 'PV', 'float32', 'R', 'PV', 'Process Variable'),
|
||||
'WSP': (0x0004, 'WSP', 'float32', 'R/W', 'SP', 'Working Set Point'),
|
||||
'OP': (0x0006, 'Output', 'float32', 'R/W', 'OP', 'Output'),
|
||||
}
|
||||
|
||||
def load_modbus_map(csv_path: Path):
|
||||
"""C4-All-Modbus-Map.csv → loop_map, sig_map, var_map"""
|
||||
with open(csv_path, encoding='utf-8-sig') as f:
|
||||
rows = list(csv.reader(f))
|
||||
header_idx = next(i for i,r in enumerate(rows) if r and r[0]=='Hex Addr')
|
||||
data = rows[header_idx+1:]
|
||||
|
||||
loop_map = {} # loop_num → {'tag': str, 'base': int, 'desc': str}
|
||||
sig_map = {} # tag_num → {'tag': str, 'addr': int, 'dtype': str, 'desc': str}
|
||||
var_map = {} # var_num → {'tag': str, 'addr': int, 'desc': str}
|
||||
|
||||
for r in data:
|
||||
if len(r) < 9: continue
|
||||
partition = r[2].strip()
|
||||
tag_name = r[3].strip()
|
||||
if not tag_name: continue
|
||||
|
||||
addr_hex = r[0].strip()
|
||||
try:
|
||||
addr = int(addr_hex, 16)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
desc = r[4].strip()
|
||||
dtype = r[7].strip()
|
||||
access = r[8].strip()
|
||||
|
||||
# PID Loop 1-24
|
||||
if partition == 'Loops 1-24' and tag_name.endswith('.PV'):
|
||||
n = (addr - 0x0040) // 0x0100 + 1
|
||||
base_tag = tag_name.replace('.PV', '')
|
||||
loop_map[n] = {'tag': base_tag, 'base': addr, 'desc': desc}
|
||||
|
||||
# PID Loop 25-32
|
||||
elif partition == 'Loops 25-32' and tag_name.endswith('.PV'):
|
||||
n = (addr - 0x7840) // 0x0100 + 25
|
||||
base_tag = tag_name.replace('.PV', '')
|
||||
loop_map[n] = {'tag': base_tag, 'base': addr, 'desc': desc}
|
||||
|
||||
# Signal Tags 1-1000
|
||||
elif partition == 'Signal Tags 1-1000':
|
||||
n = (addr - 0x2000) // 2 + 1
|
||||
dt = 'float32' if 'float' in dtype.lower() else 'uint16'
|
||||
sig_map[n] = {'tag': tag_name, 'addr': addr, 'dtype': dt,
|
||||
'access': access, 'desc': desc}
|
||||
|
||||
# Variables 1-600
|
||||
elif partition == 'Variables 1-600':
|
||||
n = (addr - 0x18C0) // 2 + 1
|
||||
var_map[n] = {'tag': tag_name, 'addr': addr, 'desc': desc}
|
||||
|
||||
return loop_map, sig_map, var_map
|
||||
|
||||
|
||||
def parse_indexed(src: str):
|
||||
"""Indexed Address → (partition, n, param)"""
|
||||
m = re.match(r'\S+\s+(LOOP|LOOPX)\s+(\d+)\s+(\w+)', src.strip())
|
||||
if m: return m.group(1), int(m.group(2)), m.group(3)
|
||||
m = re.match(r'\S+\s+TAG\s+(\d+)\s+VALUE', src.strip())
|
||||
if m: return 'TAG', int(m.group(1)), 'VALUE'
|
||||
m = re.match(r'\S+\s+MATH_VAR\s+(\d+)\s+VALUE', src.strip())
|
||||
if m: return 'MATH_VAR', int(m.group(1)), 'VALUE'
|
||||
return None, None, None
|
||||
|
||||
|
||||
def get_loop_num(spv: str, smd: str) -> tuple[str|None, int|None]:
|
||||
"""SourceAddressPV/MD 에서 루프 번호 추출 → (partition, n)"""
|
||||
for src in [spv, smd]:
|
||||
if not src: continue
|
||||
part, n, _ = parse_indexed(src)
|
||||
if part in ('LOOP', 'LOOPX'):
|
||||
return part, n
|
||||
return None, None
|
||||
|
||||
|
||||
def build_mapping(modbus_csv: Path, sinam_xlsx: Path) -> list[dict]:
|
||||
loop_map, sig_map, var_map = load_modbus_map(modbus_csv)
|
||||
|
||||
wb = openpyxl.load_workbook(sinam_xlsx, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
xlsx_rows = list(ws.iter_rows(values_only=True))
|
||||
headers = xlsx_rows[1]
|
||||
col = {h: i for i, h in enumerate(headers) if h}
|
||||
|
||||
rows_out = []
|
||||
seen_tagnames = set()
|
||||
|
||||
def emit(tagname: str, hc900_tag: str, modbus_addr: int,
|
||||
dtype: str, access: str, loop_no, param_type: str,
|
||||
description: str, experion_src: str):
|
||||
if tagname in seen_tagnames:
|
||||
return
|
||||
seen_tagnames.add(tagname)
|
||||
rows_out.append(OrderedDict([
|
||||
('tagname', tagname),
|
||||
('hc900_tag', hc900_tag),
|
||||
('modbus_addr', modbus_addr),
|
||||
('modbus_addr_hex', f'0x{modbus_addr:04X}'),
|
||||
('data_type', dtype),
|
||||
('access', access),
|
||||
('loop_no', loop_no if loop_no else ''),
|
||||
('param_type', param_type),
|
||||
('description', description),
|
||||
('experion_src', experion_src),
|
||||
('is_active', 'TRUE'),
|
||||
]))
|
||||
|
||||
for row in xlsx_rows[2:]:
|
||||
item_name = row[col['ItemName']]
|
||||
cls = row[col['Class']]
|
||||
spv = str(row[col['SourceAddressPV']] or '').strip()
|
||||
sop = str(row[col['SourceAddressOP']] or '').strip()
|
||||
smd = str(row[col['SourceAddressMD']] or '').strip()
|
||||
|
||||
if not item_name or not cls: continue
|
||||
# C4 참조만 처리
|
||||
if not any('C4 ' in s for s in [spv, sop, smd]):
|
||||
continue
|
||||
|
||||
base_name = str(item_name).strip().lower()
|
||||
|
||||
# ── AnalogPoint (PID Loop) ────────────────────────────────────────
|
||||
if cls == 'AnalogPoint':
|
||||
loop_part, loop_n = get_loop_num(spv, smd)
|
||||
if loop_n is None:
|
||||
continue
|
||||
linfo = loop_map.get(loop_n)
|
||||
if linfo is None:
|
||||
continue
|
||||
|
||||
base_tag = linfo['tag']
|
||||
base_addr = linfo['base']
|
||||
|
||||
# 표준 파라미터 세트 생성
|
||||
for suffix, hc_suffix, offset, dtype, access, ptype, pdesc in ANALOG_PARAMS:
|
||||
tagname = f'{base_name}.{suffix}'
|
||||
hc900_tag = f'{base_tag}.{hc_suffix}'
|
||||
addr = base_addr + offset
|
||||
src_str = f'{loop_part} {loop_n} → {base_tag}'
|
||||
emit(tagname, hc900_tag, addr, dtype, access, loop_n, ptype, pdesc, src_str)
|
||||
|
||||
# ── StatusPoint / 기타 ────────────────────────────────────────────
|
||||
else:
|
||||
processed_srcs = set()
|
||||
for src in [spv, sop, smd]:
|
||||
if not src or 'C4 ' not in src or src in processed_srcs:
|
||||
continue
|
||||
processed_srcs.add(src)
|
||||
part, n, param = parse_indexed(src)
|
||||
if part is None:
|
||||
continue
|
||||
|
||||
if part == 'TAG':
|
||||
info = sig_map.get(n)
|
||||
hc900_tag = info['tag'] if info else f'SIG_TAG_{n}'
|
||||
addr = 0x2000 + (n-1)*2
|
||||
dtype = info['dtype'] if info else 'float32'
|
||||
access = 'R'
|
||||
pdesc = info['desc'] if info else ''
|
||||
emit(base_name, hc900_tag, addr, dtype, access, None, 'SIG', pdesc, src)
|
||||
|
||||
elif part == 'MATH_VAR':
|
||||
info = var_map.get(n)
|
||||
hc900_tag = info['tag'] if info else f'VAR_{n}'
|
||||
addr = 0x18C0 + (n-1)*2
|
||||
pdesc = info['desc'] if info else ''
|
||||
emit(base_name, hc900_tag, addr, 'float32', 'R/W', None, 'VAR', pdesc, src)
|
||||
|
||||
elif part in ('LOOP', 'LOOPX'):
|
||||
# AnalogPoint가 아닌데 LOOP 소스 참조 (드물지만 처리)
|
||||
linfo = loop_map.get(n)
|
||||
if linfo is None: continue
|
||||
pinfo = EXP_PARAM_MAP.get(param)
|
||||
if pinfo is None: continue
|
||||
off, hc_suffix, dtype, access, ptype, pdesc = pinfo
|
||||
addr = linfo['base'] + off
|
||||
tagname = f'{base_name}' if src == spv else f'{base_name}.{param.lower()}'
|
||||
hc900_tag = f"{linfo['tag']}.{hc_suffix}"
|
||||
emit(tagname, hc900_tag, addr, dtype, access, n, ptype, pdesc, src)
|
||||
|
||||
return rows_out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='C4 태그 매핑 CSV 생성')
|
||||
parser.add_argument('--modbus-csv', default=str(BASE/'C4-All-Modbus-Map.csv'))
|
||||
parser.add_argument('--sinam-xlsx', default=str(BASE/'Sinam_Tag_all.xlsx'))
|
||||
parser.add_argument('-o', '--output', default=str(BASE/'c4_tag_mapping.csv'))
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = build_mapping(Path(args.modbus_csv), Path(args.sinam_xlsx))
|
||||
|
||||
with open(args.output, 'w', newline='', encoding='utf-8') as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
# 통계
|
||||
from collections import Counter
|
||||
ptypes = Counter(r['param_type'] for r in rows)
|
||||
loop_rows = sum(1 for r in rows if r['loop_no'])
|
||||
sig_rows = sum(1 for r in rows if r['param_type'] == 'SIG')
|
||||
var_rows = sum(1 for r in rows if r['param_type'] == 'VAR')
|
||||
|
||||
print(f'총 매핑: {len(rows)}개')
|
||||
print(f' Loop 파라미터: {loop_rows}개 ({len(set(r["loop_no"] for r in rows if r["loop_no"]))} loops × 최대 {len(ANALOG_PARAMS)} params)')
|
||||
print(f' Signal Tag: {sig_rows}개')
|
||||
print(f' Variable: {var_rows}개')
|
||||
print(f' param_type 분포: {dict(ptypes.most_common())}')
|
||||
print(f'저장: {args.output}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
225
scripts/build_register_map.py
Normal file
225
scripts/build_register_map.py
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HC900 Register Map Builder
|
||||
|
||||
Converts HC Designer CSV exports (SummaryFunctionBlockReport, SignalTags, Variables)
|
||||
into a unified JSON register map for the Modbus TCP gateway.
|
||||
|
||||
Usage:
|
||||
python3 build_register_map.py \
|
||||
--loop-csv ../docs/SummaryFucntionBlockReport.csv \
|
||||
--signal-csv ../docs/SignalTags.csv \
|
||||
--variable-csv ../docs/Variables.csv \
|
||||
-o ../docs/register-map.json
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# ─── PID loop parameter offset map (based on manual Table 6-3) ───
|
||||
|
||||
LOOP_PARAM_OFFSETS = [
|
||||
# (name, offset_hex, type, access, description)
|
||||
# PV / SP / OP
|
||||
("PV", 0x00, "float32", "R", "Process Variable"),
|
||||
("RSP", 0x02, "float32", "R", "Remote Set Point (SP2)"),
|
||||
("SP", 0x04, "float32", "RW", "Working Set Point"),
|
||||
("OP", 0x06, "float32", "RW", "Output"),
|
||||
# Gains / tuning
|
||||
("GAIN1", 0x0C, "float32", "RW", "Gain #1 / Prop Band #1"),
|
||||
("DIR", 0x0E, "float32", "R", "Direction (0=Direct, 1=Reverse)"),
|
||||
("RESET1", 0x10, "float32", "RW", "Reset #1"),
|
||||
("RATE1", 0x12, "float32", "RW", "Rate #1"),
|
||||
# Ranges
|
||||
("PV_LO", 0x16, "float32", "R", "PV Low Range"),
|
||||
("PV_HI", 0x18, "float32", "R", "PV High Range"),
|
||||
# Alarms
|
||||
("ALM1_SP1", 0x1A, "float32", "RW", "Alarm #1 SP #1"),
|
||||
("ALM1_SP2", 0x1C, "float32", "RW", "Alarm #1 SP #2"),
|
||||
# Local SPs
|
||||
("LSP1", 0x2A, "float32", "RW", "Local SP #1"),
|
||||
("LSP2", 0x2C, "float32", "RW", "Local SP #2"),
|
||||
# Limits
|
||||
("SP_LO", 0x34, "float32", "RW", "SP Low Limit"),
|
||||
("SP_HI", 0x36, "float32", "RW", "SP High Limit"),
|
||||
("OP_LO", 0x3A, "float32", "RW", "Output Low Limit"),
|
||||
("OP_HI", 0x3C, "float32", "RW", "Output High Limit"),
|
||||
("DEV", 0x4A, "float32", "R", "Deviation (SP-PV)"),
|
||||
# Bit-packed status / control
|
||||
("FUZZY_EN", 0xB7, "uint16", "RW", "Fuzzy Enable"),
|
||||
("ATUNE_REQ", 0xB8, "uint16", "RW", "Autotune Request"),
|
||||
("MODE", 0xBA, "uint16", "RW", "Auto/Manual (0=Man, 1=Auto)"),
|
||||
("SP_STATE", 0xBB, "uint16", "RW", "Set Point State (0=SP1, 1=SP2)"),
|
||||
("RSP_STATE", 0xBC, "uint16", "RW", "Remote/Local SP (0=LSP, 1=RSP)"),
|
||||
("LOOP_STATUS", 0xBE, "uint16", "R", "Loop Status (bit packed)"),
|
||||
]
|
||||
|
||||
# ─── helpers ───
|
||||
|
||||
def hex_addr(s: str) -> int:
|
||||
return int(s.strip(), 16)
|
||||
|
||||
def strip_header(rows, header_marker: str):
|
||||
"""Skip rows until we find the actual data header."""
|
||||
for i, row in enumerate(rows):
|
||||
if row and row[0] == header_marker:
|
||||
return rows[i + 1:]
|
||||
return rows
|
||||
|
||||
# ─── parsers ───
|
||||
|
||||
def parse_loop_csv(path: Path) -> list[dict]:
|
||||
"""Parse SummaryFunctionBlockReport.csv → list of loop dicts."""
|
||||
loops = []
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
hex_addr_raw = row[0].strip()
|
||||
if not hex_addr_raw.startswith("0x"):
|
||||
continue
|
||||
tag = row[2].strip()
|
||||
desc = row[3].strip()
|
||||
typ = row[4].strip()
|
||||
num = row[5].strip()
|
||||
if typ != "PID":
|
||||
continue
|
||||
addr = hex_addr(hex_addr_raw)
|
||||
loops.append({
|
||||
"tag": tag,
|
||||
"description": desc,
|
||||
"start_addr": addr,
|
||||
"loop_number": int(num.replace("#", "")),
|
||||
})
|
||||
return loops
|
||||
|
||||
|
||||
def parse_signal_or_variable_csv(path: Path, access: str) -> list[dict]:
|
||||
"""Parse SignalTags.csv or Variables.csv → list of tag dicts."""
|
||||
tags = []
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 8:
|
||||
continue
|
||||
hex_addr_raw = row[0].strip()
|
||||
if not hex_addr_raw.startswith("0x"):
|
||||
continue
|
||||
tag = row[2].strip()
|
||||
desc = row[3].strip()
|
||||
typ = row[6].strip() if len(row) > 6 else "float 32"
|
||||
eu = row[7].strip() if len(row) > 7 else ""
|
||||
# normalise type
|
||||
if "float" in typ.lower():
|
||||
data_type = "float32"
|
||||
register_count = 2
|
||||
elif "signed 16" in typ.lower() or "unsigned 16" in typ.lower() or "integer" in typ.lower():
|
||||
data_type = "uint16"
|
||||
register_count = 1
|
||||
else:
|
||||
data_type = "float32"
|
||||
register_count = 2
|
||||
|
||||
addr = hex_addr(hex_addr_raw)
|
||||
tags.append({
|
||||
"tag": tag,
|
||||
"description": desc,
|
||||
"addr": addr,
|
||||
"count": register_count,
|
||||
"type": data_type,
|
||||
"access": access,
|
||||
"eu": eu,
|
||||
})
|
||||
return tags
|
||||
|
||||
|
||||
# ─── main ───
|
||||
|
||||
def build(loop_path, signal_path, variable_path, output_path):
|
||||
loops = parse_loop_csv(loop_path)
|
||||
signals = parse_signal_or_variable_csv(signal_path, "R")
|
||||
variables = parse_signal_or_variable_csv(variable_path, "RW")
|
||||
|
||||
registers = []
|
||||
|
||||
# 1. Expand PID loops into individual parameters
|
||||
for loop in loops:
|
||||
base = loop["start_addr"]
|
||||
for param in LOOP_PARAM_OFFSETS:
|
||||
name, off, dtype, access, desc_template = param
|
||||
reg_count = 2 if dtype == "float32" else 1
|
||||
addr = base + off
|
||||
tag_name = f"{loop['tag']}.{name}"
|
||||
description = f"{loop['description']} / {desc_template}" if loop['description'] else desc_template
|
||||
registers.append({
|
||||
"tag": tag_name,
|
||||
"addr": addr,
|
||||
"count": reg_count,
|
||||
"type": dtype,
|
||||
"access": access,
|
||||
"description": description,
|
||||
})
|
||||
|
||||
# 2. Signal Tags (read-only)
|
||||
for sig in signals:
|
||||
registers.append({
|
||||
"tag": sig["tag"],
|
||||
"addr": sig["addr"],
|
||||
"count": sig["count"],
|
||||
"type": sig["type"],
|
||||
"access": sig["access"],
|
||||
"description": sig["description"],
|
||||
"eu": sig["eu"],
|
||||
})
|
||||
|
||||
# 3. Variables (R/W)
|
||||
for var in variables:
|
||||
registers.append({
|
||||
"tag": var["tag"],
|
||||
"addr": var["addr"],
|
||||
"count": var["count"],
|
||||
"type": var["type"],
|
||||
"access": var["access"],
|
||||
"description": var["description"],
|
||||
"eu": var["eu"],
|
||||
})
|
||||
|
||||
# sort by address
|
||||
registers.sort(key=lambda r: r["addr"])
|
||||
|
||||
output = {
|
||||
"controller": "HC900-C70 Rev 4.4x",
|
||||
"report_generated": "2026-06-01",
|
||||
"float_format": "FP_B",
|
||||
"notes": "FP_B = IEEE 754 Big Endian (byte order 4,3,2,1). "
|
||||
"Address is the first register (0-based). "
|
||||
"float32 uses 2 consecutive registers.",
|
||||
"register_count": len(registers),
|
||||
"registers": registers,
|
||||
}
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"✓ Register map written to {output_path}")
|
||||
print(f" Loops: {len(loops)}")
|
||||
print(f" Signals: {len(signals)}")
|
||||
print(f" Variables: {len(variables)}")
|
||||
print(f" Total registers in map: {len(registers)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Build HC900 register map JSON from CSV exports")
|
||||
parser.add_argument("--loop-csv", required=True, help="SummaryFunctionBlockReport.csv")
|
||||
parser.add_argument("--signal-csv", required=True, help="SignalTags.csv")
|
||||
parser.add_argument("--variable-csv", required=True, help="Variables.csv")
|
||||
parser.add_argument("-o", "--output", default="register-map.json", help="Output JSON path")
|
||||
args = parser.parse_args()
|
||||
build(args.loop_csv, args.signal_csv, args.variable_csv, args.output)
|
||||
112
scripts/build_register_map_from_csv.py
Normal file
112
scripts/build_register_map_from_csv.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HC900 Register Map Builder — C3/C4 Full Modbus Map CSV → register-map.json
|
||||
|
||||
Reads the "Modbus Full Address Report" CSV exported from HC Designer
|
||||
(C3-All-Modbus-Map.csv / C4-All-Modbus-Map.csv) and produces the
|
||||
JSON register map consumed by the hc900_gateway.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_full_map(path: Path) -> dict:
|
||||
registers = []
|
||||
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
# Determine which section we're in
|
||||
for row in rows:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
hex_addr_raw = row[0].strip()
|
||||
if not hex_addr_raw.startswith("0x"):
|
||||
continue
|
||||
addr = int(hex_addr_raw, 16)
|
||||
tag = row[2].strip()
|
||||
desc = row[3].strip()
|
||||
typ = row[4].strip()
|
||||
num = row[5].strip()
|
||||
|
||||
if typ == "PID":
|
||||
dtype_raw = row[6].strip()
|
||||
access_raw = row[7].strip() if len(row) > 7 else "R"
|
||||
if "unsigned 16" in dtype_raw.lower() or "integer" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
|
||||
dtype = "uint16"
|
||||
count = 1
|
||||
else:
|
||||
dtype = "float32"
|
||||
count = 2
|
||||
access = "RW" if "w" in access_raw.lower() else "R"
|
||||
registers.append({
|
||||
"tag": tag, "addr": addr, "count": count,
|
||||
"type": dtype, "access": access, "description": desc,
|
||||
})
|
||||
|
||||
elif typ in ("Signal Tag", "Signal"):
|
||||
dtype_raw = row[6].strip()
|
||||
if "unsigned 16" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
|
||||
dtype = "uint16"
|
||||
count = 1
|
||||
else:
|
||||
dtype = "float32"
|
||||
count = 2
|
||||
registers.append({
|
||||
"tag": tag, "addr": addr, "count": count,
|
||||
"type": dtype, "access": "R", "description": desc,
|
||||
})
|
||||
|
||||
elif typ in ("Variable", "Math Variable"):
|
||||
dtype_raw = row[6].strip()
|
||||
if "unsigned 16" in dtype_raw.lower() or "signed 16" in dtype_raw.lower():
|
||||
dtype = "uint16"
|
||||
count = 1
|
||||
else:
|
||||
dtype = "float32"
|
||||
count = 2
|
||||
registers.append({
|
||||
"tag": tag, "addr": addr, "count": count,
|
||||
"type": dtype, "access": "RW", "description": desc,
|
||||
})
|
||||
|
||||
registers.sort(key=lambda r: r["addr"])
|
||||
|
||||
# Detect controller name from header
|
||||
controller = "HC900-C70"
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
for line in f:
|
||||
if line.startswith("File Name:"):
|
||||
controller = line.split(",", 1)[1].strip()
|
||||
break
|
||||
|
||||
return {
|
||||
"controller": controller,
|
||||
"float_format": "FP_B",
|
||||
"notes": "FP_B = IEEE 754 Big Endian (byte order 4,3,2,1). "
|
||||
"Address is the first register (0-based). "
|
||||
"float32 uses 2 consecutive registers.",
|
||||
"register_count": len(registers),
|
||||
"registers": registers,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Build register-map.json from HC Designer Full Modbus Map CSV")
|
||||
parser.add_argument("csv", help="Path to the *-All-Modbus-Map.csv")
|
||||
parser.add_argument("-o", "--output", default="register-map.json", help="Output JSON path")
|
||||
args = parser.parse_args()
|
||||
|
||||
data = parse_full_map(Path(args.csv))
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
pid_count = sum(1 for r in data["registers"] if r["tag"] and any(c in r["tag"] for c in ".-_"))
|
||||
print(f"✓ Register map written to {args.output}")
|
||||
print(f" Controller: {data['controller']}")
|
||||
print(f" Total registers: {data['register_count']}")
|
||||
322
scripts/build_register_map_from_sinam.py
Normal file
322
scripts/build_register_map_from_sinam.py
Normal file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build register-map.json from Sinam_Tag_all.xlsx SourceAddress columns.
|
||||
|
||||
Reads Sinam_Tag_all.xlsx (Experion tag→HC900 address mapping), filters
|
||||
for C3 controller entries, converts Experion indexed addresses to
|
||||
fixed Modbus addresses, and writes register-map.json.
|
||||
|
||||
Usage:
|
||||
python3 build_register_map_from_sinam.py \
|
||||
--sinam docs/Sinam_Tag_all.xlsx \
|
||||
--csv docs/C3-All-Modbus-Map.csv \
|
||||
-o docs/register-map.json
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
# ─── Experion indexed address → Modbus address ───
|
||||
|
||||
PARAM_MAP = {
|
||||
'PV': (0x00, 2, 'float32'),
|
||||
'RSP': (0x02, 2, 'float32'),
|
||||
'WSP': (0x04, 2, 'float32'),
|
||||
'SP': (0x04, 2, 'float32'),
|
||||
'OP': (0x06, 2, 'float32'),
|
||||
'OPWORK': (0x06, 2, 'float32'),
|
||||
'LSP1': (0x2A, 2, 'float32'),
|
||||
'LSP2': (0x2C, 2, 'float32'),
|
||||
'AMSTAT': (0xBA, 1, 'uint16'),
|
||||
'LOOPSTAT': (0xBE, 1, 'uint16'),
|
||||
}
|
||||
|
||||
|
||||
def parse_indexed_addr(addr_str: str, csv_lookup: dict) -> dict | None:
|
||||
"""Convert an Experion indexed address (e.g. 'C3 LOOP 1 OP')
|
||||
to a dict with modbus_addr, count, type, and optional description.
|
||||
|
||||
csv_lookup: {(partition, index): {'addr': int, 'dtype': str, 'tag': str}}
|
||||
"""
|
||||
addr_str = addr_str.strip()
|
||||
|
||||
# LOOP / LOOPX
|
||||
m = re.match(r'C3 (LOOP|LOOPX) (\d+) (\w+)', addr_str)
|
||||
if m:
|
||||
loop_type, n_str, param = m.group(1), int(m.group(2)), m.group(3)
|
||||
if loop_type == 'LOOP':
|
||||
assert 1 <= n_str <= 24, f'LOOP number {n_str} out of range 1-24'
|
||||
base = 0x0040 + (n_str - 1) * 0x0100
|
||||
else:
|
||||
assert 25 <= n_str <= 32, f'LOOPX number {n_str} out of range 25-32'
|
||||
base = 0x7840 + (n_str - 25) * 0x0100
|
||||
param_info = PARAM_MAP.get(param)
|
||||
if param_info is None:
|
||||
print(f' ⚠ Unknown param "{param}" in "{addr_str}", skipping')
|
||||
return None
|
||||
off, count, dtype = param_info
|
||||
addr = base + off
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'R',
|
||||
'description': f'{loop_type} #{n_str} {param}',
|
||||
}
|
||||
|
||||
# TAG (Signal Tag)
|
||||
m = re.match(r'C3 TAG (\d+) VALUE', addr_str)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
addr = 0x2000 + (n - 1) * 2
|
||||
info = csv_lookup.get(('TAG', n), {})
|
||||
dtype = info.get('dtype', 'float32')
|
||||
count = 2 if dtype == 'float32' else 1
|
||||
tag_name = info.get('tag', f'SIG_TAG_{n}')
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'R',
|
||||
'description': f'Signal Tag #{n} ({tag_name})',
|
||||
}
|
||||
|
||||
# MATH_VAR (Variable)
|
||||
m = re.match(r'C3 MATH_VAR (\d+) VALUE', addr_str)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
addr = 0x18C0 + (n - 1) * 2
|
||||
info = csv_lookup.get(('MATH_VAR', n), {})
|
||||
dtype = info.get('dtype', 'float32')
|
||||
count = 2 if dtype == 'float32' else 1
|
||||
tag_name = info.get('tag', f'VAR_{n}')
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': count,
|
||||
'type': dtype,
|
||||
'access': 'RW',
|
||||
'description': f'Variable #{n} ({tag_name})',
|
||||
}
|
||||
|
||||
# Raw address format: C3 4:0x789e
|
||||
m = re.match(r'C3 (\d+):0x([0-9a-fA-F]+)', addr_str)
|
||||
if m:
|
||||
addr = int(m.group(2), 16)
|
||||
dtype_str = addr_str.split()[-1] if 'UINT' in addr_str.upper() else 'uint16'
|
||||
return {
|
||||
'modbus_addr': addr,
|
||||
'count': 1,
|
||||
'type': 'uint16',
|
||||
'access': 'R',
|
||||
'description': f'Raw addr 0x{addr:04X}',
|
||||
}
|
||||
|
||||
print(f' ⚠ Could not parse indexed address: "{addr_str}", skipping')
|
||||
return None
|
||||
|
||||
|
||||
# ─── CSV lookup ───
|
||||
|
||||
def build_csv_lookup(csv_path: Path) -> dict:
|
||||
"""Build a lookup dict from C3-All-Modbus-Map.csv.
|
||||
|
||||
Returns: {(partition, index): {'tag': str, 'dtype': str, 'addr': int}}
|
||||
"""
|
||||
lookup = {}
|
||||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
for row in rows:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
hex_addr_raw = row[0].strip()
|
||||
if not hex_addr_raw.startswith('0x'):
|
||||
continue
|
||||
addr = int(hex_addr_raw, 16)
|
||||
tag = row[2].strip()
|
||||
typ = row[4].strip()
|
||||
num_str = row[5].strip()
|
||||
dtype_raw = row[6].strip() if len(row) > 6 else 'float 32'
|
||||
|
||||
# Normalize dtype
|
||||
if 'unsigned 16' in dtype_raw.lower() or 'signed 16' in dtype_raw.lower() or 'integer' in dtype_raw.lower():
|
||||
dtype = 'uint16'
|
||||
else:
|
||||
dtype = 'float32'
|
||||
|
||||
# PID loop → map loop number
|
||||
if typ == 'PID' and num_str.startswith('#'):
|
||||
n = int(num_str[1:])
|
||||
lookup[('LOOP', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
# Signal Tag
|
||||
if typ == 'Signal Tag' and num_str.isdigit():
|
||||
n = int(num_str)
|
||||
lookup[('TAG', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
# Variable / Math Variable
|
||||
if typ in ('Variable', 'Math Variable') and num_str.isdigit():
|
||||
n = int(num_str)
|
||||
lookup[('MATH_VAR', n)] = {'tag': tag, 'dtype': dtype, 'addr': addr}
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
# ─── Sinam parsing ───
|
||||
|
||||
def iter_sinam_c3_entries(sinam_path: Path, csv_lookup: dict) -> list[dict]:
|
||||
"""Iterate over Sinam_Tag_all.xlsx rows with C3 source addresses.
|
||||
|
||||
Yields register entries dicts ready for JSON output.
|
||||
"""
|
||||
wb = openpyxl.load_workbook(sinam_path, read_only=True, data_only=True)
|
||||
ws = wb['Sheet1']
|
||||
|
||||
registers = []
|
||||
seen_global = set() # {(tag_name, addr)} — global dedup
|
||||
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
item_name = row[0]
|
||||
cls = row[1]
|
||||
if not item_name:
|
||||
continue
|
||||
item_name = str(item_name).strip()
|
||||
if not cls:
|
||||
continue
|
||||
cls = str(cls).strip()
|
||||
|
||||
pv = str(row[28]).strip() if row[28] else ''
|
||||
op = str(row[29]).strip() if row[29] else ''
|
||||
md = str(row[30]).strip() if row[30] else ''
|
||||
|
||||
# Collect C3 source addresses
|
||||
sources = []
|
||||
for field, val in [('PV', pv), ('OP', op), ('MD', md)]:
|
||||
if val and val.upper().startswith('C3'):
|
||||
sources.append((field, val))
|
||||
|
||||
if not sources:
|
||||
continue
|
||||
|
||||
# Per-row dedup: track addresses to avoid OP/MD pointing to same reg
|
||||
row_addrs = set()
|
||||
|
||||
# Determine primary field: the main source that drops the suffix
|
||||
# AnalogPoint: PV = main analog value, OP = loop status (supplement)
|
||||
# StatusPoint: OP = digital state (main), MD = supplement (if different)
|
||||
if cls == 'AnalogPoint':
|
||||
primary_field = 'PV'
|
||||
elif cls == 'StatusPoint':
|
||||
primary_field = 'OP'
|
||||
else:
|
||||
primary_field = sources[0][0]
|
||||
|
||||
# For each source address, create register entry
|
||||
for field, addr_str in sources:
|
||||
result = parse_indexed_addr(addr_str, csv_lookup)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
addr = result['modbus_addr']
|
||||
|
||||
# Per-row address dedup (OP/MD often point to same register)
|
||||
if addr in row_addrs:
|
||||
continue
|
||||
row_addrs.add(addr)
|
||||
|
||||
# Tag naming:
|
||||
# single source → ItemName
|
||||
# multi-source, primary field → ItemName (no suffix)
|
||||
# multi-source, other field → ItemName.field
|
||||
if len(sources) == 1 or field == primary_field:
|
||||
tag_name = item_name
|
||||
else:
|
||||
tag_name = f'{item_name}.{field}'
|
||||
|
||||
# Global dedup by (tag_name, addr)
|
||||
key = (tag_name, addr)
|
||||
if key in seen_global:
|
||||
continue
|
||||
seen_global.add(key)
|
||||
|
||||
entry = {
|
||||
'tag': tag_name,
|
||||
'addr': result['modbus_addr'],
|
||||
'count': result['count'],
|
||||
'type': result['type'],
|
||||
'access': result['access'],
|
||||
'description': result['description'],
|
||||
}
|
||||
registers.append(entry)
|
||||
|
||||
wb.close()
|
||||
return registers
|
||||
|
||||
|
||||
# ─── main ───
|
||||
|
||||
def build(sinam_path: Path, csv_path: Path | None, output_path: Path):
|
||||
csv_lookup = build_csv_lookup(csv_path) if csv_path else {}
|
||||
|
||||
print(f'Building CSV lookup from {csv_path}...')
|
||||
print(f' LOOP entries: {sum(1 for k in csv_lookup if k[0] == "LOOP")}')
|
||||
print(f' TAG entries: {sum(1 for k in csv_lookup if k[0] == "TAG")}')
|
||||
print(f' MATH_VAR entries: {sum(1 for k in csv_lookup if k[0] == "MATH_VAR")}')
|
||||
|
||||
print(f'Parsing C3 entries from {sinam_path}...')
|
||||
registers = iter_sinam_c3_entries(sinam_path, csv_lookup)
|
||||
|
||||
registers.sort(key=lambda r: r['addr'])
|
||||
|
||||
output = {
|
||||
'controller': 'C3',
|
||||
'report_generated': '2026-06-03',
|
||||
'float_format': 'FP_B',
|
||||
'notes': 'Register map built from Sinam_Tag_all.xlsx SourceAddress columns (C3 only). '
|
||||
'Tags are mapped from Experion indexed addresses to fixed Modbus addresses.',
|
||||
'register_count': len(registers),
|
||||
'registers': registers,
|
||||
}
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f'\n✓ Register map written to {output_path}')
|
||||
print(f' Total C3 registers from Sinam: {len(registers)}')
|
||||
|
||||
# Summary by type
|
||||
by_field = {}
|
||||
for r in registers:
|
||||
suffix = r['tag'].split('.')[-1] if '.' in r['tag'] else '(main)'
|
||||
by_field[suffix] = by_field.get(suffix, 0) + 1
|
||||
print(' By suffix:')
|
||||
for s, c in sorted(by_field.items(), key=lambda x: -x[1]):
|
||||
print(f' {c:4d} {s}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Build register-map.json from Sinam_Tag_all.xlsx (C3 controller)')
|
||||
parser.add_argument('--sinam', required=True,
|
||||
help='Path to Sinam_Tag_all.xlsx')
|
||||
parser.add_argument('--csv',
|
||||
default='docs/C3-All-Modbus-Map.csv',
|
||||
help='Path to C3-All-Modbus-Map.csv for type/tag lookup (optional)')
|
||||
parser.add_argument('-o', '--output', default='docs/register-map.json',
|
||||
help='Output JSON path')
|
||||
parser.add_argument('--no-csv', action='store_true',
|
||||
help='Skip CSV lookup (use defaults for type)')
|
||||
args = parser.parse_args()
|
||||
|
||||
csv_path = None if args.no_csv else Path(args.csv)
|
||||
if csv_path and not csv_path.exists():
|
||||
print(f'Warning: CSV path {csv_path} not found, continuing without lookup')
|
||||
csv_path = None
|
||||
|
||||
build(Path(args.sinam), csv_path, Path(args.output))
|
||||
48
scripts/load_state_labels.py
Normal file
48
scripts/load_state_labels.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
xlsx의 StatusPoint DescriptorState0~7 → hc900.tag_metadata 로드
|
||||
실행: python3 scripts/load_state_labels.py
|
||||
"""
|
||||
import openpyxl
|
||||
import psycopg2
|
||||
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"
|
||||
|
||||
wb = openpyxl.load_workbook(XLSX_PATH, read_only=True, data_only=True)
|
||||
ws = wb['Sheet1']
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
headers = rows[1]
|
||||
col = {h: i for i, h in enumerate(headers) if h}
|
||||
|
||||
conn = psycopg2.connect(DB_DSN)
|
||||
cur = conn.cursor()
|
||||
|
||||
inserted = 0
|
||||
for row in rows[2:]:
|
||||
name = str(row[col['ItemName']] or '').strip()
|
||||
cls = str(row[col['Class']] or '').strip()
|
||||
if cls != 'StatusPoint' or not name:
|
||||
continue
|
||||
|
||||
base_tag = name.lower()
|
||||
|
||||
for i in range(8):
|
||||
key = f'DescriptorState{i}'
|
||||
if key not in col:
|
||||
continue
|
||||
val = row[col[key]]
|
||||
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)))
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print(f"완료: {inserted}개 상태 레이블 저장")
|
||||
121
scripts/migration_20260603_controller_id.sql
Normal file
121
scripts/migration_20260603_controller_id.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- ============================================================================
|
||||
-- Migration: Add controller_id columns for multi-controller support
|
||||
-- Run this once against the existing HC900 database.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. realtime_table: add controller_id + UNIQUE(controller_id, tagname)
|
||||
ALTER TABLE realtime_table
|
||||
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
|
||||
|
||||
-- Drop any existing unique constraint on tagname alone (idempotent wrapper)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'realtime_table'
|
||||
AND indexname = 'idx_realtime_table_tagname_unique'
|
||||
) THEN
|
||||
DROP INDEX IF EXISTS idx_realtime_table_tagname_unique;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'realtime_table' AND c.contype = 'u'
|
||||
) THEN
|
||||
-- There's a unique constraint; we can't easily drop it by name unless we know it.
|
||||
-- Use a safe approach: drop any unique constraint on tagname
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add the new composite UNIQUE (required for ON CONFLICT (controller_id, tagname) DO UPDATE)
|
||||
-- Skip if already exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'realtime_table'
|
||||
AND indexname = 'idx_realtime_table_ctrl_tag_unique'
|
||||
) THEN
|
||||
CREATE UNIQUE INDEX idx_realtime_table_ctrl_tag_unique
|
||||
ON realtime_table(controller_id, tagname);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. history_table: add controller_id with default 'HC1'
|
||||
ALTER TABLE history_table
|
||||
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
|
||||
|
||||
-- 3. event_history_table: add controller_id
|
||||
ALTER TABLE event_history_table
|
||||
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
|
||||
|
||||
-- 4. tag_metadata: add controller_id
|
||||
ALTER TABLE tag_metadata
|
||||
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
|
||||
|
||||
-- 5. hc900_map_master: add controller_id
|
||||
ALTER TABLE hc900_map_master
|
||||
ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1';
|
||||
|
||||
-- 6. v_tag_summary: recreate with controller_id
|
||||
DROP VIEW IF EXISTS v_tag_summary CASCADE;
|
||||
CREATE VIEW v_tag_summary AS
|
||||
SELECT
|
||||
rt_base.base_tag,
|
||||
pv_rt.livevalue AS pv,
|
||||
sp_rt.livevalue AS sp,
|
||||
op_rt.livevalue AS op,
|
||||
instate0_rt.livevalue AS instate0,
|
||||
instate1_rt.livevalue AS instate1,
|
||||
instate2_rt.livevalue AS instate2,
|
||||
desc_md.value AS description,
|
||||
area_md.value AS area,
|
||||
sub_area_md.value AS sub_area,
|
||||
rt_base.controller_id
|
||||
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag, controller_id FROM realtime_table) rt_base
|
||||
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv' AND pv_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp' AND sp_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op' AND op_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0' AND instate0_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1' AND instate1_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2' AND instate2_rt.controller_id = rt_base.controller_id
|
||||
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
|
||||
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
|
||||
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area';
|
||||
|
||||
-- 7. v_plant_running_state: recreate (depends on v_tag_summary)
|
||||
DROP VIEW IF EXISTS v_plant_running_state;
|
||||
CREATE VIEW v_plant_running_state AS
|
||||
WITH pump_state AS (
|
||||
SELECT
|
||||
trim(split_part(area, '|', 2)) AS area_code,
|
||||
area AS area_raw,
|
||||
base_tag,
|
||||
pv,
|
||||
controller_id
|
||||
FROM v_tag_summary
|
||||
WHERE area IS NOT NULL
|
||||
AND (base_tag LIKE 'p-%' OR base_tag LIKE 'vp-%')
|
||||
AND pv ~ '\|\s*(L-RUN|R-RUN|L-STOP|R-STOP|L-TRIP|R-TRIP)\s*\|'
|
||||
)
|
||||
SELECT
|
||||
area_code,
|
||||
MAX(area_raw) AS area_raw,
|
||||
COUNT(*) AS total_pumps,
|
||||
COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') AS running_pumps,
|
||||
COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|') AS tripped_pumps,
|
||||
COUNT(*) FILTER (WHERE pv ~ '\|\s*(L-STOP|R-STOP)\s*\|') AS stopped_pumps,
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') > 0 THEN 'RUNNING'
|
||||
WHEN COUNT(*) FILTER (WHERE pv ~ '\|\s*[LR]-TRIP\s*\|') > 0 THEN 'TRIPPED'
|
||||
ELSE 'STOPPED'
|
||||
END AS status,
|
||||
array_agg(base_tag) FILTER (WHERE pv ~ '\|\s*[LR]-RUN\s*\|') AS running_pump_tags
|
||||
FROM pump_state
|
||||
WHERE area_code IS NOT NULL AND area_code <> ''
|
||||
GROUP BY area_code
|
||||
ORDER BY area_code;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user