""" Legacy Viewer (Dashboard View) - 상단 20%: 속도계 그룹 - 중간 30%: 신호 카드 그룹 - 하단 50%: 그래프 그룹 (미니 그래프) """ from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QFrame, QLabel, QSplitter, QScrollArea, QSizePolicy, QPushButton) from PySide6.QtCore import Qt from PySide6.QtGui import QColor from app.ui.components.signal_components import ( OnOffSignalLabel, DataSignalLabel, SignalCard ) from app.core.sync_controller import sync_manager from app.ui.views.graph_view import GraphView class DashboardView(QWidget): def __init__(self, panel_id): super().__init__() self.panel_id = panel_id # 노이즈 필터링 상태 self.last_valid_pwm = 0 self._signal_filter_state = {} self._signal_valid_sources = {} self._source_filtered_last = {} # 신호 컴포넌트 참조 저장 self.signal_widgets = {} self.setup_ui() sync_manager.time_changed.connect(self.update_view) def setup_ui(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # === 메인 Splitter (수직) === self.main_splitter = QSplitter(Qt.Vertical) self.main_splitter.setChildrenCollapsible(False) # 완전히 접히지 않도록 self.main_splitter.setHandleWidth(5) self.main_splitter.setStyleSheet(""" QSplitter::handle { background-color: #3E3E42; } QSplitter::handle:hover { background-color: #007ACC; } QSplitter::handle:pressed { background-color: #005A9E; } """) # === [1] 상단: 신호 카드 그룹 === signal_group = self._create_signal_group() self.main_splitter.addWidget(signal_group) # === [2] 하단: 미니 그래프 그룹 === graph_group = self._create_graph_group() self.main_splitter.addWidget(graph_group) # 초기 비율 설정 (60:40) self.main_splitter.setSizes([300, 200]) main_layout.addWidget(self.main_splitter) # ========================================================================= # [2] 신호 카드 그룹 # ========================================================================= def _create_signal_group(self): # 전체 컨테이너 (스크롤 + VD 버튼) wrapper = QWidget() wrapper_layout = QVBoxLayout(wrapper) wrapper_layout.setContentsMargins(0, 0, 0, 0) wrapper_layout.setSpacing(0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) scroll.setMinimumHeight(100) scroll.setStyleSheet(""" QScrollArea { background-color: #1E1E1E; border: none; } QScrollBar:vertical { background: #2D2D30; width: 8px; } QScrollBar::handle:vertical { background: #555; border-radius: 4px; min-height: 20px; } QScrollBar:horizontal { background: #2D2D30; height: 8px; } QScrollBar::handle:horizontal { background: #555; border-radius: 4px; min-width: 20px; } """) container = QWidget() container.setStyleSheet("background-color: #1E1E1E;") grid = QGridLayout(container) grid.setContentsMargins(3, 3, 3, 3) grid.setSpacing(3) # 카드 생성 - 3열 배치 # Row 0: ATC | ATO | TWC | FAIL # Row 1: RLY(ATO) | DI(ATC) | DO(ATC) # Row 2: ETC1 (DATA) | ETC2 (ON/OFF) # Row 0: INFO, ATC, ATO, TWC (4열) self.card_info = self._create_info_card() self.card_atc = self._create_atc_card() self.card_ato = self._create_ato_card() self.card_twc = self._create_twc_card() grid.addWidget(self.card_info, 0, 0) grid.addWidget(self.card_atc, 0, 1) grid.addWidget(self.card_ato, 0, 2) grid.addWidget(self.card_twc, 0, 3) # Row 1: FAIL, RLY(ATO), DI(ATC), DO(ATC) self.card_fail = self._create_fail_card() self.card_rly = self._create_rly_card() self.card_di = self._create_di_card() self.card_do = self._create_do_card() grid.addWidget(self.card_fail, 1, 0) grid.addWidget(self.card_rly, 1, 1) grid.addWidget(self.card_di, 1, 2) grid.addWidget(self.card_do, 1, 3) # Row 2: ETC1 (DATA 전용), ETC2 (ON/OFF 전용) self.card_etc1 = self._create_etc1_card() # DATA 컴포넌트 self.card_etc2 = self._create_etc2_card() # ON/OFF 컴포넌트 grid.addWidget(self.card_etc1, 2, 0, 1, 2) # 2열 차지 grid.addWidget(self.card_etc2, 2, 2, 1, 2) # 2열 차지 # 메인 카드 목록 (참조용) self.main_cards = [ self.card_info, self.card_atc, self.card_ato, self.card_twc, self.card_fail, self.card_rly, self.card_di, self.card_do, self.card_etc1, self.card_etc2 ] # VD 카드 (6개) - 토글 가능, 기본값 OFF (숨김) self.vd_cards = [ self._create_vdi_a_card(), self._create_vdi_b_card(), self._create_vdi_c_card(), self._create_vdi_d_card(), self._create_vdo_a_card(), self._create_vdo_b_card(), ] # VD 카드 배치 (4열, Row 3~4) for i, card in enumerate(self.vd_cards): card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) row = 3 + (i // 4) col = i % 4 grid.addWidget(card, row, col) card.setVisible(False) # 기본값: 숨김 (OFF) scroll.setWidget(container) wrapper_layout.addWidget(scroll, 1) # VD 토글 버튼 (오른쪽 하단) btn_container = QWidget() btn_container.setStyleSheet("background-color: #1E1E1E;") btn_layout = QHBoxLayout(btn_container) btn_layout.setContentsMargins(5, 2, 5, 2) btn_layout.addStretch() self.vd_visible = False # VD 카드 표시 상태 - 기본값 OFF self.btn_vd_toggle = QPushButton("VD ▲") # 기본값: 숨김 상태 self.btn_vd_toggle.setFixedSize(50, 22) self.btn_vd_toggle.setStyleSheet(""" QPushButton { background-color: #4C4C4C; color: #888888; border: 1px solid #666; border-radius: 3px; font-size: 10px; font-weight: bold; } QPushButton:hover { background-color: #5C5C5C; } QPushButton:pressed { background-color: #3C3C3C; } """) self.btn_vd_toggle.clicked.connect(self._toggle_vd_cards) btn_layout.addWidget(self.btn_vd_toggle) wrapper_layout.addWidget(btn_container) return wrapper def _toggle_vd_cards(self): """VD 카드들(VDI A/B/C/D, VDO A/B) 표시/숨김 토글""" self.vd_visible = not self.vd_visible for card in self.vd_cards: card.setVisible(self.vd_visible) if self.vd_visible: self.btn_vd_toggle.setText("VD ▼") self.btn_vd_toggle.setStyleSheet(""" QPushButton { background-color: #4C4C4C; color: #00FF00; border: 1px solid #666; border-radius: 3px; font-size: 10px; font-weight: bold; } QPushButton:hover { background-color: #5C5C5C; } """) else: self.btn_vd_toggle.setText("VD ▲") self.btn_vd_toggle.setStyleSheet(""" QPushButton { background-color: #4C4C4C; color: #888888; border: 1px solid #666; border-radius: 3px; font-size: 10px; font-weight: bold; } QPushButton:hover { background-color: #5C5C5C; } """) # ------------------------------------------------------------------------- # INFO 카드 (상단 상태 요약) # ------------------------------------------------------------------------- # ------------------------------------------------------------------------- # INFO 카드 (상단 상태 요약) # ------------------------------------------------------------------------- # ------------------------------------------------------------------------- # INFO 카드 (상단 상태 요약) # ------------------------------------------------------------------------- def _create_info_card(self): card = SignalCard("INFO") # 커스텀 레이아웃을 위해 add_signal 대신 signal_grid 직접 사용 grid = card.signal_grid # 1. TIME (상단, 전체 너비 사용 및 중앙 정렬) time_sig = DataSignalLabel("time", "TIME", "") time_sig.set_value_style("font-size: 16px; font-weight: bold; color: #00D084;") # 강조 # DataSignalLabel은 내부적으로 QHBoxLayout을 사용하므로, # 중앙 정렬을 위해 컨테이너나 스타일 조정이 필요할 수 있으나, # 여기서는 Grid의 0행 전체를 차지하게 함. grid.addWidget(time_sig, 0, 0, 1, 4, Qt.AlignCenter) card.signals["time"] = time_sig self.signal_widgets["info_time"] = time_sig # 2. SPEED (속도계) - 1행, 전체 너비 from app.ui.components.speedometer import SpeedometerWidget self.speedometer = SpeedometerWidget(title="SPEED", unit="km/h", max_value=120) grid.addWidget(self.speedometer, 1, 0, 1, 4, Qt.AlignCenter) card.signals["speed"] = self.speedometer # 필요 시 참조용 self.signal_widgets["info_speed"] = self.speedometer # 3. 나머지 데이터 (LIMIT, DTG, PWM, ATC CODE) - 2행에 2열씩 배치 # Row 2: LIMIT, DTG # Row 3: PWM, ATC CODE limit_sig = DataSignalLabel("limit", "LIMIT", "km/h") dtg_sig = DataSignalLabel("dtg", "DTG", "m") pwm_sig = DataSignalLabel("pwm", "PWM", "%") atc_sig = DataSignalLabel("atc_code", "ATC CODE", "") # (위젯, 행, 열, 행스팬, 열스팬) grid.addWidget(limit_sig, 2, 0, 1, 2) grid.addWidget(dtg_sig, 2, 2, 1, 2) grid.addWidget(pwm_sig, 3, 0, 1, 2) grid.addWidget(atc_sig, 3, 2, 1, 2) # signals 등록 card.signals["limit"] = limit_sig card.signals["dtg"] = dtg_sig card.signals["pwm"] = pwm_sig card.signals["atc_code"] = atc_sig self.signal_widgets["info_limit"] = limit_sig self.signal_widgets["info_dtg"] = dtg_sig self.signal_widgets["info_pwm"] = pwm_sig self.signal_widgets["info_atc_code"] = atc_sig # 레이아웃 마무리 (SignalCard 내부 메서드 호출 대신 수동으로 stretch 설정) grid.setRowStretch(4, 1) return card # ------------------------------------------------------------------------- # ATC 카드 # ------------------------------------------------------------------------- def _create_atc_card(self): card = SignalCard("ATC") # DATA: 운전실 상태 cab = DataSignalLabel("cab", "운전실", description="운전실 선택 상태 (TC1/TC2/OFF)") card.add_signal(cab, "cab") self.signal_widgets["atc_cab"] = cab # DATA: ATC ACTIVE, CARR, CODE, CODE FREQ, LIMIT for name, desc in [ ("ATC ACT", "ATC 활성화 상태"), ("CARR", "ATC 캐리어 주파수"), ("CODE", "ATC 속도 코드"), ("FREQ", "코드 주파수"), ("LIMIT", "ATC 제한속도"), ]: sig = DataSignalLabel(name.replace(" ", "_").lower(), name, description=desc) card.add_signal(sig, name.replace(" ", "_").lower()) self.signal_widgets[f"atc_{name.replace(' ', '_').lower()}"] = sig return card # ------------------------------------------------------------------------- # TWC 카드 # ------------------------------------------------------------------------- def _create_twc_card(self): card = SignalCard("TWC") # ON/OFF for name, desc in [ ("DCW", "Door Close Warning"), ("TWX Tx", "TWC 송신 활성화"), ("BERTH", "Train Berth - 정위치 정차"), ("W.DOOR", "Wrong Door - 잘못된 도어"), ]: sig = OnOffSignalLabel(name.replace(" ", "_").lower(), name, desc) card.add_signal(sig, name.replace(" ", "_").lower()) self.signal_widgets[f"twc_{name.replace(' ', '_').lower()}"] = sig # DATA for name, desc in [ ("열번", "열차 번호"), ("현재역", "현재 역 코드"), ("다음역", "다음 역 코드"), ("종착역", "종착역 코드"), ("DOOR", "다음 도어 방향"), ]: sig = DataSignalLabel(name, name, description=desc) card.add_signal(sig, name) self.signal_widgets[f"twc_{name}"] = sig return card # ------------------------------------------------------------------------- # ATO 카드 # ------------------------------------------------------------------------- def _create_ato_card(self): card = SignalCard("ATO") # ON/OFF for name, full_name, desc in [ ("TASC", "TASC", "Train Automatic Stop Control - 자동정차제어"), ("TASC DB", "TASC DB", "TASC Dynamic Brake"), ("ATO EB", "ATO EB REQ", "ATO Emergency Brake Request - ATO 비상제동 요청"), ]: sig = OnOffSignalLabel(name, full_name, desc) card.add_signal(sig, name.lower().replace(" ", "_")) self.signal_widgets[f"ato_{name.lower().replace(' ', '_')}"] = sig # DATA for name, desc in [ ("MARKER", "ATO 마커 (PG1/2/3/X)"), ("OSC", "OSC 주파수"), ("PWM", "PWM 값"), ("DTG", "남은거리(m)"), ]: sig = DataSignalLabel(name.lower(), name, description=desc) card.add_signal(sig, name.lower()) self.signal_widgets[f"ato_{name.lower()}"] = sig return card # ------------------------------------------------------------------------- # RLY (ATO) 카드 # ------------------------------------------------------------------------- def _create_rly_card(self): card = SignalCard("RLY (ATO)") for name in ["DR", "BR", "ADC", "ADOL", "ADOR", "OSC", "KUR", "SEL"]: sig = OnOffSignalLabel(name, name, f"RLY {name} 릴레이 출력") card.add_signal(sig, name.lower()) self.signal_widgets[f"rly_{name.lower()}"] = sig return card # ------------------------------------------------------------------------- # FAIL 카드 # ------------------------------------------------------------------------- def _create_fail_card(self): card = SignalCard("FAIL") for name, desc in [ ("ATO.R", "ATO Receiver Fail"), ("ATO.C", "ATO Controller Fail"), ("TCMS", "TCMS Communication Fail"), ("TACHO#1", "Tachometer #1 Fail"), ("TACHO#2", "Tachometer #2 Fail"), ]: sig = OnOffSignalLabel(name.replace(".", "_").replace("#", ""), name, desc) sig.set_style_override( on_style="background-color: #E53935; color: white; border-radius: 3px; padding: 2px 6px; font-weight: bold;", ) card.add_signal(sig, name.replace(".", "_").replace("#", "").lower()) self.signal_widgets[f"fail_{name.replace('.', '_').replace('#', '').lower()}"] = sig return card # ------------------------------------------------------------------------- # DI (ATC) 카드 # ------------------------------------------------------------------------- def _create_di_card(self): card = SignalCard("DI (ATC)") # ON/OFF for name in ["HCR", "TCR", "START"]: sig = OnOffSignalLabel(name, name, f"DI {name} 입력 신호") card.add_signal(sig, name.lower()) self.signal_widgets[f"di_{name.lower()}"] = sig # DATA 형태로 표시 (운전모드/마스콘/역전기/PSD/DOOR) for name, desc in [ ("운전모드", "운전 모드"), ("마스콘", "마스콘 위치"), ("역전기", "역전기 위치"), ("PSD", "PSD 상태"), ("DOOR", "도어 상태"), ]: sig = DataSignalLabel(name.lower(), name, description=desc) card.add_signal(sig, name.lower()) self.signal_widgets[f"di_{name.lower()}"] = sig return card # ------------------------------------------------------------------------- # DO (ATC) 카드 # ------------------------------------------------------------------------- def _create_do_card(self): card = SignalCard("DO (ATC)") for name in ["EB+", "EB-", "FSB", "ZVR", "EDR", "EDL"]: sig = OnOffSignalLabel(name.replace("+", "p").replace("-", "m"), name, f"DO {name} 출력 신호") card.add_signal(sig, name.replace("+", "p").replace("-", "m").lower()) self.signal_widgets[f"do_{name.replace('+', 'p').replace('-', 'm').lower()}"] = sig return card # ------------------------------------------------------------------------- # ETC1 카드 (DATA 컴포넌트만) - 가로 배치 # ------------------------------------------------------------------------- def _create_etc1_card(self): card = SignalCard("ETC1") card.set_horizontal_data_mode(True) # DATA를 가로로 배치 # DATA for name in ["INIT PDT", "MNUL PDT", "TCMS DR", "DIA1", "DIA2", "VER", "DOOR MD"]: sig = DataSignalLabel(name.replace(" ", "_").lower(), name, description=f"ETC {name}") card.add_signal(sig, name.replace(" ", "_").lower()) self.signal_widgets[f"etc_{name.replace(' ', '_').lower()}"] = sig return card # ------------------------------------------------------------------------- # ETC2 카드 (ON/OFF 컴포넌트만) # ------------------------------------------------------------------------- def _create_etc2_card(self): card = SignalCard("ETC2") # ON/OFF for name in ["SYS ACT", "INCH", "CS", "평상", "회복", "O.SPD", "사전제동", "가속제한", "-70↑", "+70↑", "TDIR A", "TDIR B"]: sig = OnOffSignalLabel(name.replace(" ", "_"), name, f"ETC {name} 신호") card.add_signal(sig, name.replace(" ", "_").lower()) self.signal_widgets[f"etc_{name.replace(' ', '_').lower()}"] = sig return card # ------------------------------------------------------------------------- # VDI A/B/C/D 카드 # ------------------------------------------------------------------------- def _create_vdi_a_card(self): return self._create_vdi_ab_card("VDI A", "vdi_a") def _create_vdi_b_card(self): return self._create_vdi_ab_card("VDI B", "vdi_b") def _create_vdi_ab_card(self, title, prefix): card = SignalCard(title) for name in ["HCR", "TCR", "FA", "AUTO", "MCS", "YARD", "FMC", "D.OPEN", "D.CLOSE", "MC EB", "MC BR", "MC DR", "FWD", "NEU", "RVS"]: sig = OnOffSignalLabel(name.replace(".", "_"), name, f"{title} {name}") card.add_signal(sig, name.replace(".", "_").lower()) self.signal_widgets[f"{prefix}_{name.replace('.', '_').lower()}"] = sig return card def _create_vdi_c_card(self): return self._create_vdi_cd_card("VDI C", "vdi_c") def _create_vdi_d_card(self): return self._create_vdi_cd_card("VDI D", "vdi_d") def _create_vdi_cd_card(self, title, prefix): card = SignalCard(title) for name in ["PSD OP", "PSD CL", "START", "UNIT1", "EB+FB", "EB-FB", "FSB FB", "ZVR FB", "EDR FB", "EDL FB", "TC1", "TC2"]: sig = OnOffSignalLabel(name.replace("+", "p").replace("-", "m").replace(" ", "_"), name, f"{title} {name}") card.add_signal(sig, name.replace("+", "p").replace("-", "m").replace(" ", "_").lower()) self.signal_widgets[f"{prefix}_{name.replace('+', 'p').replace('-', 'm').replace(' ', '_').lower()}"] = sig return card # ------------------------------------------------------------------------- # VDO A/B 카드 # ------------------------------------------------------------------------- def _create_vdo_a_card(self): return self._create_vdo_card("VDO A", "vdo_a") def _create_vdo_b_card(self): return self._create_vdo_card("VDO B", "vdo_b") def _create_vdo_card(self, title, prefix): card = SignalCard(title) for name in ["EB+", "EB-", "FSB", "ZVR", "EDL", "EDR"]: sig = OnOffSignalLabel(name.replace("+", "p").replace("-", "m"), name, f"{title} {name}") card.add_signal(sig, name.replace("+", "p").replace("-", "m").lower()) self.signal_widgets[f"{prefix}_{name.replace('+', 'p').replace('-', 'm').lower()}"] = sig return card # ========================================================================= # [3] 그래프 그룹 (GraphView 전체 임베드) # ========================================================================= def _create_graph_group(self): # GraphView를 그대로 임베드 self.embedded_graph = GraphView(self.panel_id) self.embedded_graph.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.embedded_graph.setMinimumHeight(150) return self.embedded_graph def set_data(self, data_list): """외부에서 데이터 로드 시 그래프에도 전달""" if hasattr(self, 'embedded_graph'): self.embedded_graph.set_data(data_list) # source 기반 신뢰도 추정 (가짜 0 제거용) signal_attr_map = { "trainspeed": "trainspeed", "pwm_value": "pwm_value", "dtg": "dtg", "osc_f": "osc_f", } self._signal_valid_sources = self._infer_valid_sources(data_list, signal_attr_map) self._source_filtered_last = {} # ========================================================================= # 노이즈 필터링 함수들 (1초 단위 깜박임 필터링) # ========================================================================= def apply_pwm_filter(self, raw_pwm): if 15 <= raw_pwm <= 90: self.last_valid_pwm = raw_pwm return raw_pwm if raw_pwm == 0: return self.last_valid_pwm return 0 def filter_majority_signal(self, signal_name: str, current_value: bool, window_size: int = 5) -> bool: """ 과반수 기반 디지털 신호 필터 (매 프레임 깜박이는 신호용) 최근 window_size개 값 중 과반수가 ON이면 ON 반환 SYSTEM ACTIVE처럼 매 초 깜박이는 신호에 적용 """ state_key = f"maj_{signal_name}" if state_key not in self._signal_filter_state: self._signal_filter_state[state_key] = { 'history': [], } state = self._signal_filter_state[state_key] state['history'].append(current_value) # window_size 유지 if len(state['history']) > window_size: state['history'].pop(0) # 과반수 계산 on_count = sum(state['history']) return on_count > len(state['history']) // 2 def filter_digital_signal(self, signal_name: str, current_value: bool, stable_frames: int = 3) -> bool: """ 디지털 신호 노이즈 필터링 (1초 깜박임 방지) - 완화된 버전 Args: signal_name: 신호 이름 (상태 추적용) current_value: 현재 값 stable_frames: 값이 변경되기 위해 필요한 연속 프레임 수 (기본 3프레임) Logic: - 첫 번째 값은 그대로 반영 (초기화) - 이후 값이 변경되려면 stable_frames 연속 같은 값이어야 함 - 깜박임(1-2프레임 변동)은 무시됨 """ if signal_name not in self._signal_filter_state: # 초기값은 현재 값으로 설정 (첫 프레임부터 정상 표시) self._signal_filter_state[signal_name] = { 'stable_value': current_value, # 현재 값으로 초기화 'pending_value': current_value, 'same_count': 0, } return current_value state = self._signal_filter_state[signal_name] if current_value == state['stable_value']: # 안정값과 같으면 유지 state['pending_value'] = current_value state['same_count'] = 0 return state['stable_value'] elif current_value == state['pending_value']: # 대기값과 같으면 카운트 증가 state['same_count'] += 1 if state['same_count'] >= stable_frames: state['stable_value'] = current_value state['same_count'] = 0 return state['stable_value'] else: # 새로운 값이면 대기값 갱신 state['pending_value'] = current_value state['same_count'] = 1 return state['stable_value'] def filter_numeric_signal(self, signal_name: str, current_value: float, min_valid: float = 0, stable_frames: int = 3) -> float: """ 숫자 신호 노이즈 필터링 (1초 깜박임 방지) - 완화된 버전 Args: signal_name: 신호 이름 current_value: 현재 값 min_valid: 유효 최소값 (기본 0 = 모든 값 허용) stable_frames: 값이 변경되기 위해 필요한 연속 프레임 수 Logic: - 첫 번째 값은 그대로 반영 - 이후 값 변경은 stable_frames 연속 같은 값이어야 함 """ state_key = f"num_{signal_name}" if state_key not in self._signal_filter_state: self._signal_filter_state[state_key] = { 'stable_value': current_value, # 현재 값으로 초기화 'pending_value': current_value, 'same_count': 0, } return current_value state = self._signal_filter_state[state_key] # 같은 값이면 유지 if current_value == state['stable_value']: state['pending_value'] = current_value state['same_count'] = 0 return state['stable_value'] # 다른 값이면 카운트 if current_value == state['pending_value']: state['same_count'] += 1 if state['same_count'] >= stable_frames: state['stable_value'] = current_value state['same_count'] = 0 else: state['pending_value'] = current_value state['same_count'] = 1 return state['stable_value'] def filter_string_signal(self, signal_name: str, current_value: str, stable_frames: int = 3) -> str: """ 문자열 신호 노이즈 필터링 (1초 깜박임 방지) Args: signal_name: 신호 이름 current_value: 현재 값 stable_frames: 안정된 것으로 인정할 연속 프레임 수 Logic: - 값이 변경되면 stable_frames 동안 이전 값 유지 - 새 값이 stable_frames 연속되면 변경 인정 """ state_key = f"str_{signal_name}" if state_key not in self._signal_filter_state: self._signal_filter_state[state_key] = { 'stable_value': current_value, 'pending_value': current_value, 'same_count': 0, } state = self._signal_filter_state[state_key] if current_value == state['stable_value']: # 안정값과 같으면 유지 state['pending_value'] = current_value state['same_count'] = 0 return state['stable_value'] elif current_value == state['pending_value']: # 대기값과 같으면 카운트 증가 state['same_count'] += 1 if state['same_count'] >= stable_frames: state['stable_value'] = current_value state['same_count'] = 0 return state['stable_value'] else: # 새로운 값이면 대기값 갱신 state['pending_value'] = current_value state['same_count'] = 1 return state['stable_value'] def filter_mode_signal(self, signal_name: str, current_mode: str, stable_frames: int = 3) -> str: """ 모드 신호 노이즈 필터링 (FA/AUTO/MCS 등 모드 전환 시) Logic: - None이 아닌 모드가 들어오면 즉시 표시 - None이 들어오면 stable_frames 동안 이전 모드 유지 - None이 stable_frames 연속되면 None으로 인정 """ state_key = f"mode_{signal_name}" if state_key not in self._signal_filter_state: self._signal_filter_state[state_key] = { 'stable_mode': None, 'none_count': 0, } state = self._signal_filter_state[state_key] if current_mode is not None: state['none_count'] = 0 state['stable_mode'] = current_mode return current_mode else: state['none_count'] += 1 if state['none_count'] >= stable_frames: state['stable_mode'] = None return state['stable_mode'] def _infer_valid_sources(self, data_list, signal_attr_map, zero_ratio_threshold=0.98, min_samples=50): """ 소스별로 '거의 항상 0'인 신호를 찾아 유효 소스를 추정한다. - 특정 소스가 해당 신호에서 0 비율이 매우 높고, 다른 소스는 그렇지 않으면 해당 소스를 제외. """ stats = {k: {} for k in signal_attr_map.keys()} for d in data_list or []: src = getattr(d, "source", None) if src is None: continue for key, attr in signal_attr_map.items(): val = getattr(d, attr, None) if val is None: continue s = stats[key].setdefault(src, {"n": 0, "zero": 0}) s["n"] += 1 if val == 0: s["zero"] += 1 valid_sources_map = {} for key, by_src in stats.items(): if not by_src or len(by_src) <= 1: valid_sources_map[key] = None continue ratios = {} for src, s in by_src.items(): if s["n"] < min_samples: continue ratios[src] = (s["zero"] / s["n"]) if s["n"] else 0 if not ratios: valid_sources_map[key] = None continue low_zero = [src for src, r in ratios.items() if r < zero_ratio_threshold] high_zero = [src for src, r in ratios.items() if r >= zero_ratio_threshold] if low_zero and high_zero: valid_sources_map[key] = set(low_zero) else: valid_sources_map[key] = None return valid_sources_map def _get_source_filtered_value(self, data, key, attr, default=0): """ source 기반으로 유효 값만 사용. 유효하지 않은 소스면 직전 유효값 유지. """ val = getattr(data, attr, default) src = getattr(data, "source", None) valid_sources = self._signal_valid_sources.get(key) is_valid = (valid_sources is None) or (src in valid_sources) if is_valid: self._source_filtered_last[key] = val return val if key in self._source_filtered_last: return self._source_filtered_last[key] return val if val is not None else default # ========================================================================= # 데이터 업데이트 # ========================================================================= def update_view(self, index, data, source_id): if not data: return # === INFO 카드 업데이트 === speed = self._get_source_filtered_value(data, "trainspeed", "trainspeed", default=0) raw_pwm = self._get_source_filtered_value(data, "pwm_value", "pwm_value", default=0) filtered_pwm = self.apply_pwm_filter(raw_pwm) limit_spd = self.filter_numeric_signal("limitspeed", getattr(data, 'limitspeed', 0)) raw_dtg = self._get_source_filtered_value(data, "dtg", "dtg", default=0) dtg = self.filter_numeric_signal("dtg", raw_dtg, min_valid=1) atc_code = getattr(data, 'atc_code', '-') time_str = getattr(data, 'time', '-')[-8:] if w := self.signal_widgets.get("info_time"): w.set_value(time_str) if w := self.signal_widgets.get("info_speed"): w.set_value(f"{speed:.1f}") if hasattr(w, "set_limit"): w.set_limit(limit_spd) if hasattr(w, "set_atc_code"): w.set_atc_code(atc_code) if w := self.signal_widgets.get("info_limit"): w.set_value(int(limit_spd)) if w := self.signal_widgets.get("info_dtg"): w.set_value(f"{dtg:.0f}") if w := self.signal_widgets.get("info_pwm"): w.set_value(filtered_pwm) if w := self.signal_widgets.get("info_atc_code"): w.set_value(str(atc_code)) # === 신호 카드 업데이트 === self._update_signals(data) def _update_signals(self, data): """신호 카드 내 모든 신호 업데이트 (노이즈 필터링 적용)""" # =================================================================== # ATC 카드 # =================================================================== tc1 = self.filter_digital_signal("tc1", getattr(data, 'tc1', False)) tc2 = self.filter_digital_signal("tc2", getattr(data, 'tc2', False)) cab_text = "TC1" if tc1 else ("TC2" if tc2 else "OFF") if w := self.signal_widgets.get("atc_cab"): w.set_value(cab_text) # =================================================================== # DO 카드 (모든 출력 신호에 필터 적용) # =================================================================== do_signals = [ ("ebp", "do_ebp"), ("ebm", "do_ebm"), ("fsb", "do_fsb"), ("zvr", "do_zvr"), ("edr", "do_edr"), ("edl", "do_edl") ] for name, attr in do_signals: val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"do_{name}"): w.set_status(val) # =================================================================== # DI 카드 (모든 입력 신호에 필터 적용) # =================================================================== # HCR, TCR, START if w := self.signal_widgets.get("di_hcr"): w.set_status(self.filter_digital_signal("di_hcr", getattr(data, 'hcr', False))) if w := self.signal_widgets.get("di_tcr"): w.set_status(self.filter_digital_signal("di_tcr", getattr(data, 'tcr', False))) if w := self.signal_widgets.get("di_start"): w.set_status(self.filter_digital_signal("di_start", getattr(data, 'ato_start_btn', False))) # 운전모드 (DATA) raw_mode = None for m in ["fa", "auto", "mcs", "yard", "fmc"]: if self.filter_digital_signal(f"di_{m}", getattr(data, m, False)): raw_mode = m.upper() break mode_text = raw_mode or "없음" if w := self.signal_widgets.get("di_운전모드"): w.set_value(mode_text) # PSD psd_open = self.filter_digital_signal("psd_open", getattr(data, 'psd_open', False)) psd_close = self.filter_digital_signal("psd_close", getattr(data, 'psd_close', False)) psd_text = "열림" if psd_open else ("닫힘" if psd_close else "없음") if w := self.signal_widgets.get("di_psd"): w.set_value(psd_text) # DOOR door_open = self.filter_digital_signal("door_open", getattr(data, 'door_open', False)) door_close = self.filter_digital_signal("door_close", getattr(data, 'door_close', False)) door_text = "열림" if door_open else ("닫힘" if door_close else "없음") if w := self.signal_widgets.get("di_door"): w.set_value(door_text) # 마스콘 mc_eb = self.filter_digital_signal("mascon_eb", getattr(data, 'mascon_eb', False)) mc_br = self.filter_digital_signal("mascon_br", getattr(data, 'mascon_br', False)) mc_dr = self.filter_digital_signal("mascon_dr", getattr(data, 'mascon_dr', False)) raw_mc_mode = "EB" if mc_eb else ("BR" if mc_br else ("DR" if mc_dr else None)) mc_text = raw_mc_mode or "없음" if w := self.signal_widgets.get("di_마스콘"): w.set_value(mc_text) # 역전기 fwd = self.filter_digital_signal("reversingrod_fwd", getattr(data, 'reversingrod_fwd', False)) neu = self.filter_digital_signal("reversingrod_neu", getattr(data, 'reversingrod_neu', False)) rvs = self.filter_digital_signal("reversingrod_rvs", getattr(data, 'reversingrod_rvs', False)) rev_text = "전진" if fwd else ("중립" if neu else ("후진" if rvs else "없음")) if w := self.signal_widgets.get("di_역전기"): w.set_value(rev_text) # =================================================================== # ATO 카드 # =================================================================== if w := self.signal_widgets.get("ato_tasc"): w.set_status(self.filter_digital_signal("tasc", getattr(data, 'tasc', False))) if w := self.signal_widgets.get("ato_tasc_db"): w.set_status(self.filter_digital_signal("tascdb", getattr(data, 'tascdb', False))) if w := self.signal_widgets.get("ato_ato_eb"): w.set_status(self.filter_digital_signal("ato_eb_req", getattr(data, 'ato_eb_req', False))) # ATO DATA if w := self.signal_widgets.get("ato_marker"): marker_val = self.filter_string_signal("ato_marker", str(getattr(data, 'marker', '-'))) w.set_value(marker_val) if w := self.signal_widgets.get("ato_osc"): raw_osc = self._get_source_filtered_value(data, "osc_f", "osc_f", default=0) osc = self.filter_numeric_signal("osc_f", raw_osc, min_valid=0) w.set_value(f"{osc:.1f}" if osc else "-") if w := self.signal_widgets.get("ato_pwm"): pwm_val = self.apply_pwm_filter(self._get_source_filtered_value(data, "pwm_value", "pwm_value", default=0)) w.set_value(pwm_val) if w := self.signal_widgets.get("ato_dtg"): dtg_val = self.filter_numeric_signal("dtg_ato", self._get_source_filtered_value(data, "dtg", "dtg", default=0), min_valid=1) w.set_value(f"{dtg_val:.0f}") # =================================================================== # RLY (ATO) 카드 # =================================================================== rly_signals = [ ("dr", "trac_dr"), ("br", "trac_br"), ("adc", "adc"), ("adol", "adol"), ("ador", "ador"), ("osc", "osc"), ("kur", "kur"), ("sel", "start_enable") ] for name, attr in rly_signals: val = self.filter_digital_signal(f"rly_{attr}", getattr(data, attr, False)) if w := self.signal_widgets.get(f"rly_{name}"): w.set_status(val) # =================================================================== # FAIL 카드 # =================================================================== fail_signals = [ ("ato_r", "fail_atcr"), ("ato_c", "fail_atoc"), ("tcms", "fail_tcms"), ("tacho1", "fail_tacho1"), ("tacho2", "fail_tacho2") ] for name, attr in fail_signals: val = self.filter_digital_signal(f"fail_{attr}", getattr(data, attr, False)) if w := self.signal_widgets.get(f"fail_{name}"): w.set_status(val) # =================================================================== # TWC 카드 # =================================================================== if w := self.signal_widgets.get("twc_dcw"): w.set_status(self.filter_digital_signal("door_close_warning", getattr(data, 'door_close_warning', False))) if w := self.signal_widgets.get("twc_twx_tx"): w.set_status(self.filter_digital_signal("twct_enable", getattr(data, 'twct_enable', False))) if w := self.signal_widgets.get("twc_berth"): w.set_status(self.filter_digital_signal("trainberth", getattr(data, 'trainberth', False))) if w := self.signal_widgets.get("twc_w.door"): w.set_status(self.filter_digital_signal("wrongdoor", getattr(data, 'wrongdoor', False))) # TWC DATA if w := self.signal_widgets.get("twc_열번"): w.set_value(getattr(data, 'trainno', '-')) if w := self.signal_widgets.get("twc_현재역"): w.set_value(getattr(data, 'pstn', '-')) if w := self.signal_widgets.get("twc_다음역"): w.set_value(getattr(data, 'nstn', '-')) if w := self.signal_widgets.get("twc_종착역"): w.set_value(getattr(data, 'dstn', '-')) if w := self.signal_widgets.get("twc_DOOR"): w.set_value(getattr(data, 'nextdoor', '-')) # =================================================================== # ETC 카드 # =================================================================== # SYSTEM ACTIVE는 매 프레임 깜박이므로 과반수 필터 사용 if w := self.signal_widgets.get("etc_sys_act"): val = self.filter_majority_signal("system_active", getattr(data, 'system_active', False)) w.set_status(val) # 나머지 ETC 신호는 일반 필터 사용 etc_signals = [ ("inch", "inching"), ("cs", "trac_cs"), ("평상", "nomal"), ("회복", "recovery"), ("o.spd", "over_spd_warning"), ("사전제동", "pre_brake"), ("가속제한", "limit_drive"), ("-70↑", "sh_stop1"), ("+70↑", "ov_stop1"), ("tdir_a", "tacho_dir_a"), ("tdir_b", "tacho_dir_b") ] for name, attr in etc_signals: val = self.filter_digital_signal(f"etc_{attr}", getattr(data, attr, False)) if w := self.signal_widgets.get(f"etc_{name}"): w.set_status(val) # ETC DATA (문자열 필터 적용) if w := self.signal_widgets.get("etc_init_pdt"): val = self.filter_string_signal("etc_ipdt", str(getattr(data, 'ipdt', '-'))) w.set_value(val) if w := self.signal_widgets.get("etc_mnul_pdt"): val = self.filter_string_signal("etc_mpdt", str(getattr(data, 'mpdt', '-'))) w.set_value(val) if w := self.signal_widgets.get("etc_tcms_dr"): val = self.filter_string_signal("etc_tcmsdoor", str(getattr(data, 'tcmsdoor', '-'))) w.set_value(val) if w := self.signal_widgets.get("etc_door_md"): val = self.filter_string_signal("etc_doormod", str(getattr(data, 'doormod', '-'))) w.set_value(val) # =================================================================== # VDI A/B/C/D, VDO A/B - 모든 신호에 필터 적용 # =================================================================== # VDI A vdi_a_map = { "hcr": "vdia_hcr", "tcr": "vdia_tcr", "fa": "vdia_fa", "auto": "vdia_auto", "mcs": "vdia_mcs", "yard": "vdia_yard", "fmc": "vdia_fmc", "d_open": "vdia_dooropen", "d_close": "vdia_doorclose", "mc_eb": "vdia_masconeb", "mc_br": "vdia_masconbr", "mc_dr": "vdia_mascondr", "fwd": "vdia_fwd", "neu": "vdia_neu", "rvs": "vdia_rvs" } for widget_name, attr in vdi_a_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdi_a_{widget_name}"): w.set_status(val) # VDI B vdi_b_map = { "hcr": "vdib_hcr", "tcr": "vdib_tcr", "fa": "vdib_fa", "auto": "vdib_auto", "mcs": "vdib_mcs", "yard": "vdib_yard", "fmc": "vdib_fmc", "d_open": "vdib_dooropen", "d_close": "vdib_doorclose", "mc_eb": "vdib_masconeb", "mc_br": "vdib_masconbr", "mc_dr": "vdib_mascondr", "fwd": "vdib_fwd", "neu": "vdib_neu", "rvs": "vdib_rvs" } for widget_name, attr in vdi_b_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdi_b_{widget_name}"): w.set_status(val) # VDI C vdi_c_map = { "psd_op": "vdic_psdopen", "psd_cl": "vdic_psdclose", "start": "vdic_startbtn", "unit1": "vdic_unit1", "ebpfb": "vdic_ebpfb", "ebmfb": "vdic_ebmfb", "fsb_fb": "vdic_fsbfb", "zvr_fb": "vdic_zvrfb", "edr_fb": "vdic_edrfb", "edl_fb": "vdic_edlfb", "tc1": "vdic_tc1", "tc2": "vdic_tc2" } for widget_name, attr in vdi_c_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdi_c_{widget_name}"): w.set_status(val) # VDI D vdi_d_map = { "psd_op": "vdid_psdopen", "psd_cl": "vdid_psdclose", "start": "vdid_startbtn", "unit1": "vdid_unit1", "ebpfb": "vdid_ebpfb", "ebmfb": "vdid_ebmfb", "fsb_fb": "vdid_fsbfb", "zvr_fb": "vdid_zvrfb", "edr_fb": "vdid_edrfb", "edl_fb": "vdid_edlfb", "tc1": "vdid_tc1", "tc2": "vdid_tc2" } for widget_name, attr in vdi_d_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdi_d_{widget_name}"): w.set_status(val) # VDO A vdo_a_map = { "ebp": "vdoa_ebp", "ebm": "vdoa_ebm", "fsb": "vdoa_fsb", "zvr": "vdoa_zvr", "edl": "vdoa_edl", "edr": "vdoa_edr" } for widget_name, attr in vdo_a_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdo_a_{widget_name}"): w.set_status(val) # VDO B vdo_b_map = { "ebp": "vdob_ebp", "ebm": "vdob_ebm", "fsb": "vdob_fsb", "zvr": "vdob_zvr", "edl": "vdob_edl", "edr": "vdob_edr" } for widget_name, attr in vdo_b_map.items(): val = self.filter_digital_signal(attr, getattr(data, attr, False)) if w := self.signal_widgets.get(f"vdo_b_{widget_name}"): w.set_status(val)