first commit

This commit is contained in:
9700X_PC 2026-01-18 10:52:59 +09:00
commit ef3d9212b5
167 changed files with 78230 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv/
build/
dist/

BIN
TrackInfo.xlsx Normal file

Binary file not shown.

0
app/__init__.py Normal file
View File

Binary file not shown.

32
app/ai/__init__.py Normal file
View File

@ -0,0 +1,32 @@
"""
AI 모듈 패키지
AI 프로바이더 관리 통합 인터페이스를 제공합니다.
"""
from .base import AIProviderType, AIMessage, AIResponse, BaseAIProvider
from .ai_client import AIClient, get_ai_client
from .providers import (
OpenAIProvider,
OpenRouterProvider,
GeminiProvider,
XAIProvider,
)
__all__ = [
# Core
'AIProviderType',
'AIMessage',
'AIResponse',
'BaseAIProvider',
# Client
'AIClient',
'get_ai_client',
# Providers
'OpenAIProvider',
'OpenRouterProvider',
'GeminiProvider',
'XAIProvider',
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

340
app/ai/ai_client.py Normal file
View File

@ -0,0 +1,340 @@
"""
AI Client - AI 프로바이더 관리 통합 인터페이스
사용 예시:
from app.ai.ai_client import AIClient, AIProviderType
# 클라이언트 생성
client = AIClient()
# 프로바이더 설정
client.set_provider(AIProviderType.OPENAI, api_key="sk-...")
# 또는 설정에서 자동 로드
client.load_from_settings()
# 채팅 요청
response = client.chat("안녕하세요!")
"""
from typing import Optional, Dict, List, Type
from .base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
from .providers import OpenAIProvider, OpenRouterProvider, GeminiProvider, XAIProvider
class AIClient:
"""
AI 클라이언트 매니저
여러 AI 프로바이더를 관리하고 통합 인터페이스를 제공합니다.
"""
# 프로바이더 타입별 클래스 매핑
PROVIDER_CLASSES: Dict[AIProviderType, Type[BaseAIProvider]] = {
AIProviderType.OPENAI: OpenAIProvider,
AIProviderType.OPENROUTER: OpenRouterProvider,
AIProviderType.GEMINI: GeminiProvider,
AIProviderType.XAI: XAIProvider,
}
def __init__(self):
self._current_provider: Optional[BaseAIProvider] = None
self._providers: Dict[AIProviderType, BaseAIProvider] = {}
self._settings = None
@property
def current_provider(self) -> Optional[BaseAIProvider]:
"""현재 활성화된 프로바이더"""
return self._current_provider
@property
def current_provider_type(self) -> Optional[AIProviderType]:
"""현재 프로바이더 타입"""
if self._current_provider:
return self._current_provider.provider_type
return None
@property
def available_provider_types(self) -> List[AIProviderType]:
"""사용 가능한 프로바이더 타입 목록"""
return list(self.PROVIDER_CLASSES.keys())
def get_provider_name(self, provider_type: AIProviderType) -> str:
"""프로바이더 타입에 해당하는 표시 이름 반환"""
names = {
AIProviderType.OPENAI: "OpenAI",
AIProviderType.OPENROUTER: "OpenRouter",
AIProviderType.GEMINI: "Google Gemini",
AIProviderType.XAI: "xAI (Grok)",
}
return names.get(provider_type, provider_type.value)
def set_provider(
self,
provider_type: AIProviderType,
api_key: str,
model: Optional[str] = None
) -> bool:
"""
프로바이더 설정 활성화
Args:
provider_type: 프로바이더 타입
api_key: API
model: 사용할 모델 (None이면 기본 모델)
Returns:
초기화 성공 여부
"""
provider_class = self.PROVIDER_CLASSES.get(provider_type)
if not provider_class:
raise ValueError(f"지원하지 않는 프로바이더: {provider_type}")
# 프로바이더 인스턴스 생성
provider = provider_class(api_key=api_key, model=model)
# 초기화 시도
if provider.initialize():
self._providers[provider_type] = provider
self._current_provider = provider
return True
return False
def switch_provider(self, provider_type: AIProviderType) -> bool:
"""
이미 등록된 프로바이더로 전환
Args:
provider_type: 전환할 프로바이더 타입
Returns:
전환 성공 여부
"""
if provider_type in self._providers:
self._current_provider = self._providers[provider_type]
return True
return False
def update_api_key(self, provider_type: AIProviderType, api_key: str) -> bool:
"""
프로바이더의 API 업데이트
Args:
provider_type: 프로바이더 타입
api_key: API
Returns:
업데이트 재초기화 성공 여부
"""
if provider_type in self._providers:
provider = self._providers[provider_type]
provider.update_api_key(api_key)
return provider.initialize()
return False
def update_model(self, model: str) -> bool:
"""
현재 프로바이더의 모델 변경
Args:
model: 모델명
Returns:
변경 성공 여부
"""
if self._current_provider:
self._current_provider.model = model
return True
return False
def get_available_models(self) -> List[str]:
"""현재 프로바이더의 사용 가능한 모델 목록"""
if self._current_provider:
return self._current_provider.available_models
return []
def load_from_settings(self, settings: 'AISettings') -> bool:
"""
설정에서 프로바이더 정보 로드
Args:
settings: AISettings 인스턴스
Returns:
로드 성공 여부
"""
self._settings = settings
# 현재 선택된 프로바이더 가져오기
current_type = settings.get_current_provider()
if not current_type:
return False
# API 키 가져오기
api_key = settings.get_api_key(current_type)
if not api_key:
return False
# 모델 가져오기
model = settings.get_model(current_type)
return self.set_provider(current_type, api_key, model)
def chat(
self,
user_message: str,
system_prompt: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
간편 채팅 메서드 (동기)
Args:
user_message: 사용자 메시지
system_prompt: 시스템 프롬프트 (선택)
temperature: 응답 다양성
max_tokens: 최대 토큰
Returns:
AI 응답
"""
if not self._current_provider:
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
messages = []
if system_prompt:
messages.append(AIMessage(role="system", content=system_prompt))
messages.append(AIMessage(role="user", content=user_message))
return self._current_provider.chat_sync(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**kwargs
)
async def chat_async(
self,
user_message: str,
system_prompt: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
간편 채팅 메서드 (비동기)
Args:
user_message: 사용자 메시지
system_prompt: 시스템 프롬프트 (선택)
temperature: 응답 다양성
max_tokens: 최대 토큰
Returns:
AI 응답
"""
if not self._current_provider:
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
messages = []
if system_prompt:
messages.append(AIMessage(role="system", content=system_prompt))
messages.append(AIMessage(role="user", content=user_message))
return await self._current_provider.chat(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**kwargs
)
def chat_with_history(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
대화 히스토리와 함께 채팅 (동기)
Args:
messages: 메시지 리스트
temperature: 응답 다양성
max_tokens: 최대 토큰
Returns:
AI 응답
"""
if not self._current_provider:
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
return self._current_provider.chat_sync(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**kwargs
)
async def chat_with_history_async(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
대화 히스토리와 함께 채팅 (비동기)
Args:
messages: 메시지 리스트
temperature: 응답 다양성
max_tokens: 최대 토큰
Returns:
AI 응답
"""
if not self._current_provider:
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
return await self._current_provider.chat(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**kwargs
)
def is_ready(self) -> bool:
"""프로바이더가 사용 준비되었는지 확인"""
return self._current_provider is not None and self._current_provider._is_initialized
def get_status(self) -> Dict:
"""현재 상태 정보 반환"""
if self._current_provider:
return {
"ready": self.is_ready(),
"provider": self._current_provider.provider_name,
"model": self._current_provider.model,
"api_key": self._current_provider.api_key, # 마스킹됨
}
return {
"ready": False,
"provider": None,
"model": None,
"api_key": None,
}
# 싱글톤 인스턴스
_ai_client_instance: Optional[AIClient] = None
def get_ai_client() -> AIClient:
"""AI 클라이언트 싱글톤 인스턴스 반환"""
global _ai_client_instance
if _ai_client_instance is None:
_ai_client_instance = AIClient()
return _ai_client_instance

158
app/ai/base.py Normal file
View File

@ -0,0 +1,158 @@
"""
AI Provider 기본 클래스 정의
모든 AI 프로바이더는 기본 클래스를 상속받아 구현합니다.
"""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from enum import Enum
class AIProviderType(Enum):
"""지원하는 AI 프로바이더 타입"""
OPENAI = "openai"
OPENROUTER = "openrouter"
GEMINI = "gemini"
XAI = "xai"
@dataclass
class AIMessage:
"""AI 메시지 데이터 클래스"""
role: str # 'system', 'user', 'assistant'
content: str
@dataclass
class AIResponse:
"""AI 응답 데이터 클래스"""
content: str
model: str
provider: str
usage: Optional[Dict[str, int]] = None
raw_response: Optional[Any] = None
class BaseAIProvider(ABC):
"""
AI Provider 추상 기본 클래스
모든 AI 프로바이더는 클래스를 상속받아 구현해야 합니다.
"""
def __init__(self, api_key: str, model: Optional[str] = None):
"""
Args:
api_key: API
model: 사용할 모델명 (None이면 기본 모델 사용)
"""
self._api_key = api_key
self._model = model or self.default_model
self._is_initialized = False
@property
@abstractmethod
def provider_type(self) -> AIProviderType:
"""프로바이더 타입 반환"""
pass
@property
@abstractmethod
def provider_name(self) -> str:
"""프로바이더 이름 반환"""
pass
@property
@abstractmethod
def default_model(self) -> str:
"""기본 모델명 반환"""
pass
@property
@abstractmethod
def available_models(self) -> List[str]:
"""사용 가능한 모델 목록 반환"""
pass
@property
def model(self) -> str:
"""현재 사용 중인 모델명"""
return self._model
@model.setter
def model(self, value: str):
"""모델 변경"""
self._model = value
@property
def api_key(self) -> str:
"""API 키 (마스킹된 값)"""
if self._api_key:
return self._api_key[:8] + "..." + self._api_key[-4:]
return ""
def update_api_key(self, api_key: str):
"""API 키 업데이트"""
self._api_key = api_key
self._is_initialized = False
@abstractmethod
def initialize(self) -> bool:
"""
프로바이더 초기화 (클라이언트 생성 )
Returns:
초기화 성공 여부
"""
pass
@abstractmethod
async def chat(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
채팅 요청 (비동기)
Args:
messages: 대화 메시지 리스트
temperature: 응답 다양성 (0.0 ~ 1.0)
max_tokens: 최대 토큰
**kwargs: 추가 파라미터
Returns:
AI 응답
"""
pass
@abstractmethod
def chat_sync(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""
채팅 요청 (동기)
Args:
messages: 대화 메시지 리스트
temperature: 응답 다양성 (0.0 ~ 1.0)
max_tokens: 최대 토큰
**kwargs: 추가 파라미터
Returns:
AI 응답
"""
pass
def validate_api_key(self) -> bool:
"""API 키 유효성 검사 (간단한 형식 체크)"""
return bool(self._api_key and len(self._api_key) > 10)
def __repr__(self):
return f"{self.__class__.__name__}(model={self._model}, initialized={self._is_initialized})"

115
app/ai/interface.py Normal file
View File

@ -0,0 +1,115 @@
import polars as pl
import json
from typing import List, Dict, Any, Optional
from app.core.reference_data import ref_data
class MMIAIInterface:
"""
Interface for AI to interact with MMI Data.
Provides tools and data access methods.
"""
def __init__(self, parquet_path: str):
self.parquet_path = parquet_path
self.df: Optional[pl.DataFrame] = None
self._load_data()
def _load_data(self):
try:
self.df = pl.read_parquet(self.parquet_path)
except Exception as e:
print(f"[AI Interface] Load Error: {e}")
self.df = None
def get_tools_schema(self) -> List[Dict[str, Any]]:
"""
Returns the JSON schema for tools available to the AI.
"""
return [
{
"type": "function",
"function": {
"name": "get_error_stats",
"description": "Get statistics of errors (e.g., ATC status, Warnings) for the current log.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "search_station_event",
"description": "Find events happening at a specific station.",
"parameters": {
"type": "object",
"properties": {
"station_name": {"type": "string", "description": "Name of the station (e.g., '범어사')"}
},
"required": ["station_name"]
}
}
}
]
def get_error_stats(self) -> Dict[str, Any]:
"""
Tool: Get Error Statistics
"""
if self.df is None:
return {"error": "No data loaded"}
# Example: Count ATC Status != 'ATC ACTIVE' (64)
# Note: atc_byte 64 is ACTIVE.
# We need to filter where system_id == 1 (Main) usually?
# Let's return stats for Main system (1)
main_df = self.df.filter(pl.col("system_id") == 1)
total_rows = len(main_df)
# Count non-active frames
# 64 is ATC ACTIVE.
non_active_count = main_df.filter(pl.col("atc_byte") != 64).height
return {
"total_frames": total_rows,
"non_active_frames": non_active_count,
"active_ratio": (total_rows - non_active_count) / total_rows if total_rows > 0 else 0
}
def search_station_event(self, station_name: str) -> Dict[str, Any]:
"""
Tool: Search events at a station
"""
if self.df is None:
return {"error": "No data loaded"}
# Filter by station name
# We assume 'station_name' column exists (added by FastParser)
station_df = self.df.filter(pl.col("station_name") == station_name)
if station_df.is_empty():
return {"message": f"No events found at {station_name}"}
# Return summary of that station visit
# e.g. avg speed, time range
return {
"station": station_name,
"visit_count": station_df.height,
"avg_speed": station_df["trainspeed"].mean(),
"start_time": str(station_df["timestamp"].min()),
"end_time": str(station_df["timestamp"].max())
}
def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""
Dispatcher for tool execution.
"""
if tool_name == "get_error_stats":
return self.get_error_stats()
elif tool_name == "search_station_event":
return self.search_station_event(arguments.get("station_name", ""))
else:
return {"error": "Unknown tool"}

1
app/ai/llm_engine.py Normal file
View File

@ -0,0 +1 @@
# LangChain / Ollama Integration

157
app/ai/maintenance_kb.py Normal file
View File

@ -0,0 +1,157 @@
"""
정비지침서(또는 추가 지식) JSON 로딩/검색 기반
목표:
- 사용자가 JSON 형태로 정비 지침서를 추가해두면 앱이 읽어서 AI 프롬프트에 관련 발췌를 넣을 있게
기본 경로:
- resources/maintenance_kb/*.json
- ~/.mmi_analyzer/maintenance_kb/*.json
JSON 포맷(권장, 유연하게 파싱):
[
{
"id": "door_001",
"title": "출입문 불일치 대응",
"tags": ["door", "psd", "fault"],
"keywords": ["출입문", "PSD", "불일치", "DOORSTAT"],
"content": "정비 절차 ...",
"source": "정비지침서 v1.2"
}
]
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def _tokenize(text: str) -> List[str]:
text = (text or "").lower()
# 한글/영문/숫자 중심 토큰화 (아주 단순)
tokens = re.findall(r"[0-9a-z가-힣]{2,}", text)
return tokens
@dataclass
class KBDoc:
doc_id: str
title: str
content: str
tags: List[str]
keywords: List[str]
source: str
raw: Dict[str, Any]
class MaintenanceKnowledgeBase:
def __init__(self):
self._docs: List[KBDoc] = []
self._resource_dir = Path(__file__).resolve().parents[2] / "resources" / "maintenance_kb"
self._user_dir = Path.home() / ".mmi_analyzer" / "maintenance_kb"
self._resource_dir.mkdir(parents=True, exist_ok=True)
self._user_dir.mkdir(parents=True, exist_ok=True)
def count(self) -> int:
return len(self._docs)
def reload(self) -> None:
self._docs = []
for base in [self._resource_dir, self._user_dir]:
for p in sorted(base.glob("*.json")):
self._load_file(p)
def _load_file(self, path: Path) -> None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return
items = data if isinstance(data, list) else [data]
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("name") or path.stem)
content = str(item.get("content") or item.get("text") or "")
tags = item.get("tags") or []
keywords = item.get("keywords") or []
source = str(item.get("source") or str(path.name))
doc_id = str(item.get("id") or f"{path.stem}:{i}")
if not content and item.get("steps"):
# steps 배열을 content로 합치기
try:
content = "\n".join([str(s) for s in item.get("steps")])
except Exception:
content = ""
self._docs.append(
KBDoc(
doc_id=doc_id,
title=title,
content=content,
tags=list(tags) if isinstance(tags, list) else [str(tags)],
keywords=list(keywords) if isinstance(keywords, list) else [str(keywords)],
source=source,
raw=item,
)
)
def search(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
q_tokens = set(_tokenize(query))
if not q_tokens or not self._docs:
return []
scored: List[Tuple[float, KBDoc]] = []
for d in self._docs:
hay = " ".join([d.title, d.content, " ".join(d.tags), " ".join(d.keywords)]).lower()
h_tokens = _tokenize(hay)
if not h_tokens:
continue
# 단순 점수: 토큰 매칭 수 + 키워드/태그 가중치
base = 0.0
for t in q_tokens:
if t in hay:
base += 1.0
for kw in d.keywords:
if kw and kw.lower() in query.lower():
base += 2.0
scored.append((base, d))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, d in scored[: max(0, top_k)]:
if score <= 0:
continue
snippet = d.content.strip().replace("\r\n", "\n")
if len(snippet) > 600:
snippet = snippet[:600] + "..."
results.append(
{
"id": d.doc_id,
"title": d.title,
"score": score,
"source": d.source,
"snippet": snippet,
}
)
return results
_kb_singleton: Optional[MaintenanceKnowledgeBase] = None
def get_maintenance_kb() -> MaintenanceKnowledgeBase:
global _kb_singleton
if _kb_singleton is None:
_kb_singleton = MaintenanceKnowledgeBase()
_kb_singleton.reload()
return _kb_singleton

View File

@ -0,0 +1,17 @@
"""
AI Providers 패키지
AI 서비스 프로바이더 구현을 포함합니다.
"""
from .openai_provider import OpenAIProvider
from .openrouter_provider import OpenRouterProvider
from .gemini_provider import GeminiProvider
from .xai_provider import XAIProvider
__all__ = [
'OpenAIProvider',
'OpenRouterProvider',
'GeminiProvider',
'XAIProvider'
]

Binary file not shown.

View File

@ -0,0 +1,156 @@
"""
Google Gemini Provider 구현
"""
import asyncio
from typing import Optional, List, Dict, Any
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
class GeminiProvider(BaseAIProvider):
"""Google Gemini API 프로바이더"""
AVAILABLE_MODELS = [
"gemini-2.0-flash-exp",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
"gemini-1.0-pro",
]
def __init__(self, api_key: str, model: Optional[str] = None):
super().__init__(api_key, model)
self._client = None
@property
def provider_type(self) -> AIProviderType:
return AIProviderType.GEMINI
@property
def provider_name(self) -> str:
return "Google Gemini"
@property
def default_model(self) -> str:
return "gemini-1.5-flash"
@property
def available_models(self) -> List[str]:
return self.AVAILABLE_MODELS.copy()
def initialize(self) -> bool:
"""Gemini 클라이언트 초기화"""
if not self.validate_api_key():
return False
try:
import google.generativeai as genai
genai.configure(api_key=self._api_key)
self._client = genai
self._is_initialized = True
return True
except ImportError:
print("Google Generative AI 라이브러리가 설치되지 않았습니다. pip install google-generativeai 를 실행하세요.")
return False
except Exception as e:
print(f"Gemini 초기화 실패: {e}")
return False
def _convert_messages_to_gemini(self, messages: List[AIMessage]) -> tuple:
"""
AIMessage를 Gemini 형식으로 변환
Returns:
(system_instruction, history, last_user_message)
"""
system_instruction = None
history = []
last_user_message = None
for msg in messages:
if msg.role == "system":
system_instruction = msg.content
elif msg.role == "user":
last_user_message = msg.content
# 이전 user 메시지는 history에 추가
if len([m for m in messages if m.role == "user"]) > 1:
history.append({"role": "user", "parts": [msg.content]})
elif msg.role == "assistant":
history.append({"role": "model", "parts": [msg.content]})
# 마지막 user 메시지를 history에서 제거 (send_message로 별도 전송)
if history and history[-1].get("role") == "user":
history.pop()
return system_instruction, history, last_user_message
async def chat(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""비동기 채팅 요청 (Gemini는 sync를 async로 래핑)"""
# Gemini SDK는 기본적으로 동기식이므로 executor에서 실행
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(messages, temperature, max_tokens, **kwargs)
)
def chat_sync(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("Gemini 초기화 실패")
system_instruction, history, last_user_message = self._convert_messages_to_gemini(messages)
# 모델 생성 설정
generation_config = {
"temperature": temperature,
}
if max_tokens:
generation_config["max_output_tokens"] = max_tokens
# 모델 인스턴스 생성
model = self._client.GenerativeModel(
model_name=self._model,
generation_config=generation_config,
system_instruction=system_instruction
)
# 대화 시작
if history:
chat = model.start_chat(history=history)
else:
chat = model.start_chat()
# 메시지 전송
response = chat.send_message(last_user_message or "")
# 토큰 사용량 추출 시도
usage = None
if hasattr(response, 'usage_metadata'):
usage = {
"prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
"completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
"total_tokens": getattr(response.usage_metadata, 'total_token_count', 0),
}
return AIResponse(
content=response.text,
model=self._model,
provider=self.provider_name,
usage=usage,
raw_response=response
)

View File

@ -0,0 +1,141 @@
"""
OpenAI Provider 구현
"""
import asyncio
from typing import Optional, List, Dict, Any
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
class OpenAIProvider(BaseAIProvider):
"""OpenAI API 프로바이더"""
AVAILABLE_MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1-preview",
"o1-mini",
]
def __init__(self, api_key: str, model: Optional[str] = None):
super().__init__(api_key, model)
self._client = None
self._async_client = None
@property
def provider_type(self) -> AIProviderType:
return AIProviderType.OPENAI
@property
def provider_name(self) -> str:
return "OpenAI"
@property
def default_model(self) -> str:
return "gpt-4o-mini"
@property
def available_models(self) -> List[str]:
return self.AVAILABLE_MODELS.copy()
def initialize(self) -> bool:
"""OpenAI 클라이언트 초기화"""
if not self.validate_api_key():
return False
try:
from openai import OpenAI, AsyncOpenAI
self._client = OpenAI(api_key=self._api_key)
self._async_client = AsyncOpenAI(api_key=self._api_key)
self._is_initialized = True
return True
except ImportError:
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
return False
except Exception as e:
print(f"OpenAI 초기화 실패: {e}")
return False
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
"""AIMessage를 OpenAI 형식으로 변환"""
return [{"role": msg.role, "content": msg.content} for msg in messages]
async def chat(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""비동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("OpenAI 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
params.update(kwargs)
response = await self._async_client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)
def chat_sync(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("OpenAI 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
params.update(kwargs)
response = self._client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)

View File

@ -0,0 +1,155 @@
"""
OpenRouter Provider 구현
OpenRouter는 다양한 AI 모델에 대한 통합 API를 제공합니다.
"""
import asyncio
from typing import Optional, List, Dict, Any
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
class OpenRouterProvider(BaseAIProvider):
"""OpenRouter API 프로바이더"""
BASE_URL = "https://openrouter.ai/api/v1"
AVAILABLE_MODELS = [
"openai/gpt-4o",
"openai/gpt-4o-mini",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-opus",
"google/gemini-pro-1.5",
"google/gemini-flash-1.5",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-8b-instruct",
"mistralai/mixtral-8x7b-instruct",
"deepseek/deepseek-chat",
]
def __init__(self, api_key: str, model: Optional[str] = None):
super().__init__(api_key, model)
self._client = None
self._async_client = None
@property
def provider_type(self) -> AIProviderType:
return AIProviderType.OPENROUTER
@property
def provider_name(self) -> str:
return "OpenRouter"
@property
def default_model(self) -> str:
return "openai/gpt-4o-mini"
@property
def available_models(self) -> List[str]:
return self.AVAILABLE_MODELS.copy()
def initialize(self) -> bool:
"""OpenRouter 클라이언트 초기화 (OpenAI 호환 API 사용)"""
if not self.validate_api_key():
return False
try:
from openai import OpenAI, AsyncOpenAI
self._client = OpenAI(
api_key=self._api_key,
base_url=self.BASE_URL
)
self._async_client = AsyncOpenAI(
api_key=self._api_key,
base_url=self.BASE_URL
)
self._is_initialized = True
return True
except ImportError:
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
return False
except Exception as e:
print(f"OpenRouter 초기화 실패: {e}")
return False
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
"""AIMessage를 OpenAI 형식으로 변환"""
return [{"role": msg.role, "content": msg.content} for msg in messages]
async def chat(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""비동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("OpenRouter 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
# OpenRouter 특화 헤더는 extra_headers로 전달 가능
params.update(kwargs)
response = await self._async_client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)
def chat_sync(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("OpenRouter 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
params.update(kwargs)
response = self._client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)

View File

@ -0,0 +1,146 @@
"""
xAI (Grok) Provider 구현
xAI API는 OpenAI 호환 형식을 사용합니다.
"""
import asyncio
from typing import Optional, List, Dict, Any
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
class XAIProvider(BaseAIProvider):
"""xAI (Grok) API 프로바이더"""
BASE_URL = "https://api.x.ai/v1"
AVAILABLE_MODELS = [
"grok-beta",
"grok-2-1212",
"grok-2-vision-1212",
]
def __init__(self, api_key: str, model: Optional[str] = None):
super().__init__(api_key, model)
self._client = None
self._async_client = None
@property
def provider_type(self) -> AIProviderType:
return AIProviderType.XAI
@property
def provider_name(self) -> str:
return "xAI (Grok)"
@property
def default_model(self) -> str:
return "grok-beta"
@property
def available_models(self) -> List[str]:
return self.AVAILABLE_MODELS.copy()
def initialize(self) -> bool:
"""xAI 클라이언트 초기화 (OpenAI 호환 API 사용)"""
if not self.validate_api_key():
return False
try:
from openai import OpenAI, AsyncOpenAI
self._client = OpenAI(
api_key=self._api_key,
base_url=self.BASE_URL
)
self._async_client = AsyncOpenAI(
api_key=self._api_key,
base_url=self.BASE_URL
)
self._is_initialized = True
return True
except ImportError:
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
return False
except Exception as e:
print(f"xAI 초기화 실패: {e}")
return False
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
"""AIMessage를 OpenAI 형식으로 변환"""
return [{"role": msg.role, "content": msg.content} for msg in messages]
async def chat(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""비동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("xAI 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
params.update(kwargs)
response = await self._async_client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)
def chat_sync(
self,
messages: List[AIMessage],
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> AIResponse:
"""동기 채팅 요청"""
if not self._is_initialized:
if not self.initialize():
raise RuntimeError("xAI 초기화 실패")
converted_messages = self._convert_messages(messages)
params = {
"model": self._model,
"messages": converted_messages,
"temperature": temperature,
}
if max_tokens:
params["max_tokens"] = max_tokens
params.update(kwargs)
response = self._client.chat.completions.create(**params)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
provider=self.provider_name,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
} if response.usage else None,
raw_response=response
)

0
app/analysis/__init__.py Normal file
View File

View File

@ -0,0 +1 @@
# Signal Correlation Logic

View File

@ -0,0 +1 @@
# Pattern Matching Logic

0
app/core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
app/core/ref_cache.db Normal file

Binary file not shown.

255
app/core/reference_data.py Normal file
View File

@ -0,0 +1,255 @@
"""
통합 레퍼런스 데이터 관리자
SQLite 로컬 캐시 + Supabase 동기화 준비
"""
import os
import sqlite3
from typing import Dict, Optional, List
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from app.core.reference_data_defaults import (
DEFAULT_STATIONS,
DEFAULT_ERROR_CODES,
REFERENCE_DATA_VERSION
)
class DoorDirection(Enum):
DW = "DW" # Door West (Left)
DE = "DE" # Door East (Right)
@dataclass
class StationMeta:
code: int
name: str
pos: float
door_dir: DoorDirection
station_no: int
track_line: str # 'TC1' or 'TC2'
class ReferenceDataManager:
"""
통합 레퍼런스 데이터 관리자
- SQLite 로컬 캐싱
- 메모리 캐시로 빠른 조회
- Supabase 동기화 준비
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(ReferenceDataManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
# DB 경로 설정
self.db_path = Path(__file__).parent / "ref_cache.db"
# 메모리 캐시
self.tc1_stations: Dict[int, StationMeta] = {}
self.tc2_stations: Dict[int, StationMeta] = {}
self.station_name_map: Dict[str, int] = {}
self.error_code_map: Dict[str, str] = {}
self._is_loaded = False
# 초기화
self._init_db()
self._load_to_memory()
def _init_db(self):
"""DB 초기화 (첫 실행 시 기본 데이터로 생성)"""
db_exists = self.db_path.exists()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS stations (
code INTEGER PRIMARY KEY,
name TEXT NOT NULL,
pos REAL NOT NULL,
door_dir TEXT NOT NULL,
station_no INTEGER NOT NULL,
track_line TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS error_codes (
code TEXT PRIMARY KEY,
description TEXT NOT NULL
)
""")
# 인덱스 생성
cursor.execute("CREATE INDEX IF NOT EXISTS idx_station_name ON stations(name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_track_line ON stations(track_line)")
# 첫 실행 시 기본 데이터 삽입
if not db_exists:
print("[RefData] Initializing database with default data...")
# 역 정보 삽입
for station in DEFAULT_STATIONS:
cursor.execute("""
INSERT INTO stations (code, name, pos, door_dir, station_no, track_line)
VALUES (?, ?, ?, ?, ?, ?)
""", (
station["code"],
station["name"],
station["pos"],
station["door_dir"],
station["station_no"],
station["track_line"]
))
# 에러 코드 삽입
for code, desc in DEFAULT_ERROR_CODES.items():
cursor.execute("""
INSERT INTO error_codes (code, description)
VALUES (?, ?)
""", (code, desc))
# 버전 정보 삽입
cursor.execute("""
INSERT INTO metadata (key, value)
VALUES ('version', ?)
""", (REFERENCE_DATA_VERSION,))
conn.commit()
print(f"[RefData] Database initialized with version {REFERENCE_DATA_VERSION}")
conn.close()
def _load_to_memory(self):
"""SQLite에서 메모리로 로드 (빠른 조회를 위한 캐싱)"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 역 정보 로드
cursor.execute("SELECT code, name, pos, door_dir, station_no, track_line FROM stations")
rows = cursor.fetchall()
for row in rows:
code, name, pos, door_dir, station_no, track_line = row
station = StationMeta(
code=code,
name=name,
pos=pos,
door_dir=DoorDirection(door_dir),
station_no=station_no,
track_line=track_line
)
if track_line == "TC1":
self.tc1_stations[code] = station
elif track_line == "TC2":
self.tc2_stations[code] = station
# 이름 매핑 (TC1 우선)
if track_line == "TC1":
self.station_name_map[name] = code
# TC2 이름 매핑 (TC1에 없는 경우만)
for code, station in self.tc2_stations.items():
if station.name not in self.station_name_map:
self.station_name_map[station.name] = code
# 에러 코드 로드
cursor.execute("SELECT code, description FROM error_codes")
for code, desc in cursor.fetchall():
self.error_code_map[code] = desc
conn.close()
self._is_loaded = True
print(f"[RefData] Loaded from cache. TC1: {len(self.tc1_stations)}, TC2: {len(self.tc2_stations)}")
# ========== 조회 메서드 (기존 API 호환) ==========
def get_station_name(self, code: int) -> str:
"""역 코드로 역 이름 조회"""
if code in self.tc1_stations:
return self.tc1_stations[code].name
if code in self.tc2_stations:
return self.tc2_stations[code].name
return ""
def get_station_info(self, code: int) -> Optional[StationMeta]:
"""역 코드로 역 정보 조회"""
if code in self.tc1_stations:
return self.tc1_stations[code]
if code in self.tc2_stations:
return self.tc2_stations[code]
return None
def get_station_code(self, name: str) -> int:
"""역 이름으로 역 코드 조회"""
return self.station_name_map.get(name, 0)
def get_station_distance(self, station_name: str, is_tc1: bool) -> float:
"""역 이름으로 거리 조회"""
target_list = self.tc1_stations if is_tc1 else self.tc2_stations
for station in target_list.values():
if station.name == station_name:
return station.pos
return 0.0
def get_door_direction(self, station_name_or_code, is_tc1: bool) -> DoorDirection:
"""역 이름 또는 코드로 도어 방향 조회"""
target_list = self.tc1_stations if is_tc1 else self.tc2_stations
for station in target_list.values():
if isinstance(station_name_or_code, str):
if station.name == station_name_or_code:
return station.door_dir
else:
if station.code == station_name_or_code:
return station.door_dir
return DoorDirection.DW
# ========== Supabase 동기화 (추후 구현) ==========
async def sync_from_supabase(self):
"""
Supabase에서 최신 데이터 동기화
TODO: 추후 구현
"""
pass
def get_version(self) -> str:
"""현재 데이터 버전 조회"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT value FROM metadata WHERE key = 'version'")
result = cursor.fetchone()
conn.close()
return result[0] if result else "unknown"
# 전역 인스턴스
ref_data = ReferenceDataManager()

View File

@ -0,0 +1,29 @@
"""
기본 레퍼런스 데이터 정의
실행 SQLite DB 초기화에 사용됨
"""
from typing import List, Dict, Any
# 역 정보 기본 데이터
DEFAULT_STATIONS: List[Dict[str, Any]] = [
# TC1 (하선) - 예시 데이터, 실제 데이터로 교체 필요
{"code": 1001, "name": "역1", "pos": 0.0, "door_dir": "DW", "station_no": 1, "track_line": "TC1"},
{"code": 1002, "name": "역2", "pos": 1500.0, "door_dir": "DE", "station_no": 2, "track_line": "TC1"},
{"code": 1003, "name": "역3", "pos": 3200.0, "door_dir": "DW", "station_no": 3, "track_line": "TC1"},
# TC2 (상선) - 예시 데이터, 실제 데이터로 교체 필요
{"code": 2001, "name": "역1", "pos": 0.0, "door_dir": "DE", "station_no": 1, "track_line": "TC2"},
{"code": 2002, "name": "역2", "pos": 1500.0, "door_dir": "DW", "station_no": 2, "track_line": "TC2"},
{"code": 2003, "name": "역3", "pos": 3200.0, "door_dir": "DE", "station_no": 3, "track_line": "TC2"},
]
# 에러 코드 매핑 (예시)
DEFAULT_ERROR_CODES: Dict[str, str] = {
"E001": "통신 오류",
"E002": "센서 이상",
"E003": "제동 시스템 오류",
}
# 메타데이터 버전 정보
REFERENCE_DATA_VERSION = "1.0.0"

255
app/core/settings.py Normal file
View File

@ -0,0 +1,255 @@
"""
Settings management
애플리케이션 설정 관리 모듈
- 일반 설정: ~/.mmi_analyzer/settings.json
- AI 설정( 포함): ~/.mmi_analyzer/ai_settings.json (별도 파일)
"""
import json
from pathlib import Path
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
@dataclass
class AIProviderConfig:
"""개별 AI 프로바이더 설정"""
api_key: str = ""
model: str = ""
enabled: bool = False
@dataclass
class AISettings:
"""AI 관련 설정"""
current_provider: str = "" # 현재 선택된 프로바이더
providers: Dict[str, Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self):
# 기본 프로바이더 설정 초기화
default_providers = {
"openai": {"api_key": "", "model": "gpt-4o-mini", "enabled": False},
"openrouter": {"api_key": "", "model": "openai/gpt-4o-mini", "enabled": False},
"gemini": {"api_key": "", "model": "gemini-1.5-flash", "enabled": False},
"xai": {"api_key": "", "model": "grok-beta", "enabled": False},
}
for key, value in default_providers.items():
if key not in self.providers:
self.providers[key] = value
def get_api_key(self, provider_type: str) -> str:
"""프로바이더의 API 키 반환"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
if provider_key in self.providers:
return self.providers[provider_key].get("api_key", "")
return ""
def set_api_key(self, provider_type: str, api_key: str):
"""프로바이더의 API 키 설정"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
if provider_key not in self.providers:
self.providers[provider_key] = {}
self.providers[provider_key]["api_key"] = api_key
if api_key:
self.providers[provider_key]["enabled"] = True
def get_model(self, provider_type: str) -> str:
"""프로바이더의 모델 반환"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
if provider_key in self.providers:
return self.providers[provider_key].get("model", "")
return ""
def set_model(self, provider_type: str, model: str):
"""프로바이더의 모델 설정"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
if provider_key not in self.providers:
self.providers[provider_key] = {}
self.providers[provider_key]["model"] = model
def get_current_provider(self) -> Optional[str]:
"""현재 선택된 프로바이더 타입 반환"""
return self.current_provider if self.current_provider else None
def set_current_provider(self, provider_type: str):
"""현재 프로바이더 설정"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
self.current_provider = provider_key
def is_provider_configured(self, provider_type: str) -> bool:
"""프로바이더가 설정되었는지 확인"""
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
if provider_key in self.providers:
api_key = self.providers[provider_key].get("api_key", "")
return bool(api_key)
return False
@dataclass
class AppSettings:
"""전체 애플리케이션 설정"""
ai: AISettings = field(default_factory=AISettings)
theme: str = "dark"
language: str = "ko"
window_geometry: Dict[str, int] = field(default_factory=lambda: {
"x": 100, "y": 100, "width": 1800, "height": 1000
})
recent_files: list = field(default_factory=list)
max_recent_files: int = 10
class SettingsManager:
"""
설정 관리자
설정 파일 저장/로드 관리
"""
DEFAULT_SETTINGS_DIR = Path.home() / ".mmi_analyzer"
APP_SETTINGS_FILE = "settings.json"
AI_SETTINGS_FILE = "ai_settings.json"
def __init__(self, settings_dir: Optional[Path] = None):
self._settings_dir = settings_dir or self.DEFAULT_SETTINGS_DIR
self._app_settings_file = self._settings_dir / self.APP_SETTINGS_FILE
self._ai_settings_file = self._settings_dir / self.AI_SETTINGS_FILE
self._settings: Optional[AppSettings] = None
# 설정 디렉토리 생성
self._settings_dir.mkdir(parents=True, exist_ok=True)
@property
def settings(self) -> AppSettings:
"""현재 설정 반환 (없으면 로드)"""
if self._settings is None:
self.load()
return self._settings
@property
def ai_settings(self) -> AISettings:
"""AI 설정 반환"""
return self.settings.ai
def load(self) -> AppSettings:
"""설정 파일에서 로드"""
app_data: Dict[str, Any] = {}
ai_data: Dict[str, Any] = {}
# 1) 앱 설정 로드
if self._app_settings_file.exists():
try:
with open(self._app_settings_file, "r", encoding="utf-8") as f:
app_data = json.load(f) or {}
except Exception as e:
print(f"앱 설정 로드 실패: {e}")
app_data = {}
# 2) AI 설정 로드 (별도 파일)
if self._ai_settings_file.exists():
try:
with open(self._ai_settings_file, "r", encoding="utf-8") as f:
ai_data = json.load(f) or {}
except Exception as e:
print(f"AI 설정 로드 실패: {e}")
ai_data = {}
else:
# 3) 마이그레이션: 과거 settings.json 안의 ai 섹션이 있으면 ai_settings.json로 이동
legacy_ai = (app_data or {}).get("ai")
if isinstance(legacy_ai, dict) and legacy_ai:
ai_data = legacy_ai
try:
# 저장 후 app_data에서 ai 섹션 제거
with open(self._ai_settings_file, "w", encoding="utf-8") as f:
json.dump(ai_data, f, indent=2, ensure_ascii=False)
app_data.pop("ai", None)
with open(self._app_settings_file, "w", encoding="utf-8") as f:
json.dump(app_data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"AI 설정 마이그레이션 실패: {e}")
# AI 설정 복원
ai_settings = AISettings(
current_provider=ai_data.get("current_provider", "") if isinstance(ai_data, dict) else "",
providers=ai_data.get("providers", {}) if isinstance(ai_data, dict) else {},
)
self._settings = AppSettings(
ai=ai_settings,
theme=app_data.get("theme", "dark"),
language=app_data.get("language", "ko"),
window_geometry=app_data.get("window_geometry", {}),
recent_files=app_data.get("recent_files", []),
max_recent_files=app_data.get("max_recent_files", 10),
)
return self._settings
def save(self):
"""설정을 파일에 저장"""
if self._settings is None:
return
# 앱 설정 저장 (AI 제외)
try:
app_data = {
"theme": self._settings.theme,
"language": self._settings.language,
"window_geometry": self._settings.window_geometry,
"recent_files": self._settings.recent_files,
"max_recent_files": self._settings.max_recent_files,
}
with open(self._app_settings_file, "w", encoding="utf-8") as f:
json.dump(app_data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"앱 설정 저장 실패: {e}")
# AI 설정 저장 (별도)
try:
ai_data = {
"current_provider": self._settings.ai.current_provider,
"providers": self._settings.ai.providers,
}
with open(self._ai_settings_file, "w", encoding="utf-8") as f:
json.dump(ai_data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"AI 설정 저장 실패: {e}")
def reset(self):
"""설정 초기화"""
self._settings = AppSettings()
self.save()
def add_recent_file(self, file_path: str):
"""최근 파일 추가"""
if file_path in self.settings.recent_files:
self.settings.recent_files.remove(file_path)
self.settings.recent_files.insert(0, file_path)
# 최대 개수 제한
while len(self.settings.recent_files) > self.settings.max_recent_files:
self.settings.recent_files.pop()
self.save()
# 싱글톤 인스턴스
_settings_manager: Optional[SettingsManager] = None
def get_settings_manager() -> SettingsManager:
"""설정 관리자 싱글톤 인스턴스 반환"""
global _settings_manager
if _settings_manager is None:
_settings_manager = SettingsManager()
return _settings_manager
def get_settings() -> AppSettings:
"""현재 설정 반환"""
return get_settings_manager().settings
def get_ai_settings() -> AISettings:
"""AI 설정 반환"""
return get_settings_manager().ai_settings

View File

@ -0,0 +1,39 @@
from PySide6.QtCore import QObject, Signal
class SyncController(QObject):
# 시점 변경 시그널 (인덱스, 데이터행, 보낸사람ID)
time_changed = Signal(int, object, str)
# X축 범위 변경 시그널 (min_ms, max_ms, 보낸사람ID)
# 밀리초(ms)는 1.7e12 수준이라 32-bit int를 초과할 수 있어 object로 받음(오버플로우 방지)
x_range_changed = Signal(object, object, object)
# 동기화 활성화 여부
is_sync_enabled = True
def __init__(self):
super().__init__()
self._total_records = 0
def request_sync(self, index, data_row, source_id):
"""
특정 패널에서 시간 변경 요청이 오면,
동기화가 켜져 있을 때만 다른 패널들에게 전파
"""
if self.is_sync_enabled:
# Sync가 켜져있으면 모두에게 알림 (source_id 포함)
self.time_changed.emit(index, data_row, source_id)
else:
# Sync가 꺼져있으면 아무것도 안 함 (각자 놈)
pass
def request_x_range_sync(self, min_ms: int, max_ms: int, source_id):
"""
특정 패널에서 X축(시간축) 범위 변경 요청이 오면,
동기화가 켜져 있을 때만 다른 패널들에게 전파
"""
if self.is_sync_enabled:
# int 캐스팅은 유지하되 시그널 타입은 object라 안전(파이썬 int 그대로 전달)
self.x_range_changed.emit(int(min_ms), int(max_ms), source_id)
sync_manager = SyncController()

View File

@ -0,0 +1,37 @@
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
class ThemeManager:
"""애플리케이션 전체의 테마(Light/Dark)를 관리"""
LIGHT_THEME = {
"bg": "#FFFFFF", "fg": "#000000", "panel": "#F0F0F0",
"accent": "#0078D7", "border": "#CCCCCC", "text": "#333333"
}
DARK_THEME = {
"bg": "#1E1E1E", "fg": "#FFFFFF", "panel": "#2D2D30",
"accent": "#007ACC", "border": "#3E3E42", "text": "#D4D4D4"
}
@staticmethod
def apply_theme(app: QApplication, theme="dark"):
palette = QPalette()
colors = ThemeManager.DARK_THEME if theme == "dark" else ThemeManager.LIGHT_THEME
# 기본 팔레트 설정 (Qt 기본 위젯용)
# (상세 팔레트 설정 코드는 생략하고 스타일시트 위주로 적용)
# 공통 스타일시트 (Fusion 스타일 + 커스텀)
qss = f"""
QMainWindow {{ background-color: {colors['bg']}; color: {colors['text']}; }}
QWidget {{ background-color: {colors['bg']}; color: {colors['text']}; font-family: 'Malgun Gothic', 'Segoe UI'; font-size: 10pt; }}
QTabWidget::pane {{ border: 1px solid {colors['border']}; background: {colors['panel']}; }}
QTabBar::tab {{ background: {colors['bg']}; border: 1px solid {colors['border']}; padding: 8px 16px; margin-right: 2px; }}
QTabBar::tab:selected {{ background: {colors['accent']}; color: white; font-weight: bold; }}
QSplitter::handle {{ background-color: {colors['border']}; width: 2px; }}
QPushButton {{ background-color: {colors['panel']}; border: 1px solid {colors['border']}; padding: 6px 12px; border-radius: 4px; }}
QPushButton:hover {{ background-color: {colors['accent']}; color: white; }}
QStatusBar {{ background: {colors['panel']}; color: {colors['text']}; border-top: 1px solid {colors['border']}; }}
"""
app.setStyleSheet(qss)

0
app/data/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
app/data/db_handler.py Normal file
View File

@ -0,0 +1 @@
# SQLite/JSON handler

254
app/data/fast_parser.py Normal file
View File

@ -0,0 +1,254 @@
import os
import numpy as np
import os
import numpy as np
import polars as pl
from typing import Optional
class FastLogParser:
CHUNK_SIZE = 84
@staticmethod
def parse_to_parquet(input_path: str, output_path: str = None) -> Optional[str]:
"""
Numpy 벡터 연산을 사용하여 MMI 바이너리 로그를 초고속으로 파싱 Parquet 저장.
(VDI/VDO 포함 모든 필드 완벽 구현)
"""
if not os.path.exists(input_path):
return None
file_size = os.path.getsize(input_path)
n_records = file_size // FastLogParser.CHUNK_SIZE
if n_records == 0:
return None
try:
with open(input_path, 'rb') as f:
# 파일 전체를 한번에 메모리로 로드 (가장 빠름)
raw_data = np.fromfile(f, dtype=np.uint8)
# 잘린 마지막 청크 제거 및 Reshape
limit = n_records * FastLogParser.CHUNK_SIZE
raw_data = raw_data[:limit]
# (Row수, 84바이트) 형태의 2차원 배열로 변환
m = raw_data.reshape(n_records, FastLogParser.CHUNK_SIZE)
# --- [1. Basic Info] ---
seq = m[:, 1]
source = m[:, 3]
# 16(0x10) or 80(0x50) -> 1계, 그 외 -> 2계
system_id = np.where((source == 16) | (source == 80), 1, 2).astype(np.uint8)
# 이중계 판단 (32가 아니면 Break/Standby)
is_break = (source != 32)
# Time (BCD-like hex display: 0x25 -> 25)
# (val // 16) * 10 + (val % 16) 로직이 맞음
def bcd(arr): return (arr // 16) * 10 + (arr % 16)
year = bcd(m[:, 4]).astype(np.int16) + 2000
month = bcd(m[:, 5])
day = bcd(m[:, 6])
hour = bcd(m[:, 7])
minute = bcd(m[:, 8])
second = bcd(m[:, 9])
# --- [2. Speed & Analog] ---
# uint8 두 개를 합쳐 uint16으로 변환 시 반드시 먼저 astype(uint16)을 해야 함 (안 그러면 오버플로우)
trainspeed = ((m[:, 10].astype(np.uint16) << 8) | m[:, 11].astype(np.uint16)) / 10.0
limitspeed = m[:, 12]
pwm_value = m[:, 22]
ato_limitSpeed = m[:, 33]
tasc_value = m[:, 33]
# --- [3. Digital Flags (Bitwise Vectorized)] ---
# Byte 13: DO ATC
b13 = m[:, 13]
do_zvr = (b13 & 32) > 0; do_edl = (b13 & 16) > 0; do_edr = (b13 & 8) > 0
do_fsb = (b13 & 4) > 0; do_ebm = (b13 & 2) > 0; do_ebp = (b13 & 1) > 0
# Byte 14: Status
b14 = m[:, 14]
system_active = (b14 & 128) > 0; over_spd_warning = (b14 & 64) > 0
tcr = (b14 & 32) > 0; hcr = (b14 & 16) > 0
door_open = (b14 & 8) > 0; door_close = (b14 & 4) > 0
psd_open = (b14 & 2) > 0; psd_close = (b14 & 1) > 0
# Byte 15: Mode
b15 = m[:, 15]
fa = (b15 & 128) > 0; auto = (b15 & 64) > 0; mcs = (b15 & 32) > 0
yard = (b15 & 16) > 0; fmc = (b15 & 8) > 0
reverser_rvs = (b15 & 4) > 0; reverser_fwd = (b15 & 2) > 0; reverser_neu = (b15 & 1) > 0
# Byte 16: Mascon
b16 = m[:, 16]
ato_start_btn = (b16 & 128) > 0; ato_eb_req = (b16 & 64) > 0
tacho_dir_a = (b16 & 32) > 0; tacho_dir_b = (b16 & 16) > 0
mascon_dr = (b16 & 2) > 0; mascon_br = (b16 & 4) > 0; mascon_eb = (b16 & 8) > 0
# Byte 19: ATC Status
b19 = m[:, 19]
val_19 = b19 & 192 # 0, 192, 64, 128
wheelcheck = (b19 & 32) > 0
# Byte 20: Fail
b20 = m[:, 20]
fail_atcr = (b20 & 128) > 0; fail_atoc = (b20 & 64) > 0; fail_tcms = (b20 & 32) > 0
fail_tacho2 = (b20 & 2) > 0; fail_tacho1 = (b20 & 1) > 0
# Byte 21: Marker
b21 = m[:, 21]
recovery = (b21 & 128) > 0; nomal = (b21 & 64) > 0; tasc = (b21 & 32) > 0
marker_val = b21 & 31 # 16, 8, 4, 2, 1
# Byte 23: RLY ATO
b23 = m[:, 23]
trac_dr = (b23 & 128) > 0; trac_br = (b23 & 64) > 0; trac_cs = (b23 & 32) > 0
ador = (b23 & 16) > 0; adol = (b23 & 8) > 0; adc = (b23 & 4) > 0
start_enable = (b23 & 2) > 0; trainberth = (b23 & 1) > 0
# Byte 24: TCMS
b24 = m[:, 24]
tc2 = (b24 & 128) > 0; tc1 = (b24 & 64) > 0; tascdb = (b24 & 32) > 0
# Byte 25: ETC
b25 = m[:, 25]
pre_brake = (b25 & 128) > 0; limit_drive = (b25 & 64) > 0
ov_stop1 = (b25 & 8) > 0; ov_stop2 = (b25 & 4) > 0
sh_stop1 = (b25 & 2) > 0; sh_stop2 = (b25 & 1) > 0
# Byte 26-27: Train No
trainno_int = (m[:, 26].astype(np.uint16) << 8) | m[:, 27].astype(np.uint16)
# Byte 28-30: Stations
pstn = m[:, 28]; nstn = m[:, 29]; dstn = m[:, 30]
# Byte 31-32: DTG (Distance to Go) - 정밀 로직 구현
num_dtg = (m[:, 31].astype(np.uint16) << 8) | m[:, 32].astype(np.uint16)
# DTG 부호 처리: uint16을 int16으로 해석해서 음수 여부 판단
# C# 로직: if (num_dtg & 32768) != 0: num_dtg -= 65536
# Numpy에서는 그냥 .view(np.int16)하거나 astype(np.int16)하면 32768(0x8000) 이상은 자동으로 음수가 됨.
dtg_signed = num_dtg.astype(np.int16)
# 조건: TASC 모드가 아니면서 속도가 0이고 역행(Dr)이 아닐 때 부호 반전
cond_reverse = (~tasc) & (trainspeed == 0.0) & (~trac_dr)
# np.where(조건, 참일때값, 거짓일때값)
# 참일 때: (값 * -1) / 100.0
# 거짓일 때: TASC면 / 100.0, 아니면 / 10.0
dtg = np.where(
cond_reverse,
dtg_signed * -1.0 / 100.0,
np.where(tasc, num_dtg / 100.0, num_dtg / 10.0)
)
# Byte 34: TWC
b34 = m[:, 34]
twct_enable = (b34 & 16) > 0; door_close_warning = (b34 & 8) > 0; wrongdoor = (b34 & 4) > 0
# Byte 35: ATC Code
b35 = m[:, 35]
atc_idx = (b35 & 240) >> 4
osc_f0_ok = (b35 & 8) > 0
# Byte 36-39, 55, 58: Freq (XOR)
atc_code_carrier_f = (((m[:, 36]^65).astype(np.uint16) << 8) | (m[:, 37]^82).astype(np.uint16)) * 10.0
atc_code_f = (((m[:, 38]^99).astype(np.uint16) << 8) | (m[:, 39]^116).astype(np.uint16)) / 10.0
osc_f = (((m[:, 55]^99).astype(np.uint16) << 8) | (m[:, 58]^100).astype(np.uint16)) / 10.0
# --- [Missing Fields Restored: VDI/VDO] ---
# VDI A (63-65)
vdia_rvs = (m[:, 63] & 128) > 0; vdia_neu = (m[:, 63] & 64) > 0; vdia_fwd = (m[:, 63] & 32) > 0
vdia_mascondr = (m[:, 63] & 16) > 0; vdia_masconbr = (m[:, 63] & 8) > 0; vdia_masconeb = (m[:, 63] & 4) > 0
vdia_doorclose = (m[:, 63] & 2) > 0; vdia_dooropen = (m[:, 63] & 1) > 0
vdia_fmc = (m[:, 64] & 64) > 0; vdia_yard = (m[:, 64] & 32) > 0; vdia_mcs = (m[:, 64] & 16) > 0
vdia_auto = (m[:, 64] & 8) > 0; vdia_fa = (m[:, 64] & 4) > 0
# VDI B (66-68)
vdib_rvs = (m[:, 66] & 128) > 0; vdib_neu = (m[:, 66] & 64) > 0; vdib_fwd = (m[:, 66] & 32) > 0
vdib_mascondr = (m[:, 66] & 16) > 0; vdib_masconbr = (m[:, 66] & 8) > 0; vdib_masconeb = (m[:, 66] & 4) > 0
vdib_doorclose = (m[:, 66] & 2) > 0; vdib_dooropen = (m[:, 66] & 1) > 0
# VDI C (69-71)
vdic_tc2 = (m[:, 69] & 128) > 0; vdic_tc1 = (m[:, 69] & 64) > 0
vdic_edlfb = (m[:, 69] & 32) > 0; vdic_edrfb = (m[:, 69] & 16) > 0
vdic_psdclose = (m[:, 70] & 2) > 0; vdic_psdopen = (m[:, 70] & 1) > 0
# VDO A (75-76)
vdoa_edl = (m[:, 75] & 32) > 0; vdoa_edr = (m[:, 75] & 16) > 0
vdoa_zvr = (m[:, 75] & 8) > 0; vdoa_fsb = (m[:, 75] & 4) > 0
# --- [4. Create DataFrame] ---
data_dict = {
"seq": seq, "source": source, "system_id": system_id, "is_break": is_break,
"year": year, "month": month, "day": day, "hour": hour, "minute": minute, "second": second,
"trainspeed": trainspeed, "limitspeed": limitspeed, "pwm_value": pwm_value,
"ato_limitSpeed": ato_limitSpeed, "tasc_value": tasc_value,
"do_zvr": do_zvr, "do_edl": do_edl, "do_edr": do_edr, "do_fsb": do_fsb, "do_ebm": do_ebm, "do_ebp": do_ebp,
"system_active": system_active, "over_spd_warning": over_spd_warning, "tcr": tcr, "hcr": hcr,
"door_open": door_open, "door_close": door_close, "psd_open": psd_open, "psd_close": psd_close,
"fa": fa, "auto": auto, "mcs": mcs, "yard": yard, "fmc": fmc,
"reverser_rvs": reverser_rvs, "reverser_fwd": reverser_fwd, "reverser_neu": reverser_neu,
"ato_start_btn": ato_start_btn, "ato_eb_req": ato_eb_req,
"tacho_dir_a": tacho_dir_a, "tacho_dir_b": tacho_dir_b,
"mascon_dr": mascon_dr, "mascon_br": mascon_br, "mascon_eb": mascon_eb,
"atc_status_code": val_19,
"fail_atcr": fail_atcr, "fail_atoc": fail_atoc, "fail_tcms": fail_tcms,
"recovery": recovery, "nomal": nomal, "tasc": tasc, "marker_val": marker_val,
"trac_dr": trac_dr, "trac_br": trac_br, "trac_cs": trac_cs,
"tc2": tc2, "tc1": tc1, "tascdb": tascdb,
"pre_brake": pre_brake, "limit_drive": limit_drive,
"trainno_int": trainno_int,
"pstn": pstn, "nstn": nstn, "dstn": dstn,
"dtg": dtg,
"twct_enable": twct_enable, "door_close_warning": door_close_warning, "wrongdoor": wrongdoor,
"atc_idx": atc_idx, "osc_f0_ok": osc_f0_ok,
"atc_code_carrier_f": atc_code_carrier_f, "atc_code_f": atc_code_f, "osc_f": osc_f,
# 복원된 VDI/VDO
"vdia_rvs": vdia_rvs, "vdia_neu": vdia_neu, "vdia_fwd": vdia_fwd,
"vdia_mascondr": vdia_mascondr, "vdia_masconbr": vdia_masconbr,
"vdia_doorclose": vdia_doorclose, "vdia_dooropen": vdia_dooropen,
"vdib_doorclose": vdib_doorclose, "vdib_dooropen": vdib_dooropen,
"vdoa_edl": vdoa_edl, "vdoa_edr": vdoa_edr, "vdoa_zvr": vdoa_zvr
}
df = pl.DataFrame(data_dict)
# --- [5. Post-Processing] ---
# Polars의 강력한 문자열 처리 기능 사용 (Lambda 루프 제거 -> 속도 향상)
# 시간 문자열 생성
df = df.with_columns(
pl.format("{}-{}-{} {}:{}:{}",
pl.col("year"),
pl.col("month").cast(pl.String).str.zfill(2),
pl.col("day").cast(pl.String).str.zfill(2),
pl.col("hour").cast(pl.String).str.zfill(2),
pl.col("minute").cast(pl.String).str.zfill(2),
pl.col("second").cast(pl.String).str.zfill(2)
).alias("time")
)
# 열차번호 Hex String 변환
df = df.with_columns(
pl.col("trainno_int")
.map_elements(lambda x: f"{x:04X}", return_dtype=pl.String)
.alias("trainno")
)
# [Optional] 여기서 역 이름 매핑이나 코드 변환을 수행할 수 있음
# (이전 대화의 Processor 로직을 여기에 통합해도 됨)
if output_path is None:
output_path = input_path.replace(".dat", ".parquet")
df.write_parquet(output_path, compression="zstd")
return output_path
except Exception as e:
print(f"[FastParser] Error: {e}")
import traceback
traceback.print_exc()
return None

1
app/data/log_manager.py Normal file
View File

@ -0,0 +1 @@
# Data caching and management

448
app/data/log_parser.py Normal file
View File

@ -0,0 +1,448 @@
import struct
import os
from dataclasses import dataclass, field
from typing import List, Optional
# -------------------------------------------------------------------------
# 1. 데이터 구조 정의 (C# DOSItoMMIClass 완벽 대응)
# -------------------------------------------------------------------------
@dataclass
class DOSItoMMIClass:
# [기본 정보]
seq: int = 0
source: int = 0 # 16/80: 1계, 그외: 2계
time: str = ""
trainno: str = "" # 열차번호
# [속도 및 아날로그 값]
trainspeed: float = 0.0 # 현재 속도
limitspeed: int = 0 # 제한 속도
pwm_value: int = 0 # PWM
dtg: float = 0.0 # 잔여 거리
ato_limitSpeed: int = 0 # ATO 제한 속도
tasc_value: int = 0 # 그래프용(ato_limitSpeed와 동일)
# [ATC/ATO 주파수 및 코드 (XOR 복호화 데이터)]
osc_f: float = 0.0 # OSC 주파수
atc_code_carrier_f: float = 0.0 # ATC Carrier Freq
atc_code_f: float = 0.0 # ATC Code Freq
atc_carrier_f: str = "-" # F1~F4 문자열
# [ATC 카드 상태 - Byte 13, 14, 19]
do_zvr: bool = False; do_edl: bool = False; do_edr: bool = False
do_fsb: bool = False; do_ebm: bool = False; do_ebp: bool = False
system_active: bool = False; over_spd_warning: bool = False
tcr: bool = False; hcr: bool = False
door_open: bool = False; door_close: bool = False
psd_open: bool = False; psd_close: bool = False
atc_status: str = "-" # INITIAL, MANUAL, ACTIVE, STANDBY
wheelcheck: bool = False
mpdt: str = "-" # START, NG, OK
ipdt: str = "-" # NG, OK
# [운전 모드 및 마스콘 - Byte 15, 16]
fa: bool = False; auto: bool = False; mcs: bool = False
yard: bool = False; fmc: bool = False
reverser_rvs: bool = False; reverser_fwd: bool = False; reverser_neu: bool = False
ato_start_btn: bool = False; ato_eb_req: bool = False
tacho_dir_a: bool = False; tacho_dir_b: bool = False
mascon_neu: bool = False; mascon_dr: bool = False
mascon_br: bool = False; mascon_eb: bool = False
# [FAIL 카드 정보 - Byte 20]
fail_atcr: bool = False; fail_atoc: bool = False; fail_tcms: bool = False
fail_tacho2: bool = False; fail_tacho1: bool = False
# [마커 및 TWC - Byte 21, 28-30, 34]
recovery: bool = False; nomal: bool = False; tasc: bool = False
marker: str = "-" # ATS, PGX, PG3-2, PG2, PG1
pstn: int = 0; nstn: int = 0; dstn: int = 0 # 전역, 현역, 종착역
twct_enable: bool = False # TWC 수신 여부
door_close_warning: bool = False # DCW
wrongdoor: bool = False
nextdoor: str = "-"
station_name: str = "" # 역 이름 (TrackInfo 매핑)
# [ATO RLY 및 기타 - Byte 23, 24, 25]
trac_dr: bool = False; trac_br: bool = False; trac_cs: bool = False
ador: bool = False; adol: bool = False; adc: bool = False
start_enable: bool = False; trainberth: bool = False
tc2: bool = False; tc1: bool = False; tascdb: bool = False
tcmsdoor: str = "-" # LDO, RDO
doormod: str = "-" # A/A, A/M, M/M
pre_brake: bool = False; limit_drive: bool = False
ov_stop1: bool = False; ov_stop2: bool = False
sh_stop1: bool = False; sh_stop2: bool = False
# [ATC 코드 상세 - Byte 35]
atc_code: str = "-" # 01, 02, 25 ...
atc_code_str: str = "-" # 호환용(동일 값)
atc_code_val: int = 0 # 그래프용 속도값
osc_f0_ok: bool = False
# [에러 메시지 모음]
diagnostic_err_msg: str = ""
atc_err_msg: str = ""
interface_err_msg: str = ""
ato_err_msg: str = ""
# [VDI/VDO 카드 (DIO) - Byte 63~78]
# VDI A
vdia_stat: str = ""; vdia_input: str = ""
vdia_rvs: bool = False; vdia_neu: bool = False; vdia_fwd: bool = False
vdia_mascondr: bool = False; vdia_masconbr: bool = False; vdia_masconeb: bool = False
vdia_doorclose: bool = False; vdia_dooropen: bool = False
vdia_fmc: bool = False; vdia_yard: bool = False; vdia_mcs: bool = False
vdia_auto: bool = False; vdia_fa: bool = False; vdia_tcr: bool = False; vdia_hcr: bool = False
# VDI B
vdib_stat: str = ""; vdib_input: str = ""
vdib_rvs: bool = False; vdib_neu: bool = False; vdib_fwd: bool = False
vdib_mascondr: bool = False; vdib_masconbr: bool = False; vdib_masconeb: bool = False
vdib_doorclose: bool = False; vdib_dooropen: bool = False
vdib_fmc: bool = False; vdib_yard: bool = False; vdib_mcs: bool = False
vdib_auto: bool = False; vdib_fa: bool = False; vdib_tcr: bool = False; vdib_hcr: bool = False
# VDI C
vdic_stat: str = ""; vdic_input: str = ""
vdic_tc2: bool = False; vdic_tc1: bool = False
vdic_edlfb: bool = False; vdic_edrfb: bool = False; vdic_zvrfb: bool = False
vdic_fsbfb: bool = False; vdic_ebmfb: bool = False; vdic_ebpfb: bool = False
vdic_unit1: bool = False; vdic_startbtn: bool = False
vdic_psdclose: bool = False; vdic_psdopen: bool = False
# VDI D
vdid_stat: str = ""; vdid_input: str = ""
vdid_tc2: bool = False; vdid_tc1: bool = False
vdid_edlfb: bool = False; vdid_edrfb: bool = False; vdid_zvrfb: bool = False
vdid_fsbfb: bool = False; vdid_ebmfb: bool = False; vdid_ebpfb: bool = False
vdid_unit1: bool = False; vdid_startbtn: bool = False
vdid_psdclose: bool = False; vdid_psdopen: bool = False
# VDO A/B
vdoa_stat: str = ""; vdob_stat: str = ""
vdoa_edl: bool = False; vdoa_edr: bool = False; vdoa_zvr: bool = False
vdoa_fsb: bool = False; vdoa_ebm: bool = False; vdoa_ebp: bool = False
vdob_edl: bool = False; vdob_edr: bool = False; vdob_zvr: bool = False
vdob_fsb: bool = False; vdob_ebm: bool = False; vdob_ebp: bool = False
# -------------------------------------------------------------------------
# 2. 핵심 파싱 로직 (C# DDOSItoMMI.cs 정밀 이식)
# -------------------------------------------------------------------------
class DDOSItoMMI:
@staticmethod
def set_data(data: bytes) -> DOSItoMMIClass:
mmi = DOSItoMMIClass()
# 데이터 길이 보호
if len(data) < 84:
return mmi
# [Header]
mmi.seq = data[1]
mmi.source = data[3]
# [Time] "20YY.MM.DD HH:mm:ss"
try:
mmi.time = (f"20{data[4]:02x}.{data[5]:02x}.{data[6]:02x} "
f"{data[7]:02x}:{data[8]:02x}:{data[9]:02x}")
except:
mmi.time = "Invalid Time"
# [Speed] Byte 10-11
speed_raw = (data[10] << 8) | data[11]
mmi.trainspeed = speed_raw / 10.0
mmi.limitspeed = data[12]
# [DO ATC / Byte 13]
mmi.do_zvr = (data[13] & 32) > 0
mmi.do_edl = (data[13] & 16) > 0
mmi.do_edr = (data[13] & 8) > 0
mmi.do_fsb = (data[13] & 4) > 0
mmi.do_ebm = (data[13] & 2) > 0
mmi.do_ebp = (data[13] & 1) > 0
# [Status / Byte 14]
mmi.system_active = (data[14] & 128) > 0
mmi.over_spd_warning = (data[14] & 64) > 0
mmi.tcr = (data[14] & 32) > 0
mmi.hcr = (data[14] & 16) > 0
mmi.door_open = (data[14] & 8) > 0
mmi.door_close = (data[14] & 4) > 0
mmi.psd_open = (data[14] & 2) > 0
mmi.psd_close = (data[14] & 1) > 0
# [Mode / Byte 15]
mmi.fa = (data[15] & 128) > 0
mmi.auto = (data[15] & 64) > 0
mmi.mcs = (data[15] & 32) > 0
mmi.yard = (data[15] & 16) > 0
mmi.fmc = (data[15] & 8) > 0
mmi.reverser_rvs = (data[15] & 4) > 0
mmi.reverser_fwd = (data[15] & 2) > 0
mmi.reverser_neu = (data[15] & 1) > 0
# [Mascon & ATO / Byte 16]
mmi.ato_start_btn = (data[16] & 128) > 0
mmi.ato_eb_req = (data[16] & 64) > 0
mmi.tacho_dir_a = (data[16] & 32) > 0
mmi.tacho_dir_b = (data[16] & 16) > 0
mmi.mascon_neu = (data[16] & 0) > 0 # Logic Copy
mmi.mascon_dr = (data[16] & 2) > 0
mmi.mascon_br = (data[16] & 4) > 0
mmi.mascon_eb = (data[16] & 8) > 0
# [ATC Status / Byte 19]
val_19 = data[19] & 192
if val_19 == 0: mmi.atc_status = "INITIAL PDT"
elif val_19 == 192: mmi.atc_status = "MANUAL PDT"
elif val_19 == 64: mmi.atc_status = "ATC ACTIVE"
elif val_19 == 128: mmi.atc_status = "ATC STANDBY"
mmi.wheelcheck = (data[19] & 32) > 0
val_mpdt = data[19] & 28
if val_mpdt == 16: mmi.mpdt = "START"
elif val_mpdt == 8: mmi.mpdt = "NG"
elif val_mpdt == 4: mmi.mpdt = "OK"
val_ipdt = data[19] & 3
if val_ipdt == 2: mmi.ipdt = "NG"
elif val_ipdt == 1: mmi.ipdt = "OK"
# [Fail / Byte 20]
mmi.fail_atcr = (data[20] & 128) > 0
mmi.fail_atoc = (data[20] & 64) > 0
mmi.fail_tcms = (data[20] & 32) > 0
mmi.fail_tacho2 = (data[20] & 2) > 0
mmi.fail_tacho1 = (data[20] & 1) > 0
# [Marker / Byte 21]
mmi.recovery = (data[21] & 128) > 0
mmi.nomal = (data[21] & 64) > 0
mmi.tasc = (data[21] & 32) > 0
val_21 = data[21] & 31
if val_21 == 16: mmi.marker = "ATS"
elif val_21 == 8: mmi.marker = "PGX"
elif val_21 == 4: mmi.marker = "PG3-2"
elif val_21 == 2: mmi.marker = "PG2"
elif val_21 == 1: mmi.marker = "PG1"
# [PWM / Byte 22]
mmi.pwm_value = data[22]
# [RLY ATO / Byte 23]
mmi.trac_dr = (data[23] & 128) > 0
mmi.trac_br = (data[23] & 64) > 0
mmi.trac_cs = (data[23] & 32) > 0
mmi.ador = (data[23] & 16) > 0
mmi.adol = (data[23] & 8) > 0
mmi.adc = (data[23] & 4) > 0
mmi.start_enable = (data[23] & 2) > 0
mmi.trainberth = (data[23] & 1) > 0
# [TCMS / Byte 24]
mmi.tc2 = (data[24] & 128) > 0
mmi.tc1 = (data[24] & 64) > 0
mmi.tascdb = (data[24] & 32) > 0
val_tcms_door = data[24] & 24
if val_tcms_door == 16: mmi.tcmsdoor = "LDO"
elif val_tcms_door == 8: mmi.tcmsdoor = "RDO"
elif val_tcms_door == 24: mmi.tcmsdoor = "L,RDO"
val_door_mode = data[24] & 7
if val_door_mode == 4: mmi.doormod = "A / A"
elif val_door_mode == 2: mmi.doormod = "A / M"
elif val_door_mode == 1: mmi.doormod = "M / M"
# [ETC / Byte 25]
mmi.pre_brake = (data[25] & 128) > 0
mmi.limit_drive = (data[25] & 64) > 0
mmi.ov_stop1 = (data[25] & 8) > 0
mmi.ov_stop2 = (data[25] & 4) > 0
mmi.sh_stop1 = (data[25] & 2) > 0
mmi.sh_stop2 = (data[25] & 1) > 0
# [Train No / Byte 26-27]
mmi.trainno = f"{(data[26] << 8 | data[27]):04X}"
# [Stations / Byte 28-30]
mmi.pstn = data[28]
mmi.nstn = data[29]
mmi.dstn = data[30]
# [DTG / Byte 31-32]
num_dtg = (data[31] << 8) | data[32]
if mmi.tasc:
mmi.dtg = num_dtg / 100.0
else:
if mmi.trainspeed == 0.0 and not mmi.trac_dr:
if (num_dtg & 32768) != 0:
num_dtg -= 65536
mmi.dtg = (num_dtg / 100.0) * -1.0
else:
mmi.dtg = num_dtg / 10.0
# [ATO Limit / Byte 33]
mmi.ato_limitSpeed = data[33]
mmi.tasc_value = data[33]
# [TWC / Byte 34]
# kur(128), osc(64), inching(32), twct_enable(16), dcw(8), wrongdoor(4)
mmi.twct_enable = (data[34] & 16) > 0
mmi.door_close_warning = (data[34] & 8) > 0
mmi.wrongdoor = (data[34] & 4) > 0
val_nextdoor = data[34] & 3
if val_nextdoor == 2: mmi.nextdoor = "RIGHT"
elif val_nextdoor == 1: mmi.nextdoor = "LEFT"
# [ATC Code / Byte 35]
# 상위 4비트: Code Index
atc_idx = (data[35] & 240) >> 4
atc_str_map = {0:"02", 1:"01", 2:"25", 3:"40", 4:"55", 5:"65", 6:"75", 7:"DE", 8:"DW"}
mmi.atc_code = atc_str_map.get(atc_idx, "-")
mmi.atc_code_str = mmi.atc_code
# 그래프용 ATC 코드 속도 매핑 유지
atc_speed_map = {0: 0, 1: 0, 2: 25, 3: 40, 4: 55, 5: 65, 6: 75, 7: -1, 8: -1}
mmi.atc_code_val = atc_speed_map.get(atc_idx, 0)
mmi.osc_f0_ok = (data[35] & 8) > 0
# 하위 3비트: Carrier Freq
carr_idx = data[35] & 7
if carr_idx == 1: mmi.atc_carrier_f = "F1"
elif carr_idx == 2: mmi.atc_carrier_f = "F2"
elif carr_idx == 3: mmi.atc_carrier_f = "F3"
elif carr_idx == 4: mmi.atc_carrier_f = "F4"
# [Frequency Calculations (XOR Obfuscation) / Byte 36-39, 55, 58]
mmi.atc_code_carrier_f = ((data[36] ^ 65) << 8 | (data[37] ^ 82)) * 10.0
mmi.atc_code_f = ((data[38] ^ 99) << 8 | (data[39] ^ 116)) / 10.0
mmi.osc_f = ((data[55] ^ 99) << 8 | (data[58] ^ 100)) / 10.0
# [VDI A - Byte 63, 64, 65]
mmi.vdia_rvs = (data[63] & 128) > 0
mmi.vdia_neu = (data[63] & 64) > 0
mmi.vdia_fwd = (data[63] & 32) > 0
mmi.vdia_mascondr = (data[63] & 16) > 0
mmi.vdia_masconbr = (data[63] & 8) > 0
mmi.vdia_masconeb = (data[63] & 4) > 0
mmi.vdia_doorclose = (data[63] & 2) > 0
mmi.vdia_dooropen = (data[63] & 1) > 0
mmi.vdia_fmc = (data[64] & 64) > 0
mmi.vdia_yard = (data[64] & 32) > 0
mmi.vdia_mcs = (data[64] & 16) > 0
mmi.vdia_auto = (data[64] & 8) > 0
mmi.vdia_fa = (data[64] & 4) > 0
mmi.vdia_tcr = (data[64] & 2) > 0
mmi.vdia_hcr = (data[64] & 1) > 0
mmi.vdia_stat = f"{data[65]:02X}"
# [VDI B - Byte 66, 67, 68]
mmi.vdib_rvs = (data[66] & 128) > 0
mmi.vdib_neu = (data[66] & 64) > 0
mmi.vdib_fwd = (data[66] & 32) > 0
mmi.vdib_mascondr = (data[66] & 16) > 0
mmi.vdib_masconbr = (data[66] & 8) > 0
mmi.vdib_masconeb = (data[66] & 4) > 0
mmi.vdib_doorclose = (data[66] & 2) > 0
mmi.vdib_dooropen = (data[66] & 1) > 0
mmi.vdib_fmc = (data[67] & 64) > 0
mmi.vdib_yard = (data[67] & 32) > 0
mmi.vdib_mcs = (data[67] & 16) > 0
mmi.vdib_auto = (data[67] & 8) > 0
mmi.vdib_fa = (data[67] & 4) > 0
mmi.vdib_tcr = (data[67] & 2) > 0
mmi.vdib_hcr = (data[67] & 1) > 0
mmi.vdib_stat = f"{data[68]:02X}"
# [VDI C - Byte 69, 70, 71]
mmi.vdic_tc2 = (data[69] & 128) > 0
mmi.vdic_tc1 = (data[69] & 64) > 0
mmi.vdic_edlfb = (data[69] & 32) > 0
mmi.vdic_edrfb = (data[69] & 16) > 0
mmi.vdic_zvrfb = (data[69] & 8) > 0
mmi.vdic_fsbfb = (data[69] & 4) > 0
mmi.vdic_ebmfb = (data[69] & 2) > 0
mmi.vdic_ebpfb = (data[69] & 1) > 0
mmi.vdic_unit1 = (data[70] & 128) > 0
mmi.vdic_startbtn = (data[70] & 4) > 0
mmi.vdic_psdclose = (data[70] & 2) > 0
mmi.vdic_psdopen = (data[70] & 1) > 0
mmi.vdic_stat = f"{data[71]:02X}"
# [VDI D - Byte 72, 73, 74]
mmi.vdid_tc2 = (data[72] & 128) > 0
mmi.vdid_tc1 = (data[72] & 64) > 0
mmi.vdid_edlfb = (data[72] & 32) > 0
mmi.vdid_edrfb = (data[72] & 16) > 0
mmi.vdid_zvrfb = (data[72] & 8) > 0
mmi.vdid_fsbfb = (data[72] & 4) > 0
mmi.vdid_ebmfb = (data[72] & 2) > 0
mmi.vdid_ebpfb = (data[72] & 1) > 0
mmi.vdid_unit1 = (data[73] & 128) > 0
mmi.vdid_startbtn = (data[73] & 4) > 0
mmi.vdid_psdclose = (data[73] & 2) > 0
mmi.vdid_psdopen = (data[73] & 1) > 0
mmi.vdid_stat = f"{data[74]:02X}"
# [VDO A - Byte 75, 76]
mmi.vdoa_edl = (data[75] & 32) > 0
mmi.vdoa_edr = (data[75] & 16) > 0
mmi.vdoa_zvr = (data[75] & 8) > 0
mmi.vdoa_fsb = (data[75] & 4) > 0
mmi.vdoa_ebm = (data[75] & 2) > 0
mmi.vdoa_ebp = (data[75] & 1) > 0
mmi.vdoa_stat = f"{data[76]:02X}"
# [VDO B - Byte 77, 78]
mmi.vdob_edl = (data[77] & 32) > 0
mmi.vdob_edr = (data[77] & 16) > 0
mmi.vdob_zvr = (data[77] & 8) > 0
mmi.vdob_fsb = (data[77] & 4) > 0
mmi.vdob_ebm = (data[77] & 2) > 0
mmi.vdob_ebp = (data[77] & 1) > 0
mmi.vdob_stat = f"{data[78]:02X}"
return mmi
# -------------------------------------------------------------------------
# 3. 파일 로더
# -------------------------------------------------------------------------
class LogLoader:
def load(self, filepath: str) -> List[DOSItoMMIClass]:
result_list = []
if not os.path.exists(filepath):
return result_list
file_size = os.path.getsize(filepath)
with open(filepath, 'rb') as f:
CHUNK_SIZE = 84
while f.tell() < file_size:
chunk = f.read(CHUNK_SIZE)
if len(chunk) < CHUNK_SIZE: break
# 오류 방지용 try-except 추가 권장
try:
mmi_data = DDOSItoMMI.set_data(chunk)
result_list.append(mmi_data)
except Exception as e:
print(f"Error parsing chunk at {f.tell()}: {e}")
return result_list

106
app/data/w_mmiParser.py Normal file
View File

@ -0,0 +1,106 @@
import struct
import os
import polars as pl
from typing import Dict, List, Any
class MMIParser:
"""
MMI 바이너리 로그를 파싱하여 Polars DataFrame으로 반환하는 클래스
"""
CHUNK_SIZE = 84
@staticmethod
def parse_chunk(data: bytes) -> Dict[str, Any]:
"""84바이트 청크 하나를 딕셔너리로 변환"""
if len(data) < 84:
return None
# 기본 파싱 (비트 연산 로직 유지)
# 속도를 위해 dataclass 대신 dict 사용
row = {}
# [Header]
row["seq"] = data[1]
row["source"] = data[3]
# 이중계 판단 로직: 소스가 32(0x20)가 아니면 Break(고장/대기)로 간주
# (사용자 정의 로직에 따라 16, 32 외의 값 처리도 필요할 수 있음)
row["is_break"] = (data[3] != 32)
# [Time]
try:
row["time"] = (f"20{data[4]:02x}.{data[5]:02x}.{data[6]:02x} "
f"{data[7]:02x}:{data[8]:02x}:{data[9]:02x}")
except:
row["time"] = None
# [Speed & Analog]
row["trainspeed"] = ((data[10] << 8) | data[11]) / 10.0
row["limitspeed"] = data[12]
row["pwm_value"] = data[22]
# [DTG Calculation]
tasc = (data[21] & 32) > 0
trac_dr = (data[23] & 128) > 0
num_dtg = (data[31] << 8) | data[32]
if tasc:
row["dtg"] = num_dtg / 100.0
else:
if row["trainspeed"] == 0.0 and not trac_dr:
if (num_dtg & 32768) != 0:
num_dtg -= 65536
row["dtg"] = (num_dtg / 100.0) * -1.0
else:
row["dtg"] = num_dtg / 10.0
# [Frequency & XOR Decoding]
row["atc_code_carrier_f"] = ((data[36] ^ 65) << 8 | (data[37] ^ 82)) * 10.0
row["atc_code_f"] = ((data[38] ^ 99) << 8 | (data[39] ^ 116)) / 10.0
# [Bitmasks - 주요 신호만 예시로 포함, 필요 시 전체 추가]
# bitmasking은 polars의 map_elements보다 여기서 처리해서 넘기는게 빠름
row["do_zvr"] = (data[13] & 32) > 0
row["do_ebm"] = (data[13] & 2) > 0
row["system_active"] = (data[14] & 128) > 0
row["door_open"] = (data[14] & 8) > 0
row["door_close"] = (data[14] & 4) > 0
# [Station Codes for Semantic Mapping]
row["pstn"] = data[28] # 이전역
row["nstn"] = data[29] # 현재/다음역
row["dstn"] = data[30] # 종착역
# [ATC Code Parsing]
atc_idx = (data[35] & 240) >> 4
# 매핑은 여기서 문자열로 변환하지 않고 숫자(Index)만 넘깁니다.
# (문자열 변환은 Processor에서 일괄 처리하는 게 속도상 유리)
row["atc_code_idx"] = atc_idx
return row
def load_to_dataframe(self, filepath: str) -> pl.DataFrame:
"""파일 전체를 읽어 Polars DataFrame으로 반환"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"File not found: {filepath}")
raw_data = []
file_size = os.path.getsize(filepath)
with open(filepath, 'rb') as f:
# 파일 전체를 메모리에 로드 (50만개 * 84byte = 약 42MB, 메모리에 충분함)
# IO 속도 향상을 위해 한 번에 읽음
buffer = f.read()
# 청크 단위 파싱
total_chunks = len(buffer) // self.CHUNK_SIZE
for i in range(total_chunks):
start = i * self.CHUNK_SIZE
end = start + self.CHUNK_SIZE
chunk = buffer[start:end]
parsed_row = self.parse_chunk(chunk)
if parsed_row:
raw_data.append(parsed_row)
# Polars DataFrame 생성 (고속)
return pl.DataFrame(raw_data)

View File

@ -0,0 +1,56 @@
import polars as pl
from mmi_parser import MMIParser
class MMIProcessor:
def __init__(self):
# 역 코드 매핑 테이블 (예시)
self.station_map = {
134: "범어사",
135: "남산",
136: "두실",
# ... 실제 매핑 데이터 추가
}
# ATC 코드 매핑
self.atc_code_map = {
0: "02", 1: "01", 2: "25", 3: "40", 4: "55"
}
def process_and_save(self, input_path: str, output_parquet_path: str):
# 1. 파싱 (Raw Data 로드)
parser = MMIParser()
df = parser.load_to_dataframe(input_path)
print(f"Loaded {len(df)} rows.")
# 2. 이중계 필터링 (Redundancy Logic)
# 로직: 같은 seq 중에서 is_break가 False(정상)인 것을 우선 선택
# sort: seq 오름차순, is_break 오름차순 (False=0, True=1 이므로 정상 데이터가 위로 옴)
df_clean = (
df.sort(["seq", "is_break"])
.unique(subset=["seq"], keep="first") # 그룹별 첫 번째(정상)만 유지
)
print(f"Filtered redundancy. {len(df)} -> {len(df_clean)} rows.")
# 3. 시멘틱 데이터 보강 (Semantic Enrichment)
# map_dict를 사용하여 고속 매핑
df_enriched = df_clean.with_columns([
pl.col("nstn").replace(self.station_map).alias("station_name"),
pl.col("atc_code_idx").replace(self.atc_code_map).alias("atc_code_str")
])
# 4. Parquet 저장 (압축 사용)
df_enriched.write_parquet(output_parquet_path, compression="zstd")
print(f"Saved to {output_parquet_path}")
return df_enriched
# --- 실행 예시 ---
if __name__ == "__main__":
processor = MMIProcessor()
# 로우 데이터 경로와 저장할 경로 지정
df = processor.process_and_save("raw_log.bin", "processed_data.parquet")
# 결과 확인
print(df.head())

0
app/ui/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

322
app/ui/analysis_panel.py Normal file
View File

@ -0,0 +1,322 @@
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QLabel, QPushButton, QHBoxLayout, QFileDialog
from PySide6.QtCore import Signal
from app.ui.views.graph_view import GraphView
# 추후 구현할 뷰들: from app.ui.views.dashboard_view import DashboardView
from app.data.log_parser import LogLoader
from app.core.sync_controller import sync_manager
from app.ui.views.dashboard_view import DashboardView
from app.ui.views.ai_view import AIDiagnosisView
import polars as pl
from dataclasses import fields
from app.data.fast_parser import FastLogParser
from app.data.log_parser import DOSItoMMIClass
class AnalysisPanel(QWidget):
# 데이터 로드 완료 시그널
data_loaded = Signal()
def __init__(self, panel_id, parent=None):
super().__init__(parent)
self.panel_id = panel_id # "left" or "right"
self.loader = None # 지연 초기화 - 실제 사용 시점에 생성
self.raw_data = []
self.data_date = None # 데이터 날짜 (YYYY.MM.DD 형식)
self.file_path = None
self.current_index = None
self.current_row = None
self.setup_ui()
# 동기화 신호 받기
sync_manager.time_changed.connect(self.on_sync_received)
def setup_ui(self):
layout = QVBoxLayout(self)
# [상단] 개별 파일 로드 버튼 (A/B 비교를 위해)
top_bar = QHBoxLayout()
self.lbl_file = QLabel("No File Loaded")
self.btn_load = QPushButton(f"Load File ({self.panel_id.upper()})")
self.btn_load.clicked.connect(self.open_file)
top_bar.addWidget(self.lbl_file)
top_bar.addWidget(self.btn_load)
layout.addLayout(top_bar)
# [중앙] 탭 위젯
self.tabs = QTabWidget()
# 탭 1: 그래프 뷰
self.graph_view = GraphView(self.panel_id)
self.tabs.addTab(self.graph_view, "📊 Graph")
# 탭 2: 기존 뷰어 (껍데기)
self.dashboard_view = DashboardView(self.panel_id)
self.tabs.addTab(self.dashboard_view, "📟 Legacy Viewer")
# 탭 3: 신호 목록 (껍데기)
self.tabs.addTab(QWidget(), "📜 Signal List")
# 탭 4: AI 분석 (GraphView 컨텍스트 포함)
self.ai_view = AIDiagnosisView(self.panel_id, graph_view=self.graph_view)
self.tabs.addTab(self.ai_view, "🤖 AI Diagnosis")
# 그래프 선택 구간 → AI 요청 연결
self.graph_view.range_ai_requested.connect(self._on_range_ai_requested)
layout.addWidget(self.tabs)
def open_file(self):
file_name, _ = QFileDialog.getOpenFileName(self, "Open Log", "", "Log Files (*.dat)")
if file_name:
self.load_data(file_name)
def load_data(self, file_path):
self.file_path = file_path
self.lbl_file.setText(file_path.split("/")[-1])
# 1. FastLogParser 시도 (Polars)
parquet_path = file_path.replace(".dat", ".parquet")
try:
# 파싱 및 Parquet 저장 (이미 있으면 스킵 가능하지만, 로직상 호출)
# FastLogParser 내부에서 파일 존재 체크 등을 수행함
output_path = FastLogParser.parse_to_parquet(file_path)
if output_path:
# Polars로 읽기
df = pl.read_parquet(output_path)
# DataFrame -> List[DOSItoMMIClass] 변환
# 1) 필드 목록 추출
valid_fields = {f.name for f in fields(DOSItoMMIClass)}
# 2) 딕셔너리 변환 (Polars -> Python List of Dicts)
# rows = df.to_dicts() # 이 방식은 빠름
# 3) 객체 생성 (불필요한 컬럼 제거)
# 벡터화된 방식은 아니지만, Python 루프보다는 빠름 (파싱 비용 제거됨)
# 50만건 기준 약 1~2초 소요 예상
self.raw_data = [
DOSItoMMIClass(**{k: v for k, v in row.items() if k in valid_fields})
for row in df.to_dicts()
]
print(f"[AnalysisPanel] Loaded {len(self.raw_data)} records via FastLogParser")
else:
raise Exception("FastLogParser returned None")
except Exception as e:
print(f"[AnalysisPanel] FastLogParser failed, falling back to legacy loader: {e}")
# LogLoader 지연 초기화
if self.loader is None:
self.loader = LogLoader()
self.raw_data = self.loader.load(file_path)
# 레거시 로더 사용 시 역 정보 매핑 (기존 로직 유지)
from app.core.reference_data import ref_data
if ref_data._is_loaded and self.raw_data:
for item in self.raw_data:
if item.pstn > 0:
st_name = ref_data.get_station_name(item.pstn)
if st_name:
item.station_name = st_name
# 데이터 날짜 추출 (첫 번째 레코드의 time 필드에서)
if self.raw_data and len(self.raw_data) > 0:
time_str = getattr(self.raw_data[0], 'time', '')
# time 형식: "20YY.MM.DD HH:mm:ss" -> 날짜 부분만 추출
if time_str and len(time_str) >= 10:
self.data_date = time_str[:10] # "20YY.MM.DD"
else:
self.data_date = None
else:
self.data_date = None
# 하위 뷰들에게 데이터 전달
self.graph_view.set_data(self.raw_data)
self.dashboard_view.set_data(self.raw_data) # Legacy Viewer에도 전달
self.ai_view.set_file_context(file_path)
self.ai_view.set_data(self.raw_data)
# 기본 커서 포인트(첫 레코드) 설정
if self.raw_data:
self.current_index = 0
self.current_row = self.raw_data[0]
# 첫 번째 패널이 로드되면 전체 레코드 수 설정 (임시)
if self.panel_id == "left":
sync_manager._total_records = len(self.raw_data)
# 데이터 로드 완료 시그널 발생
self.data_loaded.emit()
def on_sync_received(self, index, data, source_id):
"""
SyncController로부터 "시간 바꿔!" 명령이 오면 실행
, 내가 보낸 신호가 아닐 때만 반응해야 (Loop 방지)
"""
# 내 패널에서 움직인 커서도 기록해둬야 AI/플로팅 채팅에서 현재 시점을 알 수 있음
if source_id == self.panel_id:
self.current_index = index
self.current_row = data
return
if source_id != self.panel_id:
# 타임라인/커서 이동
self.graph_view.set_index(index)
# 추후 다른 뷰들도 이동 (예: self.dashboard.set_values(data))
def _on_range_ai_requested(self, start_ms: int, end_ms: int, start_idx: int, end_idx: int):
"""그래프에서 선택한 구간을 AI 분석으로 전달"""
if hasattr(self, "ai_view") and self.ai_view:
self.ai_view.run_range_analysis(start_idx, end_idx, start_ms, end_ms)
def set_slave_mode(self, is_slave):
# Sync 모드일 때 로드 버튼 숨기기
self.btn_load.setVisible(not is_slave)
self.lbl_file.setText("Linked to Left Panel" if is_slave else "No File")
def receive_shared_data(self, data_list):
# 다른 패널에서 데이터 받아오기
self.raw_data = data_list
self.graph_view.set_data(data_list)
self.dashboard_view.set_data(data_list) # Legacy Viewer에도 전달
self.ai_view.set_data(data_list)
if self.raw_data:
self.current_index = 0
self.current_row = self.raw_data[0]
def get_ai_context(self):
"""플로팅 AI/외부 호출용: 현재 패널의 AI 컨텍스트 묶음"""
ctx = {
"panel_id": self.panel_id,
"file_path": self.file_path,
"data_date": self.data_date,
"current_index": self.current_index,
}
try:
if hasattr(self, "graph_view") and self.graph_view and hasattr(self.graph_view, "get_ai_context"):
ctx["graph"] = self.graph_view.get_ai_context(self.current_index)
except Exception:
ctx["graph"] = {}
try:
if self.current_row is not None:
d = self.current_row
ctx["record"] = {
"time": getattr(d, "time", ""),
"trainno": getattr(d, "trainno", ""),
"trainspeed": getattr(d, "trainspeed", None),
"dtg": getattr(d, "dtg", None),
"atc_status": getattr(d, "atc_status", ""),
"marker": getattr(d, "marker", ""),
"pstn": getattr(d, "pstn", None),
}
except Exception:
pass
return ctx
def compute_pg_at_station(self, marker_name: str = "PG3-2"):
"""
'각 역마다 PG3-2 위치에서의 속도/TASC' 같은 질의를 위해
구간별( 이벤트 ~ 다음 이벤트) PG 마커를 매칭해 지표를 추출한다.
"""
if not self.raw_data or not hasattr(self, "graph_view") or not self.graph_view:
return []
gv = self.graph_view
if not hasattr(gv, "chart_view") or not getattr(gv, "start_timestamp", 0):
return []
# 역 이벤트(시간, 역명)
try:
stations = list(gv.chart_view.event_markers.get("STATION", []))
except Exception:
stations = []
stations = [(int(ts), str(name)) for ts, name in stations if ts is not None]
stations.sort(key=lambda x: x[0])
if not stations:
return []
# PG Rx 마커(시간, 타입)
try:
pgs = list(gv.chart_view.event_markers.get("PG", []))
except Exception:
pgs = []
pgs = [(int(ts), str(name)) for ts, name in pgs if ts is not None]
pgs = [p for p in pgs if p[1] == marker_name]
pgs.sort(key=lambda x: x[0])
results = []
for i, (st_ts, st_name) in enumerate(stations):
seg_start = st_ts
seg_end = stations[i + 1][0] if i + 1 < len(stations) else 10**18
# 구간 내 첫 PG3-2 (없으면 None)
pg_ts = None
for t, n in pgs:
if seg_start <= t < seg_end:
pg_ts = t
break
row = {
"station": st_name,
"station_ts": st_ts,
"pg_marker": marker_name,
"pg_ts": pg_ts,
}
if pg_ts is not None:
idx = int(round((pg_ts - gv.start_timestamp) / 1000.0))
idx = max(0, min(len(self.raw_data) - 1, idx))
d = self.raw_data[idx]
# raw 값
row.update(
{
"index": idx,
"time": getattr(d, "time", ""),
"raw_trainspeed": getattr(d, "trainspeed", None),
"raw_tasc_value": getattr(d, "tasc_value", None),
"raw_dtg": getattr(d, "dtg", None),
"raw_atc_status": getattr(d, "atc_status", ""),
}
)
# filtered(그래프 표시) 값도 함께
try:
sp = gv.signals_map.get("speed", {}).get("data", [])
ta = gv.signals_map.get("tasc", {}).get("data", [])
dg = gv.signals_map.get("dtg", {}).get("data", [])
if 0 <= idx < len(sp):
row["filtered_speed"] = sp[idx]
if 0 <= idx < len(ta):
row["filtered_tasc"] = ta[idx]
if 0 <= idx < len(dg):
row["filtered_dtg"] = dg[idx]
except Exception:
pass
# all-zero 프레임 여부
try:
if hasattr(gv, "_all_zero_mask") and gv._all_zero_mask and 0 <= idx < len(gv._all_zero_mask):
row["all_zero_frame"] = bool(gv._all_zero_mask[idx])
except Exception:
pass
try:
# 우선 filtered로 차이 계산, 없으면 raw로 폴백
s = row.get("filtered_speed", row.get("raw_trainspeed"))
t = row.get("filtered_tasc", row.get("raw_tasc_value"))
if s is not None and t is not None:
row["speed_minus_tasc"] = float(s) - float(t)
except Exception:
pass
results.append(row)
return results
def get_data_date(self):
"""현재 로드된 데이터의 날짜 반환"""
return self.data_date

View File

Binary file not shown.

View File

@ -0,0 +1,74 @@
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import Qt, QPoint, QRectF
from PySide6.QtGui import QPainter, QColor, QPen, QFont, QPolygon
class CircularGauge(QWidget):
def __init__(self, title="SPEED", min_val=0, max_val=120, unit="km/h", color="#00E5FF"):
super().__init__()
self.value = 0
self.target_value = 0 # 목표 속도 (빨간 바늘 등)
self.min_val = min_val
self.max_val = max_val
self.title = title
self.unit = unit
self.accent_color = QColor(color)
self.setMinimumSize(200, 200)
def set_value(self, val, target=None):
self.value = max(self.min_val, min(val, self.max_val))
if target is not None:
self.target_value = max(self.min_val, min(target, self.max_val))
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
side = min(w, h)
painter.translate(w/2, h/2)
painter.scale(side/200.0, side/200.0)
# 1. 배경
painter.setPen(QPen(QColor("#222"), 4))
painter.drawEllipse(-90, -90, 180, 180)
# 2. 눈금 (Scale)
start_angle = 135
span_angle = 270
painter.setPen(QPen(Qt.white, 2))
painter.setFont(QFont("Arial", 8))
step = (self.max_val - self.min_val) / 10
for i in range(11):
val = self.min_val + i * step
angle = start_angle + (i * (span_angle / 10))
painter.save()
painter.rotate(angle)
painter.drawLine(80, 0, 90, 0) # 눈금
painter.restore()
# 숫자 (삼각함수로 위치 계산 생략하고 간략히 처리하거나 추가 구현)
# 3. 메인 바늘 (Value)
val_pct = (self.value - self.min_val) / (self.max_val - self.min_val)
angle = start_angle + (val_pct * span_angle)
painter.save()
painter.rotate(angle)
painter.setBrush(self.accent_color)
painter.setPen(Qt.NoPen)
painter.drawConvexPolygon([QPoint(0, -3), QPoint(0, 3), QPoint(85, 0)])
painter.restore()
# 4. 텍스트 표시
painter.setPen(self.accent_color)
painter.setFont(QFont("Arial", 16, QFont.Bold))
painter.drawText(QRectF(-50, 20, 100, 30), Qt.AlignCenter, f"{self.value:.1f}")
painter.setPen(Qt.gray)
painter.setFont(QFont("Arial", 9))
painter.drawText(QRectF(-50, 50, 100, 20), Qt.AlignCenter, self.unit)
painter.drawText(QRectF(-50, -60, 100, 20), Qt.AlignCenter, self.title)

View File

@ -0,0 +1,445 @@
from PySide6.QtWidgets import QLabel, QMenu, QVBoxLayout, QWidget
from PySide6.QtCore import Qt,Signal, QPoint
from PySide6.QtGui import QMouseEvent, QEnterEvent, QDragLeaveEvent, QCursor
import logging
logger = logging.getLogger(__name__)
class ClickableLabel(QLabel):
"""
클릭 / 더블클릭 / 우클릭 컨텍스트 메뉴 / 마우스 호버링 / 드래그 / 그리드 스냅 지원 QLabel
[Signals]
- clicked(): 좌클릭
- double_clicked(): 좌측 더블클릭
- right_clicked(pos: QPoint): 우클릭 (로컬 좌표)
- hover_entered(): 마우스 진입
- hover_left(): 마우스 이탈
- drag_started(pos: QPoint): 드래그 시작
- dragging(delta: QPoint): 드래그 (이동량)
- drag_finished(): 드래그 종료
/사용예시
label = ClickableLabel("클릭해보세요")
label.clicked.connect(lambda: logger.info("원클릭"))
label.double_clicked.connect(lambda: logger.info("더블클릭"))
label.hover_entered.connect(lambda: logger.info("마우스 진입"))
label.hover_left.connect(lambda: logger.info("마우스 이탈"))
/우클릭 컨텍스트
label.right_clicked.connect(
lambda pos: logger.info(f"우클릭 위치: {pos}")
)
/드래그
label.drag_started.connect(
lambda pos: logger.info(f"드래그 시작 위치: {pos}")
)
label.dragging.connect(
lambda delta: logger.info(f"드래그 이동량: {delta}")
)
label.drag_finished.connect(lambda: logger.info("드래그 종료"))
def on_drag(delta: QPoint):
label.move(label.pos() + delta)
label.dragging.connect(on_drag)
/드래그 시작 UI 반응 정의 : 배경색 변경
label.drag_started.connect(lambda _: label.setStyleSheet("background:#d0ebff"))
label.drag_finished.connect(lambda: label.setStyleSheet(""))
호버링 (호버링 사용시 setMouseTracking(True) 필요)
def on_hover_enter():
label.setStyleSheet("background-color: #f0f0f0;")
def on_hover_leave():
label.setStyleSheet("background-color: none;")
label.hover_entered.connect(on_hover_enter)
label.hover_left.connect(on_hover_leave)
"""
clicked = Signal() # 좌클릭
double_clicked = Signal() # 더블클릭
right_clicked = Signal(QPoint) # 우클릭
hover_entered = Signal() # 마우스 진입
hover_left = Signal() # 마우스 이탈
drag_started = Signal(QPoint) # 드래그 시작 위치
dragging = Signal(QPoint) # 드래그 이동량
drag_finished = Signal() # 드래그 종료
def __init__(
self,
text: str = "",
parent=None,
hover_text=None,
*,
enable_click: bool = True,
enable_double_click: bool = True,
enable_right_click: bool = False,
enable_hover: bool = True,
enable_drag: bool = False,
enable_context_menu: bool = False,
context_menu_builder=None,
enable_grid_snap: bool = False,
grid_size: int = 20,
**kwargs
):
super().__init__(text, parent)
# -----------------------------
# 기능 활성화 플래그
# -----------------------------
self.enable_click = enable_click
self.enable_double_click = enable_double_click
self.enable_right_click = enable_right_click
self.enable_hover = enable_hover
self.enable_drag = enable_drag
self.enable_context_menu = enable_context_menu
self._context_menu_builder = context_menu_builder
self.enable_grid_snap = enable_grid_snap
# -----------------------------
# 드래그 / 그리드 설정
# -----------------------------
self.grid_size = grid_size
self._drag_threshold = 5 # px (클릭 vs 드래그 구분)
self._press_pos: QPoint | None = None
self._dragging = False
# -----------------------------
# UI 기본 설정
# -----------------------------
self.setCursor(Qt.PointingHandCursor)
# 텍스트 중앙 정렬
self.setAlignment(Qt.AlignCenter)
self._hover_text = hover_text
self._hover_text_provider: callable | None = None
self._hover_popup = None
# hover는 mouse tracking 필수
if self.enable_hover:
self.setMouseTracking(True)
else:
self.setMouseTracking(False)
# -------------------------------------------------
# 마우스 누름
# -------------------------------------------------
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
if self.enable_click:
self.clicked.emit()
if self.enable_drag:
self._press_pos = event.pos()
self._dragging = False
elif event.button() == Qt.RightButton:
if self.enable_right_click:
self.right_clicked.emit(event.pos())
if self.enable_context_menu:
self._show_context_menu(event.globalPos())
super().mousePressEvent(event)
# -------------------------------------------------
# 마우스 이동 (드래그)
# -------------------------------------------------
def mouseMoveEvent(self, event: QMouseEvent):
if not self.enable_drag or self._press_pos is None:
super().mouseMoveEvent(event)
return
delta = event.pos() - self._press_pos
if not self._dragging and delta.manhattanLength() >= self._drag_threshold:
self._dragging = True
self.drag_started.emit(self._press_pos)
if self._dragging:
self.dragging.emit(delta)
self._move_with_grid_snap(delta)
super().mouseMoveEvent(event)
# -------------------------------------------------
# 마우스 해제
# -------------------------------------------------
def mouseReleaseEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton and self.enable_drag:
if self._dragging:
self.drag_finished.emit()
self._dragging = False
self._press_pos = None
super().mouseReleaseEvent(event)
# -------------------------------------------------
# 더블클릭
# -------------------------------------------------
def mouseDoubleClickEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton and self.enable_double_click:
self.double_clicked.emit()
super().mouseDoubleClickEvent(event)
# -------------------------------------------------
# 호버
# 사용법 (주의:provider 함수에서 DB 조회, 네트워크 요청, 긴 계산 등을 포함하면 안됨,필요한 경우 캐시+갱신 이벤트구조로 구현)
# label.set_hover_text_provider(
# lambda: f"""
# 상태: {node.status}
# 전압: {node.voltage}V
# 최근 점검: {node.last_check}
# """.strip()
# )
# -------------------------------------------------
def enterEvent(self, event: QEnterEvent):
if self.enable_hover:
self.hover_entered.emit()
text = self._get_hover_text()
if text:
self._hover_popup = HoverInfoPopup(text)
pos = QCursor.pos() + QPoint(10, 10) # 마우스 커서 위치에서 약간 오프셋
self._hover_popup.move(pos)
self._hover_popup.show()
super().enterEvent(event)
def leaveEvent(self, event: QDragLeaveEvent):
if not self.enable_hover:
super().leaveEvent(event)
return
# popup이 있고, 마우스가 popup 안에 있으면 닫지 않음
if self._hover_popup and self._hover_popup.is_mouse_inside():
return
self.hover_left.emit()
if self.enable_hover:
if self._hover_popup:
self._hover_popup.close()
self._hover_popup = None
super().leaveEvent(event)
def set_hover_text(self, text: str):
"""
hover 정보 텍스트 설정 (즉시 반영)
"""
self._hover_text = text
self._hover_text_provider = None
if self._hover_popup:
self._hover_popup.set_text(text)
def _get_hover_text(self) -> str | None:
if self._hover_text_provider:
return self._hover_text_provider()
return self._hover_text
def clear_hover_text(self):
"""
hover 정보 제거
"""
self._hover_text = None
self._hover_text_provider = None
if self._hover_popup:
self._hover_popup.close()
self._hover_popup = None
def set_hover_text_provider(self, provider: callable):
"""
hover 시점에 호출되는 함수 등록
"""
self._hover_text_provider = provider
self._hover_text = None
if self._hover_popup:
self._hover_popup.set_text(provider())
# =================================================
# 내부 기능
# =================================================
def _show_context_menu(self, global_pos):
if not self._context_menu_builder:
return
menu = QMenu(self)
self._context_menu_builder(menu, self) # 컨텍스트 메뉴 빌더 호출
menu.exec(global_pos)
''' 외부에서 컨텍스트 메뉴 빌더 정의
def build_label_menu(menu: QMenu, label: ClickableLabel):
menu.addAction("편집", lambda: edit_label(label))
menu.addAction("삭제", lambda: delete_label(label))
menu.addSeparator()
menu.addAction("정보", lambda: show_info(label))
def edit_label(label):
logger.info(f"편집: {label.text()}")
def delete_label(label):
label.deleteLater()
def show_info(label):
logger.info(f"위치: {label.pos()}")
객체 생성시 컨텍스트 메뉴 빌더 전달
label = ClickableLabel(
"NODE-1",
context_menu_builder=build_label_menu
)
'''
def _move_with_grid_snap(self, delta: QPoint):
new_pos = self.pos() + delta
if self.enable_grid_snap:
x = round(new_pos.x() / self.grid_size) * self.grid_size
y = round(new_pos.y() / self.grid_size) * self.grid_size
self.move(QPoint(x, y))
else:
self.move(new_pos)
class PopupTheme:
LIGHT = "light"
DARK = "dark"
class HoverInfoPopup(QWidget):
"""
마우스 호버 정보 팝업 표시 QWidget
사용예시
popup = HoverInfoPopup("호버 정보")
popup.show()
"""
def __init__(self, text: str, theme=PopupTheme.DARK, parent=None):
super().__init__(parent)
# -----------------------------
# 테마 설정
# -----------------------------
self._theme = theme
self.setWindowFlags(
Qt.ToolTip |
Qt.FramelessWindowHint |
Qt.BypassWindowManagerHint
)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.apply_theme(theme)
# -----------------------------
# 윈도우 설정
# -----------------------------
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
# -----------------------------
# 드래그 설정
# -----------------------------
self._drag_start_pos = None
# -----------------------------
# UI 설정
# -----------------------------
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 6, 8, 6)
# -----------------------------
# 라벨 설정
# -----------------------------
self.label = QLabel(text)
self.label.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
)
layout.addWidget(self.label)
self.setStyleSheet("""
QWidget {
background-color: #ffffe1;
border: 1px solid #c8c8a9;
border-radius: 4px;
font-size: 12px;
}
""")
# -----------------------------
# 드래그 이동
# -----------------------------
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self._drag_start_pos = event.globalPos() - self.frameGeometry().topLeft()
def mouseMoveEvent(self, event: QMouseEvent):
if self._drag_start_pos:
self.move(event.globalPos() - self._drag_start_pos)
def mouseReleaseEvent(self, event: QMouseEvent):
self._drag_start_pos = None
# -------------------------------------------------
# 텍스트 설정
# -------------------------------------------------
def set_text(self, text: str):
self.label.setText(text)
self.adjustSize() # 내용 바뀌면 크기 자동 조정
# -------------------------------------------------
# 마우스 위치 확인
# -------------------------------------------------
def is_mouse_inside(self) -> bool:
pos = QCursor.pos()
return self.geometry().contains(pos)
# -------------------------------------------------
# 마우스 이탈 시 팝업 닫기
# -------------------------------------------------
def leaveEvent(self, event):
self.close()
super().leaveEvent(event)
# -------------------------------------------------
# 테마 적용
# -------------------------------------------------
def apply_theme(self, theme: str):
if theme == PopupTheme.DARK:
self.setStyleSheet("""
QWidget {
background-color: #2b2b2b;
color: #eaeaea;
border: 1px solid #555;
border-radius: 6px;
}
""")
else:
self.setStyleSheet("""
QWidget {
background-color: #ffffe1;
color: #202020;
border: 1px solid #c8c8a9;
border-radius: 6px;
}
""")

View File

@ -0,0 +1,466 @@
"""
신호 표시용 컴포넌트들
- OnOffSignalLabel: ON/OFF 신호 표시
- ModeSignalLabel: MODE 신호 표시 (여러 상태 하나)
- DataSignalLabel: 데이터 신호 표시 (신호명 + )
- SignalCard: 신호들을 그룹화하는 카드
"""
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QMenu, QGridLayout, QScrollArea, QSizePolicy)
from PySide6.QtCore import Qt, Signal, QPoint
from PySide6.QtGui import QCursor, QMouseEvent
# ============================================================================
# 1. ON/OFF 신호 라벨
# ============================================================================
class OnOffSignalLabel(QLabel):
"""
ON/OFF 신호 표시용 라벨
- ON: 연두색 배경 + 검은색 글자
- OFF: 어두운 회색 배경 + 흰색 글자
"""
clicked = Signal()
right_clicked = Signal(QPoint)
# 스타일 상수
STYLE_ON = "background-color: #76FF03; color: black; border-radius: 3px; padding: 2px 6px; font-weight: bold;"
STYLE_OFF = "background-color: #3C3C3C; color: #AAAAAA; border-radius: 3px; padding: 2px 6px;"
def __init__(self, signal_name: str, display_name: str = None,
description: str = "", on_list: list = None, off_list: list = None, parent=None):
super().__init__(display_name or signal_name, parent)
self.signal_name = signal_name
self.display_name = display_name or signal_name
self.description = description
self.on_list = on_list or [] # ON일 때 목록
self.off_list = off_list or [] # OFF일 때 목록
self._style_on_override = None
self._style_off_override = None
self._is_on = False
# UI 설정
self.setAlignment(Qt.AlignCenter)
self.setCursor(Qt.PointingHandCursor)
self.setMinimumSize(40, 18)
self.setMaximumHeight(22)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# 툴팁 설정
if description:
self.setToolTip(description)
self.set_status(False)
def set_status(self, is_on: bool):
"""신호 상태 설정"""
self._is_on = is_on
if is_on:
self.setStyleSheet(self._style_on_override or self.STYLE_ON)
else:
self.setStyleSheet(self._style_off_override or self.STYLE_OFF)
def set_style_override(self, on_style: str | None = None, off_style: str | None = None):
"""ON/OFF 스타일 오버라이드"""
self._style_on_override = on_style
self._style_off_override = off_style
# 현재 상태 즉시 반영
self.set_status(self._is_on)
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self.clicked.emit()
elif event.button() == Qt.RightButton:
self.right_clicked.emit(event.pos())
self._show_context_menu(event.globalPosition().toPoint())
super().mousePressEvent(event)
def _show_context_menu(self, global_pos):
menu = QMenu(self)
menu.setStyleSheet("""
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
QMenu::item:selected { background-color: #0078D7; }
""")
# ON일 때 목록
if self.on_list:
on_menu = menu.addMenu("🟢 ON 목록")
for item in self.on_list:
on_menu.addAction(item)
# OFF일 때 목록
if self.off_list:
off_menu = menu.addMenu("⚫ OFF 목록")
for item in self.off_list:
off_menu.addAction(item)
menu.addSeparator()
# 신호 설명
if self.description:
desc_action = menu.addAction(" 신호 설명")
desc_action.triggered.connect(self._show_description_popup)
menu.exec(global_pos)
def _show_description_popup(self):
from app.ui.components.clickableLabel import HoverInfoPopup
popup = HoverInfoPopup(f"<b>{self.display_name}</b><br><br>{self.description}")
popup.move(QCursor.pos() + QPoint(10, 10))
popup.show()
# ============================================================================
# 2. MODE 신호 라벨 (여러 상태 중 하나)
# ============================================================================
class ModeSignalLabel(QLabel):
"""
MODE 신호 표시용 라벨
- 신호 없음: OFF (어두운 회색 배경 + 흰색 글자)
- 신호 있음: 해당 모드 표시 (연보라색 배경 + 검은색 글자)
"""
clicked = Signal()
right_clicked = Signal(QPoint)
STYLE_OFF = "background-color: #3C3C3C; color: #AAAAAA; border-radius: 3px; padding: 2px 6px;"
STYLE_ON = "background-color: #B388FF; color: black; border-radius: 3px; padding: 2px 6px; font-weight: bold;"
def __init__(self, signal_name: str, mode_list: list = None,
description: str = "", parent=None):
super().__init__("OFF", parent)
self.signal_name = signal_name
self.mode_list = mode_list or [] # 가능한 모드 목록
self.description = description
self._current_mode = None
# UI 설정
self.setAlignment(Qt.AlignCenter)
self.setCursor(Qt.PointingHandCursor)
self.setMinimumSize(45, 18)
self.setMaximumHeight(22)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
if description:
self.setToolTip(description)
self.set_mode(None)
def set_mode(self, mode: str):
"""현재 모드 설정"""
self._current_mode = mode
if mode:
self.setText(mode)
self.setStyleSheet(self.STYLE_ON)
else:
self.setText("OFF")
self.setStyleSheet(self.STYLE_OFF)
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self.clicked.emit()
elif event.button() == Qt.RightButton:
self.right_clicked.emit(event.pos())
self._show_context_menu(event.globalPosition().toPoint())
super().mousePressEvent(event)
def _show_context_menu(self, global_pos):
menu = QMenu(self)
menu.setStyleSheet("""
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
QMenu::item:selected { background-color: #0078D7; }
""")
# 모드 목록
if self.mode_list:
mode_menu = menu.addMenu("📋 모드 목록")
for mode in self.mode_list:
action = mode_menu.addAction(mode)
if mode == self._current_mode:
action.setEnabled(False)
menu.addSeparator()
if self.description:
desc_action = menu.addAction(" 신호 설명")
desc_action.triggered.connect(self._show_description_popup)
menu.exec(global_pos)
def _show_description_popup(self):
from app.ui.components.clickableLabel import HoverInfoPopup
popup = HoverInfoPopup(f"<b>{self.signal_name}</b><br><br>{self.description}")
popup.move(QCursor.pos() + QPoint(10, 10))
popup.show()
# ============================================================================
# 3. DATA 신호 라벨 (신호명 + 값) - 세로 배치
# ============================================================================
class DataSignalLabel(QFrame):
"""
데이터 신호 표시용 라벨 (신호명 + 2 세로 조합)
- 상단: 신호명 (밝은 파랑 배경 + 흰색 글자)
- 하단: 신호값 (남색 배경 + 흰색 글자)
"""
clicked = Signal()
right_clicked = Signal(QPoint)
STYLE_NAME = "background-color: #2196F3; color: white; border-radius: 3px 0 0 3px; padding: 2px 6px; font-size: 9px;"
STYLE_VALUE = "background-color: #1A237E; color: white; border-radius: 0 3px 3px 0; padding: 2px 6px; font-weight: bold; font-size: 10px;"
def __init__(self, signal_name: str, display_name: str = None,
unit: str = "", possible_values: list = None,
description: str = "", parent=None):
super().__init__(parent)
self.signal_name = signal_name
self.display_name = display_name or signal_name
self.unit = unit
self.possible_values = possible_values or []
self.description = description
self._setup_ui()
if description:
self.setToolTip(description)
def _setup_ui(self):
# 가로 배치 (이름 | 값)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 좌측: 신호명 라벨
self.lbl_name = QLabel(self.display_name)
self.lbl_name.setStyleSheet(self.STYLE_NAME)
self.lbl_name.setAlignment(Qt.AlignCenter)
self.lbl_name.setMinimumWidth(40)
self.lbl_name.setFixedHeight(22)
# 우측: 신호값 라벨
self.lbl_value = QLabel("-")
self.lbl_value.setStyleSheet(self.STYLE_VALUE)
self.lbl_value.setAlignment(Qt.AlignCenter)
self.lbl_value.setMinimumWidth(50)
self.lbl_value.setFixedHeight(22)
layout.addWidget(self.lbl_name)
layout.addWidget(self.lbl_value)
self.setFixedHeight(24) # 1행 높이
self.setCursor(Qt.PointingHandCursor)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def set_value(self, value):
"""신호 값 설정"""
if self.unit:
self.lbl_value.setText(f"{value}{self.unit}")
else:
self.lbl_value.setText(str(value))
def set_value_style(self, style: str):
"""값 라벨 스타일 커스텀 설정"""
self.lbl_value.setStyleSheet(self.STYLE_VALUE + style)
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self.clicked.emit()
elif event.button() == Qt.RightButton:
self.right_clicked.emit(event.pos())
self._show_context_menu(event.globalPosition().toPoint())
super().mousePressEvent(event)
def _show_context_menu(self, global_pos):
menu = QMenu(self)
menu.setStyleSheet("""
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
QMenu::item:selected { background-color: #0078D7; }
""")
# 가능한 값 목록
if self.possible_values:
val_menu = menu.addMenu("📋 가능한 값")
for val in self.possible_values:
val_menu.addAction(str(val))
menu.addSeparator()
if self.description:
desc_action = menu.addAction(" 신호 설명")
desc_action.triggered.connect(self._show_description_popup)
menu.exec(global_pos)
def _show_description_popup(self):
from app.ui.components.clickableLabel import HoverInfoPopup
popup = HoverInfoPopup(f"<b>{self.display_name}</b><br><br>{self.description}")
popup.move(QCursor.pos() + QPoint(10, 10))
popup.show()
# ============================================================================
# 4. 신호 카드 (그룹화)
# ============================================================================
class SignalCard(QFrame):
"""
신호들을 그룹화하는 카드 컴포넌트
- 타이틀
- ON/OFF, MODE 컴포넌트: 1 1
- DATA 컴포넌트: 1행에 2~4 차지 (가로로 넓게)
"""
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.title = title
self.signals = {} # signal_name -> widget
self._onoff_widgets = [] # ON/OFF, MODE 위젯들
self._data_widgets = [] # DATA 위젯들
self._horizontal_data_mode = False # DATA 가로 배치 모드
self._setup_ui()
def _setup_ui(self):
self.setStyleSheet("""
SignalCard {
background-color: #252526;
border: 1px solid #3E3E42;
border-radius: 4px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
self.setMinimumWidth(150)
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(2)
# 타이틀
self.lbl_title = QLabel(self.title)
self.lbl_title.setFixedHeight(20) # 제목 높이 고정
self.lbl_title.setStyleSheet("""
font-weight: bold;
color: #00B0FF;
font-size: 10px;
padding: 1px 3px;
border-bottom: 1px solid #3E3E42;
""")
layout.addWidget(self.lbl_title)
# 신호 컨테이너 (GridLayout 사용)
self.signal_container = QWidget()
self.signal_grid = QGridLayout(self.signal_container)
self.signal_grid.setContentsMargins(0, 2, 0, 0)
self.signal_grid.setSpacing(2)
layout.addWidget(self.signal_container)
# 현재 그리드 위치 추적
self._grid_row = 0
self._grid_col = 0
self._max_cols = 4 # 4열 배치 (DATA가 2~4열 차지 가능)
self._data_colspan = 2 # DATA 위젯의 기본 colspan (2~4열 조정 가능)
def set_horizontal_data_mode(self, enabled: bool):
"""DATA 위젯 가로 배치 모드 설정"""
self._horizontal_data_mode = enabled
def add_signal(self, widget, signal_name: str = None):
"""신호 위젯 추가 - 타입에 따라 배치 방식 다름"""
if isinstance(widget, DataSignalLabel):
if self._horizontal_data_mode:
# DATA 가로 배치 모드: 1행 1열 (ON/OFF처럼)
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col)
self._grid_col += 1
if self._grid_col >= self._max_cols:
self._grid_col = 0
self._grid_row += 1
else:
# 기본 모드: DATA 위젯은 1행에 2~4열 차지 (가로로 넓게)
# 현재 열이 0이 아니면 다음 행으로 이동
if self._grid_col != 0:
self._grid_row += 1
self._grid_col = 0
# colspan 계산 (남은 공간에 따라 2~4열)
colspan = min(self._data_colspan, self._max_cols - self._grid_col)
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col, 1, colspan)
self._grid_col += colspan
if self._grid_col >= self._max_cols:
self._grid_col = 0
self._grid_row += 1
self._data_widgets.append(widget)
else:
# ON/OFF, MODE 위젯: 1행 1열
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col)
self._grid_col += 1
if self._grid_col >= self._max_cols:
self._grid_col = 0
self._grid_row += 1
self._onoff_widgets.append(widget)
if signal_name:
self.signals[signal_name] = widget
def get_signal(self, signal_name: str):
"""신호 위젯 가져오기"""
return self.signals.get(signal_name)
def finalize_layout(self):
"""레이아웃 마무리 - 남는 공간에 stretch 추가"""
# 마지막 행 다음에 stretch 추가
if self._grid_col > 0:
self._grid_row += 1
self.signal_grid.setRowStretch(self._grid_row, 1)
# ============================================================================
# 5. FlowLayout (하위 호환용 - 더 이상 사용하지 않음)
# ============================================================================
class FlowLayout(QVBoxLayout):
"""
간단한 FlowLayout 구현 (레거시 호환용)
"""
def __init__(self, parent=None):
super().__init__(parent)
self._widgets = []
self._row_layouts = []
self._spacing = 4
self.setSpacing(4)
def setSpacing(self, spacing):
self._spacing = spacing
super().setSpacing(spacing)
def addWidget(self, widget):
self._widgets.append(widget)
self._reflow()
def _reflow(self):
# 기존 레이아웃 정리
for row_layout in self._row_layouts:
while row_layout.count():
item = row_layout.takeAt(0)
for row_layout in self._row_layouts:
self.removeItem(row_layout)
self._row_layouts.clear()
# 새 행 생성
current_row = QHBoxLayout()
current_row.setSpacing(self._spacing)
self._row_layouts.append(current_row)
super().addLayout(current_row)
for widget in self._widgets:
current_row.addWidget(widget)
current_row.addStretch()

View File

@ -0,0 +1,139 @@
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import Qt, QRectF
from PySide6.QtGui import QPainter, QColor, QPen, QFont, QBrush, QPainterPath
class SpeedometerWidget(QWidget):
def __init__(self, title="SPEED", unit="km/h", max_value=120, parent=None):
super().__init__(parent)
self.title = title
self.unit = unit
self.max_value = max_value
self.current_value = 0.0
self.limit_value = 0.0
self.atc_code = 0 # ATC CODE 추가
self.setMinimumSize(120, 120)
def set_value(self, value):
try:
self.current_value = float(value)
except ValueError:
self.current_value = 0.0
self.update()
def set_limit(self, limit):
try:
self.limit_value = float(limit)
except ValueError:
self.limit_value = 0.0
self.update()
def set_atc_code(self, code):
"""ATC CODE 설정"""
try:
self.atc_code = int(code)
except (ValueError, TypeError):
self.atc_code = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
width = self.width()
height = self.height()
side = min(width, height)
# 중앙 정렬
painter.translate(width / 2, height / 2)
painter.scale(side / 200.0, side / 200.0)
# 배경 (어두운 원)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(30, 30, 30))
painter.drawEllipse(-100, -100, 200, 200)
# 눈금 그리기 (Start: 135도, Span: 270도)
start_angle = 135 * 16
span_angle = -270 * 16
# 게이지 배경 (회색 아크)
pen = QPen(QColor(60, 60, 60))
pen.setWidth(15)
pen.setCapStyle(Qt.FlatCap)
painter.setPen(pen)
painter.drawArc(-80, -80, 160, 160, start_angle, span_angle)
# 현재 속도 아크 (녹색/노란색/빨간색)
if self.current_value > self.limit_value and self.limit_value > 0:
color = QColor(255, 82, 82) # 초과 시 빨강
elif self.current_value > 0:
color = QColor(0, 208, 132) # 평소 녹색
else:
color = QColor(60, 60, 60)
if self.current_value > 0:
val_ratio = min(self.current_value / self.max_value, 1.0)
val_span = int(span_angle * val_ratio)
pen.setColor(color)
painter.setPen(pen)
painter.drawArc(-80, -80, 160, 160, start_angle, val_span)
# 제한 속도 표시 (작은 마커)
if self.limit_value > 0:
limit_ratio = min(self.limit_value / self.max_value, 1.0)
limit_angle = 135 + (270 * limit_ratio)
painter.save()
painter.rotate(limit_angle)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(255, 165, 0)) # 주황색 마커
painter.drawRect(-2, -95, 4, 10) # 눈금 위치에 마커
painter.restore()
# ATC CODE 바늘 및 동그라미 표시
if self.atc_code > 0:
# ATC CODE에 해당하는 각도 계산 (0~max_value 범위)
atc_ratio = min(self.atc_code / self.max_value, 1.0)
atc_angle = 135 + (270 * atc_ratio)
painter.save()
painter.rotate(atc_angle)
# 빨간 바늘 (외부에서 내부로)
pen = QPen(QColor(255, 50, 50))
pen.setWidth(3)
painter.setPen(pen)
painter.drawLine(0, -95, 0, -70) # 외부(-95)에서 내부(-70)로
# 바늘 외부에 동그라미
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(255, 50, 50))
painter.drawEllipse(-8, -110, 16, 16) # 바늘 외부에 원
# 동그라미 안에 ATC CODE 텍스트
painter.setPen(QColor(255, 255, 255))
font = QFont("Arial", 8, QFont.Bold)
painter.setFont(font)
painter.drawText(QRectF(-8, -110, 16, 16), Qt.AlignCenter, str(self.atc_code))
painter.restore()
# 텍스트 표시
painter.setPen(QColor(255, 255, 255))
# 현재 값 (큰 글씨)
font = QFont("Arial", 28, QFont.Bold)
painter.setFont(font)
painter.drawText(QRectF(-100, -20, 200, 40), Qt.AlignCenter, f"{self.current_value:.1f}")
# 단위 (작은 글씨)
font.setPointSize(10)
painter.setFont(font)
painter.drawText(QRectF(-100, 20, 200, 20), Qt.AlignCenter, self.unit)
# 타이틀 (상단)
font.setPointSize(12)
painter.setFont(font)
painter.setPen(QColor(180, 180, 180))
painter.drawText(QRectF(-100, -70, 200, 20), Qt.AlignCenter, self.title)

View File

@ -0,0 +1,38 @@
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import Qt
class StatusLamp(QLabel):
def __init__(self, text, on_color="#00FF00", off_color="#333333", text_color="white", font_size=10, shape="rect"):
super().__init__(text)
self.setAlignment(Qt.AlignCenter)
self.on_color = on_color
self.off_color = off_color
self.text_color = text_color
self.shape = shape
# 기본 스타일 정의
radius = "12px" if shape == "circle" else "2px"
padding = "2px"
self.base_style = f"""
QLabel {{
color: {self.text_color};
border: 1px solid #555555;
border-radius: {radius};
font-family: 'Malgun Gothic';
font-weight: bold;
font-size: {font_size}pt;
padding: {padding};
}}
"""
self.set_status(False)
def set_status(self, is_on: bool):
bg = self.on_color if is_on else self.off_color
# 켜졌을 때 글자색을 검정으로 할지 흰색으로 할지 (가독성)
fg = "black" if (is_on and self.on_color in ["#00FF00", "#FFFF00", "#00BFFF", "#FFA500"]) else self.text_color
self.setStyleSheet(f"""
{self.base_style}
QLabel {{ background-color: {bg}; color: {fg}; }}
""")

View File

@ -0,0 +1 @@
# Timeline Slider

View File

@ -0,0 +1,70 @@
from PySide6.QtWidgets import QCheckBox
from PySide6.QtCore import Property, QSize, Qt, QRect
from PySide6.QtGui import QPainter, QColor, QBrush, QPen
class ToggleButton(QCheckBox):
"""아이폰 스타일의 커스텀 토글 스위치"""
def __init__(self, width=50, bg_color="#777", circle_color="#DDD", active_color="#00BCff",
disabled_color="#444"):
super().__init__()
self.setFixedSize(width, 28)
self.setCursor(Qt.PointingHandCursor)
self._bg_color = bg_color
self._circle_color = circle_color
self._active_color = active_color
self._disabled_color = disabled_color
self._circle_position = 3
self.stateChanged.connect(self.start_animation)
def start_animation(self, state):
self.update() # 애니메이션 없이 즉시 변경 (필요시 QPropertyAnimation 추가 가능)
def mousePressEvent(self, event):
"""마우스 클릭 이벤트 명시적 처리"""
if event.button() == Qt.LeftButton and self.isEnabled():
self.setChecked(not self.isChecked())
event.accept()
else:
super().mousePressEvent(event)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
# 비활성화 상태
if not self.isEnabled():
p.setBrush(QColor(self._disabled_color))
p.setPen(Qt.NoPen)
p.drawRoundedRect(0, 0, rect.width(), rect.height(), rect.height() / 2, rect.height() / 2)
# 어두운 원
circle_dia = rect.height() - 6
p.setBrush(QColor("#555"))
p.drawEllipse(3, 3, circle_dia, circle_dia)
p.end()
return
# 배경 그리기 (Capsule 모양)
if self.isChecked():
p.setBrush(QColor(self._active_color))
p.setPen(Qt.NoPen)
else:
p.setBrush(QColor(self._bg_color))
p.setPen(Qt.NoPen)
p.drawRoundedRect(0, 0, rect.width(), rect.height(), rect.height() / 2, rect.height() / 2)
# 원 그리기
circle_dia = rect.height() - 6
p.setBrush(QColor(self._circle_color))
if self.isChecked():
p.drawEllipse(rect.width() - circle_dia - 3, 3, circle_dia, circle_dia)
else:
p.drawEllipse(3, 3, circle_dia, circle_dia)
p.end()

View File

@ -0,0 +1,8 @@
"""
UI 다이얼로그 패키지
"""
from .api_key_dialog import APIKeyDialog
__all__ = ['APIKeyDialog']

Binary file not shown.

View File

@ -0,0 +1,521 @@
"""
API 설정 다이얼로그
AI 서비스 API 입력 관리 UI
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QLineEdit, QComboBox, QPushButton,
QTabWidget, QWidget, QGroupBox, QMessageBox,
QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QIcon
from app.core.settings import get_settings_manager, AISettings
from app.ai import AIProviderType, get_ai_client
class ProviderTab(QWidget):
"""개별 프로바이더 설정 탭"""
api_key_changed = Signal(str, str) # provider_type, api_key
def __init__(self, provider_type: str, provider_name: str, available_models: list, parent=None):
super().__init__(parent)
self.provider_type = provider_type
self.provider_name = provider_name
self.available_models = available_models
self.setup_ui()
self.load_settings()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(16)
layout.setContentsMargins(20, 20, 20, 20)
# 프로바이더 정보
info_group = QGroupBox(f"{self.provider_name} 설정")
info_group.setStyleSheet("""
QGroupBox {
font-size: 14px;
font-weight: bold;
border: 1px solid #3d3d3d;
border-radius: 8px;
margin-top: 12px;
padding-top: 12px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 8px;
}
""")
info_layout = QGridLayout(info_group)
info_layout.setSpacing(12)
info_layout.setContentsMargins(16, 24, 16, 16)
# API Key 입력
api_key_label = QLabel("API Key:")
api_key_label.setStyleSheet("font-weight: bold;")
self.api_key_input = QLineEdit()
self.api_key_input.setPlaceholderText("API 키를 입력하세요...")
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
self.api_key_input.setMinimumHeight(36)
self.api_key_input.setStyleSheet("""
QLineEdit {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #2d2d2d;
color: #fff;
font-size: 13px;
}
QLineEdit:focus {
border-color: #0078d4;
}
""")
# API Key 보기/숨기기 버튼
self.toggle_visibility_btn = QPushButton("👁")
self.toggle_visibility_btn.setFixedSize(36, 36)
self.toggle_visibility_btn.setCheckable(True)
self.toggle_visibility_btn.setStyleSheet("""
QPushButton {
border: 1px solid #555;
border-radius: 6px;
background: #2d2d2d;
}
QPushButton:checked {
background: #3d3d3d;
}
QPushButton:hover {
background: #404040;
}
""")
self.toggle_visibility_btn.clicked.connect(self.toggle_api_key_visibility)
api_key_row = QHBoxLayout()
api_key_row.addWidget(self.api_key_input)
api_key_row.addWidget(self.toggle_visibility_btn)
info_layout.addWidget(api_key_label, 0, 0)
info_layout.addLayout(api_key_row, 0, 1)
# 모델 선택
model_label = QLabel("모델:")
model_label.setStyleSheet("font-weight: bold;")
self.model_combo = QComboBox()
self.model_combo.addItems(self.available_models)
self.model_combo.setMinimumHeight(36)
self.model_combo.setStyleSheet("""
QComboBox {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #2d2d2d;
color: #fff;
font-size: 13px;
}
QComboBox:hover {
border-color: #666;
}
QComboBox::drop-down {
border: none;
width: 30px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #888;
margin-right: 10px;
}
QComboBox QAbstractItemView {
background: #2d2d2d;
border: 1px solid #555;
selection-background-color: #0078d4;
}
""")
info_layout.addWidget(model_label, 1, 0)
info_layout.addWidget(self.model_combo, 1, 1)
info_layout.setColumnStretch(1, 1)
layout.addWidget(info_group)
# 테스트 버튼
test_layout = QHBoxLayout()
test_layout.addStretch()
self.test_btn = QPushButton("🔗 연결 테스트")
self.test_btn.setMinimumHeight(40)
self.test_btn.setMinimumWidth(140)
self.test_btn.setStyleSheet("""
QPushButton {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: #0078d4;
color: white;
font-size: 13px;
font-weight: bold;
}
QPushButton:hover {
background: #1084d8;
}
QPushButton:pressed {
background: #006cc1;
}
QPushButton:disabled {
background: #555;
color: #888;
}
""")
self.test_btn.clicked.connect(self.test_connection)
test_layout.addWidget(self.test_btn)
layout.addLayout(test_layout)
# 상태 표시
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #888; font-size: 12px;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.status_label)
layout.addStretch()
def toggle_api_key_visibility(self, checked):
"""API 키 표시/숨기기 토글"""
if checked:
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Normal)
self.toggle_visibility_btn.setText("🔒")
else:
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
self.toggle_visibility_btn.setText("👁")
def load_settings(self):
"""설정에서 값 로드"""
settings = get_settings_manager().ai_settings
api_key = settings.get_api_key(self.provider_type)
if api_key:
self.api_key_input.setText(api_key)
model = settings.get_model(self.provider_type)
if model:
index = self.model_combo.findText(model)
if index >= 0:
self.model_combo.setCurrentIndex(index)
def save_settings(self):
"""설정 저장"""
settings_manager = get_settings_manager()
settings = settings_manager.ai_settings
settings.set_api_key(self.provider_type, self.api_key_input.text())
settings.set_model(self.provider_type, self.model_combo.currentText())
settings_manager.save()
def test_connection(self):
"""연결 테스트"""
api_key = self.api_key_input.text().strip()
if not api_key:
self.status_label.setText("❌ API 키를 입력하세요")
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
return
self.status_label.setText("⏳ 연결 테스트 중...")
self.status_label.setStyleSheet("color: #ffc107; font-size: 12px;")
self.test_btn.setEnabled(False)
# 테스트 실행 (간단한 동기 테스트)
try:
from app.ai import AIClient, AIProviderType
client = AIClient()
provider_type = AIProviderType(self.provider_type)
model = self.model_combo.currentText()
if client.set_provider(provider_type, api_key, model):
# 간단한 테스트 메시지
response = client.chat("Hello", max_tokens=10)
self.status_label.setText(f"✅ 연결 성공! ({response.model})")
self.status_label.setStyleSheet("color: #4caf50; font-size: 12px;")
else:
self.status_label.setText("❌ 초기화 실패")
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
except Exception as e:
error_msg = str(e)[:50] + "..." if len(str(e)) > 50 else str(e)
self.status_label.setText(f"❌ 오류: {error_msg}")
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
finally:
self.test_btn.setEnabled(True)
def get_api_key(self) -> str:
return self.api_key_input.text().strip()
def get_model(self) -> str:
return self.model_combo.currentText()
class APIKeyDialog(QDialog):
"""API 키 설정 다이얼로그"""
settings_changed = Signal()
# 프로바이더별 정보
PROVIDERS = [
{
"type": "openai",
"name": "OpenAI",
"models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo", "o1-preview", "o1-mini"],
},
{
"type": "openrouter",
"name": "OpenRouter",
"models": [
"openai/gpt-4o", "openai/gpt-4o-mini",
"anthropic/claude-3.5-sonnet", "anthropic/claude-3-opus",
"google/gemini-pro-1.5", "google/gemini-flash-1.5",
"meta-llama/llama-3.1-70b-instruct", "meta-llama/llama-3.1-8b-instruct",
"mistralai/mixtral-8x7b-instruct", "deepseek/deepseek-chat",
],
},
{
"type": "gemini",
"name": "Google Gemini",
"models": ["gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b", "gemini-1.0-pro"],
},
{
"type": "xai",
"name": "xAI (Grok)",
"models": ["grok-beta", "grok-2-1212", "grok-2-vision-1212"],
},
]
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("AI API 키 설정")
self.setMinimumSize(550, 450)
self.setModal(True)
self.provider_tabs = {}
self.setup_ui()
self.load_current_provider()
def setup_ui(self):
self.setStyleSheet("""
QDialog {
background: #1e1e1e;
color: #fff;
}
QLabel {
color: #ddd;
}
QTabWidget::pane {
border: 1px solid #3d3d3d;
border-radius: 8px;
background: #252525;
}
QTabBar::tab {
background: #2d2d2d;
color: #888;
padding: 10px 20px;
margin-right: 2px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
QTabBar::tab:selected {
background: #252525;
color: #fff;
}
QTabBar::tab:hover {
background: #353535;
color: #ccc;
}
""")
layout = QVBoxLayout(self)
layout.setSpacing(16)
layout.setContentsMargins(20, 20, 20, 20)
# 헤더
header = QLabel("🔑 AI API 키 설정")
header.setStyleSheet("""
font-size: 18px;
font-weight: bold;
color: #fff;
padding: 8px 0;
""")
layout.addWidget(header)
# 현재 프로바이더 선택
provider_layout = QHBoxLayout()
provider_label = QLabel("기본 프로바이더:")
provider_label.setStyleSheet("font-weight: bold;")
self.provider_combo = QComboBox()
for provider in self.PROVIDERS:
self.provider_combo.addItem(provider["name"], provider["type"])
self.provider_combo.setMinimumHeight(36)
self.provider_combo.setMinimumWidth(200)
self.provider_combo.setStyleSheet("""
QComboBox {
padding: 8px 12px;
border: 1px solid #555;
border-radius: 6px;
background: #2d2d2d;
color: #fff;
font-size: 13px;
}
QComboBox:hover {
border-color: #666;
}
QComboBox::drop-down {
border: none;
width: 30px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #888;
margin-right: 10px;
}
QComboBox QAbstractItemView {
background: #2d2d2d;
border: 1px solid #555;
selection-background-color: #0078d4;
}
""")
provider_layout.addWidget(provider_label)
provider_layout.addWidget(self.provider_combo)
provider_layout.addStretch()
layout.addLayout(provider_layout)
# 탭 위젯
self.tab_widget = QTabWidget()
for provider in self.PROVIDERS:
tab = ProviderTab(
provider_type=provider["type"],
provider_name=provider["name"],
available_models=provider["models"]
)
self.provider_tabs[provider["type"]] = tab
self.tab_widget.addTab(tab, provider["name"])
layout.addWidget(self.tab_widget)
# 버튼
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("취소")
cancel_btn.setMinimumHeight(40)
cancel_btn.setMinimumWidth(100)
cancel_btn.setStyleSheet("""
QPushButton {
padding: 10px 24px;
border: 1px solid #555;
border-radius: 6px;
background: #2d2d2d;
color: #ddd;
font-size: 13px;
}
QPushButton:hover {
background: #3d3d3d;
border-color: #666;
}
""")
cancel_btn.clicked.connect(self.reject)
save_btn = QPushButton("저장")
save_btn.setMinimumHeight(40)
save_btn.setMinimumWidth(100)
save_btn.setStyleSheet("""
QPushButton {
padding: 10px 24px;
border: none;
border-radius: 6px;
background: #0078d4;
color: white;
font-size: 13px;
font-weight: bold;
}
QPushButton:hover {
background: #1084d8;
}
QPushButton:pressed {
background: #006cc1;
}
""")
save_btn.clicked.connect(self.save_and_close)
button_layout.addWidget(cancel_btn)
button_layout.addWidget(save_btn)
layout.addLayout(button_layout)
def load_current_provider(self):
"""현재 설정된 프로바이더 로드"""
settings = get_settings_manager().ai_settings
current = settings.get_current_provider()
if current:
index = self.provider_combo.findData(current)
if index >= 0:
self.provider_combo.setCurrentIndex(index)
# 해당 탭으로 전환
for i, provider in enumerate(self.PROVIDERS):
if provider["type"] == current:
self.tab_widget.setCurrentIndex(i)
break
def save_and_close(self):
"""설정 저장 후 닫기"""
settings_manager = get_settings_manager()
# 모든 탭의 설정 저장
for provider_type, tab in self.provider_tabs.items():
tab.save_settings()
# 기본 프로바이더 설정
current_provider = self.provider_combo.currentData()
settings_manager.ai_settings.set_current_provider(current_provider)
settings_manager.save()
# AI 클라이언트 업데이트
self._update_ai_client()
self.settings_changed.emit()
self.accept()
def _update_ai_client(self):
"""저장된 설정으로 AI 클라이언트 업데이트"""
try:
from app.ai import get_ai_client, AIProviderType
settings = get_settings_manager().ai_settings
current_provider = settings.get_current_provider()
if current_provider:
api_key = settings.get_api_key(current_provider)
model = settings.get_model(current_provider)
if api_key:
client = get_ai_client()
provider_type = AIProviderType(current_provider)
client.set_provider(provider_type, api_key, model)
except Exception as e:
print(f"AI 클라이언트 업데이트 실패: {e}")

280
app/ui/main_window.py Normal file
View File

@ -0,0 +1,280 @@
from PySide6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QSplitter,
QToolBar,
QStatusBar,
QLabel,
)
from PySide6.QtCore import Qt, QRect
from PySide6.QtGui import QAction
from app.ui.analysis_panel import AnalysisPanel
from app.core.sync_controller import sync_manager
from app.ui.components.toggle_button import ToggleButton
from app.ui.dialogs.api_key_dialog import APIKeyDialog
from app.core.settings import get_settings_manager
from app.ui.widgets.ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open, animate_close
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("SL200 AI Smart Analyzer")
self.resize(1800, 1000)
self.setup_ui()
self.setup_menubar()
self.setup_toolbar()
# 패널 데이터 로드 시 날짜 비교를 위한 시그널 연결
self.left_panel.data_loaded.connect(self.check_sync_availability)
self.right_panel.data_loaded.connect(self.check_sync_availability)
# AI 클라이언트 초기화
self._init_ai_client()
def setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# 좌우 분할
splitter = QSplitter(Qt.Orientation.Horizontal)
# 왼쪽/오른쪽 패널 생성 (ID 부여)
self.left_panel = AnalysisPanel("left")
self.right_panel = AnalysisPanel("right")
splitter.addWidget(self.left_panel)
splitter.addWidget(self.right_panel)
splitter.setSizes([900, 900]) # 1:1 비율
layout.addWidget(splitter)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
# 플로팅 AI 버튼
self._ai_button = AIFloatingButton(central)
self._ai_button.clicked.connect(self._open_ai_chat)
self._ai_button.show()
self._ai_chat: AIChatDialog | None = None
self._ai_button.move_to_bottom_right()
def resizeEvent(self, event):
super().resizeEvent(event)
try:
if hasattr(self, "_ai_button") and self._ai_button:
self._ai_button.move_to_bottom_right()
except Exception:
pass
def setup_toolbar(self):
toolbar = QToolBar("Main Toolbar")
self.addToolBar(toolbar)
# 커스텀 토글 버튼
self.sync_toggle = ToggleButton(width=60, active_color="#00D084") # 녹색
self.sync_toggle.toggled.connect(self.on_sync_toggled)
self.sync_toggle.setEnabled(False) # 초기에는 비활성화
toolbar.addWidget(QLabel(" SYNC MODE: "))
toolbar.addWidget(self.sync_toggle)
# 상태 라벨
self.sync_status_label = QLabel(" (데이터 없음)")
self.sync_status_label.setStyleSheet("color: #888;")
toolbar.addWidget(self.sync_status_label)
def setup_menubar(self):
"""메뉴바 설정"""
menubar = self.menuBar()
# 파일 메뉴
menubar.addMenu("파일(&F)")
# 설정 메뉴
settings_menu = menubar.addMenu("설정(&S)")
# AI API 키 설정
ai_settings_action = QAction("🔑 AI API 키 설정...", self)
ai_settings_action.setShortcut("Ctrl+Shift+A")
ai_settings_action.triggered.connect(self.show_api_key_dialog)
settings_menu.addAction(ai_settings_action)
# 도움말 메뉴
menubar.addMenu("도움말(&H)")
def show_api_key_dialog(self):
"""API 키 설정 다이얼로그 표시"""
dialog = APIKeyDialog(self)
dialog.settings_changed.connect(self._on_ai_settings_changed)
dialog.exec()
def _on_ai_settings_changed(self):
"""AI 설정이 변경되었을 때"""
self.status_bar.showMessage("AI 설정이 저장되었습니다.", 3000)
self._init_ai_client()
def check_sync_availability(self):
"""두 패널의 데이터 날짜가 같은지 확인하여 Sync 버튼 활성화/비활성화"""
left_date = self.left_panel.get_data_date()
right_date = self.right_panel.get_data_date()
# 둘 다 데이터가 있는 경우에만 비교
if left_date and right_date:
if left_date == right_date:
# 날짜가 같으면 Sync 활성화
self.sync_toggle.setEnabled(True)
self.sync_status_label.setText(f" ({left_date})")
self.sync_status_label.setStyleSheet("color: #00D084;")
else:
# 날짜가 다르면 Sync 비활성화
self.sync_toggle.setEnabled(False)
self.sync_toggle.setChecked(False) # 자동으로 끔
self.sync_status_label.setText(f" (날짜 불일치: {left_date}{right_date})")
self.sync_status_label.setStyleSheet("color: #FF5252;")
elif left_date or right_date:
# 한쪽만 데이터가 있음
self.sync_toggle.setEnabled(False)
date_info = left_date or right_date
self.sync_status_label.setText(f" ({date_info} - 상대 패널 없음)")
self.sync_status_label.setStyleSheet("color: #888;")
else:
# 둘 다 데이터 없음
self.sync_toggle.setEnabled(False)
self.sync_status_label.setText(" (데이터 없음)")
self.sync_status_label.setStyleSheet("color: #888;")
def on_sync_toggled(self, checked):
"""동기화 모드 변경 시 동작"""
sync_manager.is_sync_enabled = checked
if checked:
# [SYNC ON]
# 1. 오른쪽 패널의 파일 로드 버튼 숨김
self.right_panel.set_slave_mode(True)
# 2. 왼쪽 패널의 데이터가 있다면 오른쪽으로 복사
if self.left_panel.raw_data:
self.right_panel.receive_shared_data(self.left_panel.raw_data)
self.status_bar.showMessage("Sync ON: 오른쪽 패널이 왼쪽 데이터와 동기화됩니다.")
else:
# [SYNC OFF]
self.right_panel.set_slave_mode(False)
self.status_bar.showMessage("Sync OFF: 독립 분석 모드")
def _init_ai_client(self):
"""저장된 설정으로 AI 클라이언트 초기화(가능할 때만)"""
try:
from app.ai import get_ai_client, AIProviderType
settings = get_settings_manager().ai_settings
current_provider = settings.get_current_provider()
if not current_provider:
return
api_key = settings.get_api_key(current_provider)
model = settings.get_model(current_provider)
if not api_key:
return
client = get_ai_client()
provider_type = AIProviderType(current_provider)
if client.set_provider(provider_type, api_key, model):
st = client.get_status()
self.status_bar.showMessage(f"AI 준비: {st.get('provider')} / {st.get('model')}", 3000)
except Exception as e:
print(f"AI 클라이언트 초기화 실패: {e}")
def _get_panel_ctx(self, panel_id: str, user_query: str | None = None):
panel = self.right_panel if panel_id == "right" else self.left_panel
ctx = panel.get_ai_context()
# 질의에 따라 추가 분석 결과를 포함(프롬프트 품질 개선)
if user_query:
q = user_query.lower()
if ("pg3" in q or "pg3-2" in q) and ("" in user_query):
try:
ctx.setdefault("analysis", {})
ctx["analysis"]["pg3_2_per_station"] = panel.compute_pg_at_station("PG3-2")
except Exception:
pass
return ctx
def _is_qobject_alive(self, obj) -> bool:
"""PySide6 객체가 이미 delete된 상태인지 안전하게 판별"""
if obj is None:
return False
try:
import shiboken6 # type: ignore
return bool(shiboken6.isValid(obj))
except Exception:
# shiboken6이 없거나, 일부 환경에서 import 실패 시 런타임 호출로 체크
try:
obj.objectName()
return True
except Exception:
return False
def _on_ai_chat_destroyed(self, *_args):
# 삭제된 객체 참조 제거(다음 클릭에서 RuntimeError 방지)
self._ai_chat = None
self._ai_button.set_open_state(False)
def _open_ai_chat(self):
# 열려 있으면: 웹처럼 버튼(X)로 닫기 동작
if self._ai_chat and self._is_qobject_alive(self._ai_chat) and self._ai_chat.isVisible():
btn = self._ai_button
start_rect = self._ai_chat.geometry()
# 닫힘 애니메이션은 버튼 위치로 축소
btn_pos = btn.mapTo(self, btn.rect().topLeft())
end_rect = QRect(btn_pos.x(), btn_pos.y(), btn.width(), btn.height())
animate_close(self._ai_chat, start_rect, end_rect, duration_ms=180)
return
# 레퍼런스는 남아있지만 이미 삭제된 경우 정리
if self._ai_chat and not self._is_qobject_alive(self._ai_chat):
self._ai_chat = None
self._ai_button.set_open_state(False)
self._ai_chat = AIChatDialog(self, self._get_panel_ctx)
self._ai_chat.setAttribute(Qt.WA_DeleteOnClose, True)
self._ai_chat.destroyed.connect(self._on_ai_chat_destroyed)
self._ai_button.set_open_state(True)
# 버튼 위치 기준으로 확장 애니메이션
btn = self._ai_button
# 메인윈도우 좌표로 변환
btn_top_left = btn.mapTo(self, btn.rect().topLeft())
start_rect = QRect(btn_top_left.x(), btn_top_left.y(), btn.width(), btn.height())
# 최종 크기: 버튼을 가리지 않도록 '버튼 위로' 배치(가능하면)
w, h = 560, 680
gap = 10
margin = 14
# 1) 우선: 버튼 위로(오른쪽 정렬)
end_x = btn_top_left.x() + btn.width() - w
end_y = btn_top_left.y() - h - gap
# 위로 배치 불가능하면: 버튼 왼쪽으로
if end_y < margin:
end_y = max(margin, btn_top_left.y() + btn.height() - h) # 세로는 버튼과 겹치지 않게 최대한 맞춤
end_x = btn_top_left.x() - w - gap
# 그래도 화면 밖이면: 화면 안으로 클램프(버튼 가림 최소화)
end_x = max(margin, min(end_x, self.width() - w - margin))
end_y = max(margin, min(end_y, self.height() - h - margin))
end_rect = QRect(end_x, end_y, w, h)
animate_open(self._ai_chat, start_rect, end_rect, duration_ms=280)

0
app/ui/views/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

649
app/ui/views/ai_view.py Normal file
View File

@ -0,0 +1,649 @@
"""
AI Diagnosis View
- 현재 로드된 로그 데이터를 요약/진단하고, 사용자의 질문을 LLM에 전달합니다.
- API /모델은 app/core/settings.py AI 설정(ai_settings.json)에서 로드합니다.
"""
from __future__ import annotations
import json
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import QObject, QThread, Signal, Qt
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QTextEdit,
QComboBox,
QGroupBox,
QMessageBox,
QSpinBox,
QLineEdit,
)
from app.ai import get_ai_client, AIProviderType
from app.core.settings import get_settings_manager
from app.core.sync_controller import sync_manager
from app.ui.dialogs.api_key_dialog import APIKeyDialog
from app.ai.maintenance_kb import get_maintenance_kb
def _safe_to_dict(obj: Any) -> Dict[str, Any]:
"""데이터 객체를 JSON 직렬화 가능한 dict로 축약 변환"""
if obj is None:
return {}
if is_dataclass(obj):
d = asdict(obj)
elif hasattr(obj, "__dict__"):
d = dict(obj.__dict__)
else:
return {"value": str(obj)}
# 너무 긴 텍스트는 컷
for k, v in list(d.items()):
if isinstance(v, str) and len(v) > 400:
d[k] = v[:400] + "..."
return d
def _compute_basic_stats(rows: List[Any]) -> Dict[str, Any]:
"""로그 데이터(객체 리스트)에서 기본 통계를 계산"""
if not rows:
return {"count": 0}
def _get_float(name: str) -> List[float]:
vals = []
for r in rows:
v = getattr(r, name, None)
if v is None:
continue
try:
vals.append(float(v))
except Exception:
pass
return vals
def _get_bool_ratio(name: str) -> float:
total = len(rows)
if total == 0:
return 0.0
on = 0
for r in rows:
if bool(getattr(r, name, False)):
on += 1
return on / total
speeds = _get_float("trainspeed")
dtgs = _get_float("dtg")
pwms = _get_float("pwm_value")
stats = {
"count": len(rows),
"time_start": getattr(rows[0], "time", ""),
"time_end": getattr(rows[-1], "time", ""),
"speed": {
"min": min(speeds) if speeds else None,
"max": max(speeds) if speeds else None,
"avg": (sum(speeds) / len(speeds)) if speeds else None,
},
"dtg": {
"min": min(dtgs) if dtgs else None,
"max": max(dtgs) if dtgs else None,
"avg": (sum(dtgs) / len(dtgs)) if dtgs else None,
},
"pwm": {
"min": min(pwms) if pwms else None,
"max": max(pwms) if pwms else None,
"avg": (sum(pwms) / len(pwms)) if pwms else None,
},
# 자주 쓰는 플래그 비율(ON 비율)
"flags_ratio": {
"system_active": _get_bool_ratio("system_active"),
"over_spd_warning": _get_bool_ratio("over_spd_warning"),
"tasc": _get_bool_ratio("tasc"),
"twct_enable": _get_bool_ratio("twct_enable"),
"door_open": _get_bool_ratio("door_open"),
"door_close": _get_bool_ratio("door_close"),
"psd_open": _get_bool_ratio("psd_open"),
"psd_close": _get_bool_ratio("psd_close"),
},
}
return stats
class _AIWorker(QObject):
finished = Signal(str)
failed = Signal(str)
def __init__(self, prompt: str):
super().__init__()
self.prompt = prompt
def run(self):
try:
client = get_ai_client()
if not client.is_ready():
raise RuntimeError("AI가 아직 준비되지 않았습니다. 설정에서 API 키를 입력하세요.")
resp = client.chat(self.prompt, temperature=0.2)
self.finished.emit(resp.content or "")
except Exception as e:
self.failed.emit(str(e))
class AIDiagnosisView(QWidget):
"""AnalysisPanel의 AI Diagnosis 탭 위젯"""
def __init__(self, panel_id: str, graph_view: Optional[QObject] = None, parent: Optional[QWidget] = None):
super().__init__(parent)
self.panel_id = panel_id
self.graph_view = graph_view
self.file_path: Optional[str] = None
self.data_list: List[Any] = []
self.current_index: Optional[int] = None
self.current_row: Optional[Any] = None
self._thread: Optional[QThread] = None
self._worker: Optional[_AIWorker] = None
self._busy: bool = False
self._build_ui()
self.refresh_status()
# 그래프 커서 이동(동기화 매니저 이벤트) 기반으로 현재 포인트 갱신
sync_manager.time_changed.connect(self._on_time_changed)
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(10)
# 상단 상태/컨트롤
top = QHBoxLayout()
self.lbl_status = QLabel("AI: (미설정)")
self.lbl_status.setStyleSheet("color: #ccc; font-weight: bold;")
self.provider_combo = QComboBox()
self.provider_combo.setMinimumWidth(220)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
btn_settings = QPushButton("🔑 설정")
btn_settings.clicked.connect(self._open_settings)
top.addWidget(self.lbl_status, stretch=1)
top.addWidget(QLabel("프로바이더:"))
top.addWidget(self.provider_combo)
top.addWidget(btn_settings)
root.addLayout(top)
# 데이터 범위 선택
range_box = QGroupBox("분석 범위")
range_layout = QHBoxLayout(range_box)
range_layout.addWidget(QLabel("커서 기준 ±(초):"))
self.spin_window_sec = QSpinBox()
self.spin_window_sec.setRange(10, 3600)
self.spin_window_sec.setValue(300)
range_layout.addWidget(self.spin_window_sec)
range_layout.addStretch()
root.addWidget(range_box)
# 사용자 입력(열번/호차/편성/증상)
user_box = QGroupBox("사용자 입력 (운행/편성/증상)")
user_layout = QVBoxLayout(user_box)
row1 = QHBoxLayout()
self.in_train_no = QLineEdit()
self.in_train_no.setPlaceholderText("열번 (예: 101)")
self.in_car_no = QLineEdit()
self.in_car_no.setPlaceholderText("호차 (예: 3)")
self.in_formation = QLineEdit()
self.in_formation.setPlaceholderText("편성 (예: 8량/4M4T 등)")
row1.addWidget(QLabel("열번:"))
row1.addWidget(self.in_train_no)
row1.addWidget(QLabel("호차:"))
row1.addWidget(self.in_car_no)
row1.addWidget(QLabel("편성:"))
row1.addWidget(self.in_formation)
user_layout.addLayout(row1)
self.in_symptoms = QTextEdit()
# '증상'을 자유 질의/요청까지 포함하는 단일 입력으로 정리(중복 제거)
self.in_symptoms.setPlaceholderText(
"증상/요청을 입력하세요.\n"
"예) 정위치 불가로 추정됨. 커서 시점 전후에서 문/PSD 불일치 여부와 원인 후보를 추론해줘.\n"
"예) 비상제동 개입 구간이 있다면 타임라인(사용자 표시) 기준으로 원인 후보를 분석해줘."
)
self.in_symptoms.setFixedHeight(90)
user_layout.addWidget(QLabel("증상/요청:"))
user_layout.addWidget(self.in_symptoms)
kb_row = QHBoxLayout()
self.lbl_kb = QLabel("정비지침서: 0건 로드")
btn_kb_reload = QPushButton("📚 지침서 새로고침")
btn_kb_reload.clicked.connect(self._reload_kb)
kb_row.addWidget(self.lbl_kb, stretch=1)
kb_row.addWidget(btn_kb_reload)
user_layout.addLayout(kb_row)
root.addWidget(user_box)
# 버튼들
btn_row = QHBoxLayout()
self.btn_diag_current = QPushButton("🧭 현재 포인트 진단")
self.btn_diag_current.clicked.connect(self.run_current_diagnosis)
self.btn_summarize = QPushButton("🧾 전체 요약")
self.btn_summarize.clicked.connect(self.run_overall_summary)
self.btn_infer = QPushButton("💬 증상/요청 추론")
self.btn_infer.clicked.connect(self.run_symptom_inference)
self.btn_clear = QPushButton("🧹 출력 지우기")
self.btn_clear.clicked.connect(lambda: self.txt_output.clear())
btn_row.addWidget(self.btn_diag_current)
btn_row.addWidget(self.btn_summarize)
btn_row.addWidget(self.btn_infer)
btn_row.addWidget(self.btn_clear)
btn_row.addStretch()
root.addLayout(btn_row)
# 출력
self.txt_output = QTextEdit()
self.txt_output.setReadOnly(True)
self.txt_output.setPlaceholderText("AI 응답이 여기에 표시됩니다.")
root.addWidget(QLabel("결과:"))
root.addWidget(self.txt_output, stretch=1)
self._reload_kb()
def set_file_context(self, file_path: str):
"""현재 로드 중인 파일 경로 저장(자동 입력 추출용)"""
self.file_path = file_path
self._autofill_from_file_context(force=False)
def set_data(self, data_list: List[Any]):
self.data_list = data_list or []
# 커서를 아직 안 움직여도 AI가 동작하도록 기본값(첫 레코드)을 잡아둠
self.current_index = 0 if self.data_list else None
self.current_row = self.data_list[0] if self.data_list else None
self._autofill_from_data(force=False)
self.refresh_status()
def refresh_status(self):
"""설정/클라이언트 상태를 UI에 반영"""
settings = get_settings_manager().ai_settings
current = settings.get_current_provider()
# 콤보 재구성(키가 설정된 프로바이더만)
self.provider_combo.blockSignals(True)
self.provider_combo.clear()
configured = []
for p in ["openai", "openrouter", "gemini", "xai"]:
if settings.is_provider_configured(p):
configured.append(p)
# 아무것도 없으면 전체 목록 보여주되 선택만 못 하게 안내
providers_to_show = configured if configured else ["openai", "openrouter", "gemini", "xai"]
for p in providers_to_show:
try:
name = {
"openai": "OpenAI",
"openrouter": "OpenRouter",
"gemini": "Google Gemini",
"xai": "xAI (Grok)",
}[p]
except Exception:
name = p
self.provider_combo.addItem(name, p)
if current:
idx = self.provider_combo.findData(current)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.blockSignals(False)
client = get_ai_client()
st = client.get_status()
if st.get("ready"):
self.lbl_status.setText(f"AI: {st.get('provider')} / {st.get('model')} (키: {st.get('api_key')})")
self.lbl_status.setStyleSheet("color: #00D084; font-weight: bold;")
else:
self.lbl_status.setText("AI: 미준비 (설정에서 API 키 입력 필요)")
self.lbl_status.setStyleSheet("color: #FFAB00; font-weight: bold;")
def _reload_kb(self):
kb = get_maintenance_kb()
kb.reload()
self.lbl_kb.setText(f"정비지침서: {kb.count()}건 로드")
def _autofill_from_data(self, force: bool = False):
"""데이터(레코드)에서 열번 등 자동 채움(사용자 수정 가능)"""
if not self.data_list:
return
first = self.data_list[0]
trainno = str(getattr(first, "trainno", "") or "").strip()
if trainno and (force or not (self.in_train_no.text() or "").strip()):
self.in_train_no.setText(trainno)
# 호차/편성은 데이터에 없을 수 있어 파일/문자열 기반 보완
self._autofill_from_file_context(force=force)
def _autofill_from_file_context(self, force: bool = False):
"""파일명에서 호차/편성 힌트 추출(없으면 비움)"""
if not self.file_path:
return
name = str(self.file_path).replace("\\", "/").split("/")[-1]
up = name.upper()
# 예: ..._TC2_... -> 호차=2, 편성 힌트=TC2(+ HCR/TCR 등)
car_no = ""
formation = ""
import re
m = re.search(r"\bTC(\d+)\b", up)
if m:
car_no = m.group(1)
formation = f"TC{car_no}"
# HCR/TCR 등 운영 힌트를 편성에 함께 기록
hints = []
for h in ["HCR", "TCR"]:
if h in up:
hints.append(h)
if hints:
formation = (formation + " " + " ".join(hints)).strip()
if car_no and (force or not (self.in_car_no.text() or "").strip()):
self.in_car_no.setText(car_no)
if formation and (force or not (self.in_formation.text() or "").strip()):
self.in_formation.setText(formation)
def _open_settings(self):
dlg = APIKeyDialog(self)
dlg.settings_changed.connect(self._after_settings_changed)
dlg.exec()
def _after_settings_changed(self):
# MainWindow 쪽에서도 init을 하지만, 여기서도 즉시 반영
self.refresh_status()
def _on_provider_changed(self, _idx: int):
provider = self.provider_combo.currentData()
if not provider:
return
settings_mgr = get_settings_manager()
settings_mgr.ai_settings.set_current_provider(provider)
settings_mgr.save()
# 현재 프로바이더로 클라이언트 재초기화 시도
try:
from app.ai import AIProviderType
api_key = settings_mgr.ai_settings.get_api_key(provider)
model = settings_mgr.ai_settings.get_model(provider)
if api_key:
get_ai_client().set_provider(AIProviderType(provider), api_key, model)
except Exception as e:
self._append_output(f"[경고] 프로바이더 초기화 실패: {e}")
self.refresh_status()
def _on_time_changed(self, index: int, data: Any, source_id: str):
# sync 이벤트는 반대 패널에도 오므로, 내 panel_id와 무관하게 “현재 포인트”로만 사용
# 단, 현재 탭이 어느 패널에 붙었는지를 기준으로 source_id가 같은 경우에만 갱신
if source_id != self.panel_id:
return
self.current_index = index
self.current_row = data
def _append_output(self, text: str):
self.txt_output.append(text)
self.txt_output.verticalScrollBar().setValue(self.txt_output.verticalScrollBar().maximum())
def _build_prompt_common(self) -> str:
stats = _compute_basic_stats(self.data_list)
# 그래프 맥락(선택 신호/가시 구간/타임라인)을 같이 포함
graph_ctx = self._get_graph_context()
user_ctx = self._get_user_context()
kb_ctx = self._get_kb_context()
return (
"당신은 철도 차량 MMI 로그를 분석하는 진단 엔지니어 AI입니다.\n"
"출력은 한국어로, 근거를 데이터 필드명 기준으로 간단히 제시하세요.\n\n"
f"[데이터 요약]\n{json.dumps(stats, ensure_ascii=False, indent=2)}\n"
f"\n[그래프 컨텍스트(선택 신호/표시 구간/타임라인)]\n{json.dumps(graph_ctx, ensure_ascii=False, indent=2)}\n"
f"\n[사용자 입력(운행/편성/증상)]\n{json.dumps(user_ctx, ensure_ascii=False, indent=2)}\n"
+ (f"\n[정비지침서 발췌]\n{kb_ctx}\n" if kb_ctx else "")
)
def _get_user_context(self) -> Dict[str, Any]:
return {
"train_no": (self.in_train_no.text() or "").strip(),
"car_no": (self.in_car_no.text() or "").strip(),
"formation": (self.in_formation.text() or "").strip(),
"symptoms": (self.in_symptoms.toPlainText() or "").strip(),
}
def _get_graph_context(self) -> Dict[str, Any]:
if not self.graph_view:
return {}
try:
# GraphView에 helper가 있으면 사용
if hasattr(self.graph_view, "get_ai_context"):
return self.graph_view.get_ai_context(self.current_index)
# fallback: 최소 정보만 수집
ctx: Dict[str, Any] = {}
if hasattr(self.graph_view, "axis_x"):
mn = self.graph_view.axis_x.min().toMSecsSinceEpoch()
mx = self.graph_view.axis_x.max().toMSecsSinceEpoch()
ctx["visible_range_ms"] = {"min": mn, "max": mx, "seconds": (mx - mn) / 1000.0}
if hasattr(self.graph_view, "signals_map"):
visible = []
for k, info in self.graph_view.signals_map.items():
try:
if info.get("series") and info["series"].isVisible():
visible.append({"key": k, "name": info.get("base", k)})
except Exception:
pass
ctx["visible_signals"] = visible
if hasattr(self.graph_view, "drawing_objects"):
ctx["drawing_objects"] = self._serialize_drawing_objects(self.graph_view.drawing_objects)
return ctx
except Exception:
return {}
def _serialize_drawing_objects(self, objs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out = []
if not objs:
return out
for o in objs:
if not isinstance(o, dict):
continue
t = o.get("type")
if t not in ("timeline", "text", "rect", "circle", "line"):
continue
item = dict(o)
# QColor / Qt.PenStyle 등 직렬화 불가 요소 변환
col = item.get("color")
try:
if hasattr(col, "name"):
item["color"] = col.name()
except Exception:
pass
style = item.get("style")
try:
if hasattr(style, "value"):
item["style"] = style.value
except Exception:
pass
out.append(item)
return out
def _get_kb_context(self) -> str:
kb = get_maintenance_kb()
user = self._get_user_context()
query = " ".join([user.get("symptoms", "")]).strip()
if not query:
return ""
hits = kb.search(query, top_k=3)
if not hits:
return ""
# 프롬프트 길이 제한을 위해 짧게
lines = []
for h in hits:
title = h.get("title", "untitled")
snippet = h.get("snippet", "")
lines.append(f"- {title}: {snippet}")
return "\n".join(lines)[:2500]
def _slice_window(self) -> Tuple[List[Any], int, int]:
if not self.data_list:
return [], 0, 0
if self.current_index is None:
return self.data_list, 0, len(self.data_list) - 1
half = int(self.spin_window_sec.value())
start = max(0, self.current_index - half)
end = min(len(self.data_list) - 1, self.current_index + half)
return self.data_list[start : end + 1], start, end
def _run_prompt(self, prompt: str):
# 이미 실행 중이면 차단 (QThread deleteLater로 인한 래퍼 삭제 문제 회피)
if self._busy:
QMessageBox.information(self, "AI", "이미 요청이 실행 중입니다. 완료 후 다시 시도하세요.")
return
self.btn_diag_current.setEnabled(False)
self.btn_summarize.setEnabled(False)
self.btn_infer.setEnabled(False)
self._append_output("\n---\n[AI 요청] 실행 중...")
self._busy = True
self._thread = QThread(self)
self._worker = _AIWorker(prompt)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.finished.connect(self._on_ai_finished)
self._worker.failed.connect(self._on_ai_failed)
# 정리
self._worker.finished.connect(self._thread.quit)
self._worker.failed.connect(self._thread.quit)
self._thread.finished.connect(self._on_thread_finished)
self._thread.start()
def _on_thread_finished(self):
# thread 객체가 deleteLater 등으로 사라져도 다음 실행에 영향 없도록 참조 정리
self._thread = None
self._worker = None
self._busy = False
def _on_ai_finished(self, text: str):
self._append_output(text.strip())
self.btn_diag_current.setEnabled(True)
self.btn_summarize.setEnabled(True)
self.btn_infer.setEnabled(True)
# 종료/정리는 thread.finished에서 처리
def _on_ai_failed(self, err: str):
self._append_output(f"[오류] {err}")
self.btn_diag_current.setEnabled(True)
self.btn_summarize.setEnabled(True)
self.btn_infer.setEnabled(True)
# 종료/정리는 thread.finished에서 처리
def run_overall_summary(self):
if not self.data_list:
QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.")
return
prompt = (
self._build_prompt_common()
+ "\n[요청]\n"
"- 전체 운행 흐름(속도/DTG/문/ATC/경고) 관점에서 요약\n"
"- 이상 징후(과속경고, 문 상태 불일치, TASC/DTG 이상 등) 후보를 3~7개로 제시\n"
"- 각 후보에 대해 '가능 원인/추가 확인 포인트'를 짧게 제안\n"
)
self._run_prompt(prompt)
def run_current_diagnosis(self):
if not self.data_list:
QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.")
return
window_rows, s, e = self._slice_window()
current = _safe_to_dict(self.current_row)
window_stats = _compute_basic_stats(window_rows)
prompt = (
self._build_prompt_common()
+ f"\n[커서 정보]\nindex={self.current_index}\n"
+ f"\n[커서 레코드]\n{json.dumps(current, ensure_ascii=False, indent=2)}\n"
+ f"\n[커서 주변({s}~{e}) 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n"
+ "\n[요청]\n"
"- 커서 시점의 상태를 설명하고, 주변 구간에서의 변화/이상 징후를 진단\n"
"- 문/PSD/ATC/TASC/DTG/과속경고 관련 이슈 가능성을 우선순위로 제시\n"
)
self._run_prompt(prompt)
def run_symptom_inference(self):
"""증상/요청(단일 입력) 기반 추론 실행"""
if not self.data_list:
QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.")
return
stext = (self.in_symptoms.toPlainText() or "").strip()
if not stext:
QMessageBox.information(self, "AI", "증상/요청을 입력하세요.")
return
window_rows, s, e = self._slice_window()
window_stats = _compute_basic_stats(window_rows)
current = _safe_to_dict(self.current_row)
prompt = (
self._build_prompt_common()
+ f"\n[커서 정보]\nindex={self.current_index}\n"
+ f"\n[커서 레코드]\n{json.dumps(current, ensure_ascii=False, indent=2)}\n"
+ f"\n[커서 주변({s}~{e}) 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n"
+ f"\n[사용자 증상/요청]\n{stext}\n"
+ "\n[요청]\n- 입력된 증상/요청을 기준으로 원인 후보와 근거(필드명)를 제시하고, 추가 확인 포인트/정비 접근을 제안\n"
)
self._run_prompt(prompt)
def run_range_analysis(self, start_idx: int, end_idx: int, start_ms: int, end_ms: int):
"""사용자 선택 구간 분석 요청"""
if not self.data_list:
QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.")
return
if start_idx < 0 or end_idx < 0 or start_idx >= len(self.data_list) or end_idx >= len(self.data_list):
QMessageBox.information(self, "AI", "선택 구간이 올바르지 않습니다.")
return
if start_idx > end_idx:
start_idx, end_idx = end_idx, start_idx
rows = self.data_list[start_idx:end_idx + 1]
window_stats = _compute_basic_stats(rows)
start_rec = _safe_to_dict(rows[0]) if rows else {}
end_rec = _safe_to_dict(rows[-1]) if rows else {}
prompt = (
self._build_prompt_common()
+ f"\n[선택 구간]\nindex={start_idx}~{end_idx}\n"
+ f"time_ms={start_ms}~{end_ms}\n"
+ f"\n[시작 레코드]\n{json.dumps(start_rec, ensure_ascii=False, indent=2)}\n"
+ f"\n[끝 레코드]\n{json.dumps(end_rec, ensure_ascii=False, indent=2)}\n"
+ f"\n[구간 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n"
+ "\n[요청]\n"
+ "- 선택 구간의 운행 흐름과 특이점(속도/DTG/문/ATC/경고)을 요약\n"
+ "- 이상 징후 후보와 원인 추정, 추가 확인 포인트를 제시\n"
)
self._run_prompt(prompt)

File diff suppressed because it is too large Load Diff

3488
app/ui/views/graph_view.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
# Data Grid View

View File

@ -0,0 +1,5 @@
from .ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open
__all__ = ["AIFloatingButton", "AIChatDialog", "animate_open"]

Binary file not shown.

View File

@ -0,0 +1,494 @@
"""
플로팅 AI 버튼 + 확장(애니메이션) 채팅 모달
요구사항:
- 웹의 고객응대/AI assistant처럼 우하단 플로팅 버튼
- 클릭 작은 패널에서 채팅 모달로 확장(애니메이션)
- 전환 없이 현재 화면 컨텍스트(날짜/열번/편성/호차/타임라인/표시신호/ 전후2개 ) 포함
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Optional
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QRect, QObject, QThread, Signal, QDateTime
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QWidget,
QPushButton,
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QComboBox,
QLineEdit,
QTextEdit,
QMessageBox,
QFrame,
QDialogButtonBox,
)
from app.ai import get_ai_client
from app.ai.maintenance_kb import get_maintenance_kb
from app.ui.dialogs.api_key_dialog import APIKeyDialog
def _make_json_safe(obj):
"""json.dumps에서 안전하도록 dict/list/QColor 등을 변환"""
if obj is None:
return None
if isinstance(obj, dict):
return {str(k): _make_json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [_make_json_safe(v) for v in obj]
if isinstance(obj, QDateTime):
try:
return obj.toString("yyyy-MM-dd HH:mm:ss")
except Exception:
return str(obj)
# QColor 등 name()이 있는 객체 처리
try:
if hasattr(obj, "name") and callable(obj.name):
return obj.name()
except Exception:
pass
# Enum 등 value가 있는 경우
try:
if hasattr(obj, "value"):
return getattr(obj, "value")
except Exception:
pass
return obj
class _AIWorker(QObject):
finished = Signal(str)
failed = Signal(str)
def __init__(self, prompt: str):
super().__init__()
self.prompt = prompt
def run(self):
try:
client = get_ai_client()
if not client.is_ready():
raise RuntimeError("AI가 준비되지 않았습니다. 설정에서 API 키를 입력하세요.")
resp = client.chat(self.prompt, temperature=0.2)
self.finished.emit(resp.content or "")
except Exception as e:
self.failed.emit(str(e))
class AIFloatingButton(QPushButton):
"""우하단 플로팅 버튼"""
def __init__(self, parent: QWidget):
super().__init__("AI", parent)
self.setFixedSize(56, 56)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("AI Assistant 열기")
self.setStyleSheet(
"""
QPushButton {
background-color: #0078d4;
color: white;
border: none;
border-radius: 28px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover { background-color: #1084d8; }
QPushButton:pressed { background-color: #006cc1; }
"""
)
self._is_open = False
def set_open_state(self, is_open: bool):
self._is_open = bool(is_open)
if self._is_open:
self.setText("×")
self.setToolTip("AI Assistant 닫기")
self.setStyleSheet(
"""
QPushButton {
background-color: #2a2a2a;
color: #ddd;
border: 1px solid #4a4a4a;
border-radius: 28px;
font-weight: bold;
font-size: 18px;
}
QPushButton:hover { background-color: #333; }
QPushButton:pressed { background-color: #222; }
"""
)
else:
self.setText("AI")
self.setToolTip("AI Assistant 열기")
self.setStyleSheet(
"""
QPushButton {
background-color: #0078d4;
color: white;
border: none;
border-radius: 28px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover { background-color: #1084d8; }
QPushButton:pressed { background-color: #006cc1; }
"""
)
def move_to_bottom_right(self, margin: int = 18):
p = self.parentWidget()
if not p:
return
x = p.width() - self.width() - margin
y = p.height() - self.height() - margin
self.move(max(0, x), max(0, y))
class AIChatDialog(QDialog):
"""확장되는 채팅 모달(프레임리스)"""
def __init__(self, main_window: QWidget, get_panel_ctx_callable):
super().__init__(main_window)
self._get_panel_ctx = get_panel_ctx_callable
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog)
self.setModal(False)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._busy = False
self._thread: Optional[QThread] = None
self._worker: Optional[_AIWorker] = None
self._last_payload: Optional[Dict[str, Any]] = None
self._build_ui()
self.refresh_header()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
# 실제 카드(둥근 배경)
self.card = QFrame()
self.card.setStyleSheet(
"""
QFrame {
background: #1f1f1f;
border: 1px solid #3a3a3a;
border-radius: 14px;
}
QLabel { color: #ddd; }
QLineEdit, QTextEdit {
background: #2a2a2a;
border: 1px solid #4a4a4a;
border-radius: 8px;
padding: 8px;
color: #eee;
}
QTextEdit { font-size: 12px; }
"""
)
card_layout = QVBoxLayout(self.card)
card_layout.setContentsMargins(12, 12, 12, 12)
card_layout.setSpacing(10)
# 헤더
header = QHBoxLayout()
self.lbl_title = QLabel("AI Assistant")
self.lbl_title.setStyleSheet("font-weight: bold; font-size: 14px; color: #fff;")
self.cmb_panel = QComboBox()
self.cmb_panel.addItem("Left Panel", "left")
self.cmb_panel.addItem("Right Panel", "right")
self.cmb_panel.currentIndexChanged.connect(self.refresh_header)
self.lbl_ai_status = QLabel("AI: 미준비")
self.lbl_ai_status.setStyleSheet("color: #FFAB00;")
btn_settings = QPushButton("설정")
btn_settings.setFixedHeight(30)
btn_settings.clicked.connect(self._open_settings)
btn_settings.setStyleSheet(
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} "
"QPushButton:hover{background:#333;}"
)
btn_close = QPushButton("×")
btn_close.setFixedSize(30, 30)
btn_close.clicked.connect(self.close)
btn_close.setStyleSheet(
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;font-size:16px;} "
"QPushButton:hover{background:#333;}"
)
btn_payload = QPushButton("JSON")
btn_payload.setFixedHeight(30)
btn_payload.clicked.connect(self.show_last_payload)
btn_payload.setStyleSheet(
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} "
"QPushButton:hover{background:#333;}"
)
header.addWidget(self.lbl_title)
header.addStretch()
header.addWidget(self.cmb_panel)
header.addWidget(self.lbl_ai_status)
header.addWidget(btn_payload)
header.addWidget(btn_settings)
header.addWidget(btn_close)
card_layout.addLayout(header)
# 기본정보(자동입력 + 수정가능)
info_row = QHBoxLayout()
self.in_date = QLineEdit()
self.in_date.setPlaceholderText("날짜")
self.in_trainno = QLineEdit()
self.in_trainno.setPlaceholderText("열번")
self.in_car = QLineEdit()
self.in_car.setPlaceholderText("호차")
self.in_formation = QLineEdit()
self.in_formation.setPlaceholderText("편성")
for w in [self.in_date, self.in_trainno, self.in_car, self.in_formation]:
w.setFixedHeight(32)
info_row.addWidget(QLabel("날짜"))
info_row.addWidget(self.in_date)
info_row.addWidget(QLabel("열번"))
info_row.addWidget(self.in_trainno)
info_row.addWidget(QLabel("호차"))
info_row.addWidget(self.in_car)
info_row.addWidget(QLabel("편성"))
info_row.addWidget(self.in_formation)
card_layout.addLayout(info_row)
# 추가정보(가이드/툴팁)
self.in_extra = QTextEdit()
self.in_extra.setFixedHeight(90)
self.in_extra.setPlaceholderText(
"추가정보 작성 가이드(필요한 것만 기입)\n"
"- 발생역: \n"
"- 발생시간: \n"
"- 발생증상: \n"
"- 발생장치: \n"
"- 참고: (정상/고장 비교, 다른 열번 같은 역 비교, 특정 조건 구간 등)\n"
)
self.in_extra.setToolTip(
"예시:\n"
"발생역=서면, 발생시간=18:09:12, 발생증상=정위치 불가 추정,\n"
"발생장치=ATO/TASC, 참고=타임라인으로 표시한 구간 중심으로 원인 후보 추론"
)
card_layout.addWidget(QLabel("추가정보"))
card_layout.addWidget(self.in_extra)
# 채팅 히스토리
self.txt_chat = QTextEdit()
self.txt_chat.setReadOnly(True)
self.txt_chat.setPlaceholderText("대화 내용이 여기에 표시됩니다.")
self.txt_chat.setMinimumHeight(240)
card_layout.addWidget(self.txt_chat, stretch=1)
# 입력 + 전송
bottom = QHBoxLayout()
self.in_msg = QLineEdit()
self.in_msg.setPlaceholderText("요청을 입력하세요. (예: 유사기록 검색 / 특정 조건 구간 분석 / 역명 기준 비교 등)")
self.in_msg.setFixedHeight(34)
btn_send = QPushButton("전송")
btn_send.setFixedHeight(34)
btn_send.clicked.connect(self.send_message)
btn_send.setStyleSheet(
"QPushButton{background:#0078d4;color:white;border:none;border-radius:8px;padding:6px 14px;font-weight:bold;} "
"QPushButton:hover{background:#1084d8;} "
"QPushButton:pressed{background:#006cc1;}"
)
bottom.addWidget(self.in_msg, stretch=1)
bottom.addWidget(btn_send)
card_layout.addLayout(bottom)
root.addWidget(self.card)
def _open_settings(self):
dlg = APIKeyDialog(self)
dlg.settings_changed.connect(self.refresh_header)
dlg.exec()
def refresh_header(self):
# AI 상태
st = get_ai_client().get_status()
if st.get("ready"):
self.lbl_ai_status.setText(f"AI: {st.get('provider')} / {st.get('model')}")
self.lbl_ai_status.setStyleSheet("color: #00D084;")
else:
self.lbl_ai_status.setText("AI: 미준비")
self.lbl_ai_status.setStyleSheet("color: #FFAB00;")
# 패널 컨텍스트에서 기본정보 자동 채움(비어있을 때만)
ctx = self._get_panel_ctx(self.cmb_panel.currentData(), None)
date = (ctx.get("data_date") or "") if isinstance(ctx, dict) else ""
record = (ctx.get("record") or {}) if isinstance(ctx, dict) else {}
trainno = str(record.get("trainno") or "")
if date and not self.in_date.text().strip():
self.in_date.setText(date)
if trainno and not self.in_trainno.text().strip():
self.in_trainno.setText(trainno)
def _append(self, who: str, text: str):
self.txt_chat.append(f"[{who}]\n{text}\n")
sb = self.txt_chat.verticalScrollBar()
sb.setValue(sb.maximum())
def _build_prompt(self, user_text: str) -> str:
panel_id = self.cmb_panel.currentData()
ctx = self._get_panel_ctx(panel_id, user_text) or {}
basic = {
"date": self.in_date.text().strip(),
"trainno": self.in_trainno.text().strip(),
"car_no": self.in_car.text().strip(),
"formation": self.in_formation.text().strip(),
}
extra = (self.in_extra.toPlainText() or "").strip()
# 정비지침서 발췌(유사기록/조건분석 요청에도 도움)
kb = get_maintenance_kb()
kb_hits = kb.search(" ".join([user_text, extra]), top_k=3)
payload = {
"basic": basic,
"extra": extra,
"panel_id": panel_id,
"ctx": ctx,
"kb_hits": kb_hits,
"user_text": user_text,
}
safe_payload = _make_json_safe(payload)
self._last_payload = safe_payload
self._auto_save_payload(safe_payload)
return (
"당신은 철도 차량 MMI 로그를 분석하는 AI 어시스턴트입니다.\n"
"사용자 요청에 대해, 가능한 경우 데이터 근거(필드명/타임라인/역 전후2개)를 들어 설명하세요.\n"
"불확실하면 추가로 필요한 데이터/확인 포인트를 질문 형태로 제시하세요.\n\n"
f"[기본정보]\n{json.dumps(_make_json_safe(basic), ensure_ascii=False, indent=2)}\n"
f"\n[추가정보]\n{extra}\n"
f"\n[현재 화면 컨텍스트]\n{json.dumps(_make_json_safe(ctx), ensure_ascii=False, indent=2)}\n"
+ (f"\n[정비지침서/지식 발췌]\n{json.dumps(_make_json_safe(kb_hits), ensure_ascii=False, indent=2)}\n" if kb_hits else "")
+ f"\n[사용자 요청]\n{user_text}\n"
)
def _auto_save_payload(self, payload: Dict[str, Any]):
try:
out_dir = Path.home() / ".mmi_analyzer" / "ai_debug"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "last_payload.json"
path.write_text(json.dumps(_make_json_safe(payload), ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def show_last_payload(self):
if not self._last_payload:
QMessageBox.information(self, "AI", "아직 전송된 payload가 없습니다.")
return
dlg = QDialog(self)
dlg.setWindowTitle("AI Payload (JSON)")
dlg.resize(720, 520)
lay = QVBoxLayout(dlg)
txt = QTextEdit()
txt.setReadOnly(True)
txt.setText(json.dumps(_make_json_safe(self._last_payload), ensure_ascii=False, indent=2))
lay.addWidget(txt, stretch=1)
btns = QDialogButtonBox(QDialogButtonBox.Close)
btns.rejected.connect(dlg.reject)
btns.accepted.connect(dlg.accept)
lay.addWidget(btns)
dlg.exec()
def send_message(self):
text = (self.in_msg.text() or "").strip()
if not text:
return
if self._busy:
QMessageBox.information(self, "AI", "이미 요청이 실행 중입니다. 완료 후 다시 시도하세요.")
return
self.in_msg.clear()
self._append("사용자", text)
prompt = self._build_prompt(text)
self._run(prompt)
def _run(self, prompt: str):
self._busy = True
self._append("AI", "(응답 생성 중...)")
self._thread = QThread(self)
self._worker = _AIWorker(prompt)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.finished.connect(self._on_done)
self._worker.failed.connect(self._on_fail)
self._worker.finished.connect(self._thread.quit)
self._worker.failed.connect(self._thread.quit)
self._thread.finished.connect(self._on_thread_finished)
self._thread.start()
def _on_done(self, text: str):
# 마지막 "(응답 생성 중...)" 줄은 단순히 구분만 하고 새 답변을 추가
self._append("AI", text.strip())
def _on_fail(self, err: str):
self._append("AI", f"[오류] {err}")
def _on_thread_finished(self):
self._thread = None
self._worker = None
self._busy = False
def animate_open(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 220):
dialog.setGeometry(start_rect)
dialog.setWindowOpacity(0.0)
dialog.show()
anim = QPropertyAnimation(dialog, b"geometry", dialog)
anim.setDuration(duration_ms)
anim.setStartValue(start_rect)
anim.setEndValue(end_rect)
anim.setEasingCurve(QEasingCurve.OutCubic)
anim.start()
dialog._open_anim = anim # GC 방지
fade = QPropertyAnimation(dialog, b"windowOpacity", dialog)
fade.setDuration(max(120, duration_ms))
fade.setStartValue(0.0)
fade.setEndValue(1.0)
fade.setEasingCurve(QEasingCurve.OutCubic)
fade.start()
dialog._open_fade = fade
def animate_close(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 180):
anim = QPropertyAnimation(dialog, b"geometry", dialog)
anim.setDuration(duration_ms)
anim.setStartValue(start_rect)
anim.setEndValue(end_rect)
anim.setEasingCurve(QEasingCurve.InCubic)
anim.finished.connect(dialog.close)
anim.start()
dialog._close_anim = anim
fade = QPropertyAnimation(dialog, b"windowOpacity", dialog)
fade.setDuration(max(100, duration_ms))
fade.setStartValue(dialog.windowOpacity())
fade.setEndValue(0.0)
fade.setEasingCurve(QEasingCurve.InCubic)
fade.start()
dialog._close_fade = fade

View File

@ -0,0 +1,13 @@
using System;
namespace SL200_RTLogViewer
{
// Token: 0x02000006 RID: 6
public enum DoorDirection
{
// Token: 0x0400001E RID: 30
DE,
// Token: 0x0400001F RID: 31
DW
}
}

24
c_Sharp_Code/dpi.cs Normal file
View File

@ -0,0 +1,24 @@
using System;
using System.Runtime.InteropServices;
namespace SL200_RTLogViewer
{
// Token: 0x02000005 RID: 5
public static class Dpi
{
// Token: 0x06000021 RID: 33
[DllImport("Shcore.dll")]
public static extern int SetProcessDpiAwareness(int processDpiAwareness);
// Token: 0x02000055 RID: 85
public enum DpiAwareness
{
// Token: 0x0400041C RID: 1052
None,
// Token: 0x0400041D RID: 1053
SystemAware,
// Token: 0x0400041E RID: 1054
PerMonitorAware
}
}
}

301
c_Sharp_Code/form1.cs Normal file
View File

@ -0,0 +1,301 @@
using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
using Microsoft.Win32;
using SL200_RealtimeLogViewer.RealtimeLog;
using SL200_RTLogViewer.Properties;
using SL200_RTLogViewer.subForm;
namespace SL200_RTLogViewer
{
// Token: 0x02000003 RID: 3
public class Form1 : Form
{
// Token: 0x06000014 RID: 20 RVA: 0x00003A80 File Offset: 0x00001C80
public Form1()
{
this.InitializeComponent();
}
// Token: 0x06000015 RID: 21 RVA: 0x00003AB0 File Offset: 0x00001CB0
private void button1_Click_1(object sender, EventArgs e)
{
try
{
RealtimelogForm realtimelogForm = new RealtimelogForm();
Form1.logOn = false;
realtimelogForm.FormBorderStyle = FormBorderStyle.None;
realtimelogForm.TopLevel = false;
realtimelogForm.TopMost = true;
this.mainpanel.Controls.Add(realtimelogForm);
realtimelogForm.Dock = DockStyle.Fill;
this.ShowChildForm(realtimelogForm);
}
catch (Exception ex)
{
MessageBox.Show("실시간 뷰어 기능을 사용하려면 프로그램 폴더내에 \n WinPcap을 설치해야 합니다. ", " 알림 ");
}
}
// Token: 0x06000016 RID: 22 RVA: 0x00003B30 File Offset: 0x00001D30
private void button2_Click(object sender, EventArgs e)
{
Form1.logOn = true;
this.savedlogForm.FormBorderStyle = FormBorderStyle.None;
this.savedlogForm.TopLevel = false;
this.savedlogForm.TopMost = true;
this.mainpanel.Controls.Add(this.savedlogForm);
this.savedlogForm.Dock = DockStyle.Fill;
this.ShowChildForm(this.savedlogForm);
}
// Token: 0x06000017 RID: 23 RVA: 0x00003B9C File Offset: 0x00001D9C
private void ShowChildForm(Form childForm)
{
bool flag = childForm.Equals(this._activeForm);
if (!flag)
{
bool flag2 = this._activeForm != null;
if (flag2)
{
this._activeForm.Hide();
}
this._activeForm = childForm;
this._activeForm.Show();
}
}
// Token: 0x06000018 RID: 24 RVA: 0x00003BE8 File Offset: 0x00001DE8
private void Form1_FormClosed(object sender, FormClosedEventArgs e)
{
this.savedlogForm.Close();
base.Dispose();
}
// Token: 0x06000019 RID: 25 RVA: 0x00003BFE File Offset: 0x00001DFE
private void Form1_Load(object sender, EventArgs e)
{
TrackInfo.SetInfo();
this.button2_Click(sender, e);
}
// Token: 0x0600001A RID: 26 RVA: 0x00003C10 File Offset: 0x00001E10
public void ReSetFontSize(Control.ControlCollection ctrls)
{
float num = 1f;
using (RegistryKey registryKey = Registry.CurrentUser.OpenSubKey("Control Panel\\Desktop\\WindowMetrics"))
{
bool flag = registryKey != null;
if (flag)
{
object value = registryKey.GetValue("AppliedDPI");
bool flag2 = value != null;
if (flag2)
{
int num2 = (int)value;
num = (float)num2 / 96f;
}
}
}
bool flag3 = (double)num == 1.25;
if (flag3)
{
this.SetAllControlsFontSize(base.Controls, 1.27f);
}
}
// Token: 0x0600001B RID: 27 RVA: 0x00003CB0 File Offset: 0x00001EB0
private void SetAllControlsFontSize(Control.ControlCollection ctrls, float scale)
{
foreach (object obj in ctrls)
{
Control control = (Control)obj;
bool flag = control.Controls != null;
if (flag)
{
this.SetAllControlsFontSize(control.Controls, scale);
}
bool flag2 = control != null;
if (flag2)
{
Font font = control.Font;
control.Font = new Font(font.Name, font.Size / scale);
}
}
}
// Token: 0x0600001C RID: 28 RVA: 0x00003D50 File Offset: 0x00001F50
private void button3_Click(object sender, EventArgs e)
{
this.mmiMsgForm.FormBorderStyle = FormBorderStyle.None;
this.mmiMsgForm.TopLevel = false;
this.mmiMsgForm.TopMost = true;
this.mainpanel.Controls.Add(this.mmiMsgForm);
this.mmiMsgForm.Dock = DockStyle.Fill;
this.ShowChildForm(this.mmiMsgForm);
}
// Token: 0x0600001D RID: 29 RVA: 0x00003DB8 File Offset: 0x00001FB8
private void button4_Click(object sender, EventArgs e)
{
TermsDefinitionForm termsDefinitionForm = new TermsDefinitionForm();
termsDefinitionForm.Show();
}
// Token: 0x0600001E RID: 30 RVA: 0x00003DD4 File Offset: 0x00001FD4
protected override void Dispose(bool disposing)
{
bool flag = disposing && this.components != null;
if (flag)
{
this.components.Dispose();
}
base.Dispose(disposing);
}
// Token: 0x0600001F RID: 31 RVA: 0x00003E0C File Offset: 0x0000200C
private void InitializeComponent()
{
ComponentResourceManager componentResourceManager = new ComponentResourceManager(typeof(Form1));
this.panel1 = new Panel();
this.button4 = new Button();
this.button3 = new Button();
this.button1 = new Button();
this.button2 = new Button();
this.mainpanel = new Panel();
this.panel1.SuspendLayout();
base.SuspendLayout();
this.panel1.AutoSize = true;
this.panel1.Controls.Add(this.button4);
this.panel1.Controls.Add(this.button3);
this.panel1.Controls.Add(this.button1);
this.panel1.Controls.Add(this.button2);
this.panel1.Dock = DockStyle.Top;
this.panel1.Location = new Point(0, 0);
this.panel1.Margin = new Padding(3, 4, 3, 4);
this.panel1.Name = "panel1";
this.panel1.Size = new Size(1530, 70);
this.panel1.TabIndex = 2;
this.button4.AutoSize = true;
this.button4.BackColor = Color.Transparent;
this.button4.Font = new Font("나눔고딕 ExtraBold", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
this.button4.Image = (Image)componentResourceManager.GetObject("button4.Image");
this.button4.ImageAlign = ContentAlignment.MiddleLeft;
this.button4.Location = new Point(591, 4);
this.button4.Margin = new Padding(3, 4, 3, 4);
this.button4.Name = "button4";
this.button4.Size = new Size(168, 61);
this.button4.TabIndex = 4;
this.button4.Text = "약어 설명";
this.button4.TextAlign = ContentAlignment.MiddleRight;
this.button4.UseVisualStyleBackColor = false;
this.button4.Click += this.button4_Click;
this.button3.AutoSize = true;
this.button3.BackColor = Color.Transparent;
this.button3.Font = new Font("나눔고딕 ExtraBold", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
this.button3.Image = (Image)componentResourceManager.GetObject("button3.Image");
this.button3.ImageAlign = ContentAlignment.MiddleLeft;
this.button3.Location = new Point(396, 4);
this.button3.Margin = new Padding(3, 4, 3, 4);
this.button3.Name = "button3";
this.button3.Size = new Size(192, 61);
this.button3.TabIndex = 3;
this.button3.Text = "메세지 뷰어";
this.button3.TextAlign = ContentAlignment.MiddleRight;
this.button3.UseVisualStyleBackColor = false;
this.button3.Click += this.button3_Click;
this.button1.AutoSize = true;
this.button1.BackColor = Color.Transparent;
this.button1.Font = new Font("맑은 고딕", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
this.button1.Image = Resources.realtime;
this.button1.ImageAlign = ContentAlignment.MiddleLeft;
this.button1.Location = new Point(3, 4);
this.button1.Margin = new Padding(3, 4, 3, 4);
this.button1.Name = "button1";
this.button1.Size = new Size(195, 62);
this.button1.TabIndex = 1;
this.button1.Text = "실시간 뷰어";
this.button1.TextAlign = ContentAlignment.MiddleRight;
this.button1.UseVisualStyleBackColor = false;
this.button1.Click += this.button1_Click_1;
this.button2.AutoSize = true;
this.button2.BackColor = Color.Transparent;
this.button2.Font = new Font("맑은 고딕", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
this.button2.Image = Resources.saved;
this.button2.ImageAlign = ContentAlignment.MiddleLeft;
this.button2.Location = new Point(201, 4);
this.button2.Margin = new Padding(3, 4, 3, 4);
this.button2.Name = "button2";
this.button2.Size = new Size(192, 61);
this.button2.TabIndex = 2;
this.button2.Text = "LOG 뷰어";
this.button2.TextAlign = ContentAlignment.MiddleRight;
this.button2.UseVisualStyleBackColor = false;
this.button2.Click += this.button2_Click;
this.mainpanel.AutoSize = true;
this.mainpanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
this.mainpanel.Dock = DockStyle.Fill;
this.mainpanel.Location = new Point(0, 70);
this.mainpanel.Margin = new Padding(3, 4, 3, 4);
this.mainpanel.Name = "mainpanel";
this.mainpanel.Size = new Size(1530, 852);
this.mainpanel.TabIndex = 0;
base.AutoScaleMode = AutoScaleMode.None;
this.AutoScroll = true;
base.AutoSizeMode = AutoSizeMode.GrowAndShrink;
base.ClientSize = new Size(1530, 922);
base.Controls.Add(this.mainpanel);
base.Controls.Add(this.panel1);
base.FormBorderStyle = FormBorderStyle.Fixed3D;
base.Icon = (Icon)componentResourceManager.GetObject("$this.Icon");
base.Margin = new Padding(3, 4, 3, 4);
base.MaximizeBox = false;
this.MaximumSize = new Size(1550, 965);
this.MinimumSize = new Size(1550, 965);
base.Name = "Form1";
base.SizeGripStyle = SizeGripStyle.Hide;
base.StartPosition = FormStartPosition.CenterScreen;
this.Text = "SL200_LogViewer Ver 1.0.1_25.10";
base.FormClosed += this.Form1_FormClosed;
base.Load += this.Form1_Load;
this.panel1.ResumeLayout(false);
this.panel1.PerformLayout();
base.ResumeLayout(false);
base.PerformLayout();
}
// Token: 0x04000012 RID: 18
private SavedlogForm savedlogForm = new SavedlogForm();
// Token: 0x04000013 RID: 19
private mmiMsgForm mmiMsgForm = new mmiMsgForm();
// Token: 0x04000014 RID: 20
private Form _activeForm;
// Token: 0x04000015 RID: 21
public static bool logOn;
// Token: 0x04000016 RID: 22
private IContainer components = null;
// Token: 0x04000017 RID: 23
private Button button1;
// Token: 0x04000018 RID: 24
private Panel panel1;
// Token: 0x04000019 RID: 25
private Button button2;
// Token: 0x0400001A RID: 26
private Panel mainpanel;
// Token: 0x0400001B RID: 27
private Button button3;
// Token: 0x0400001C RID: 28
private Button button4;
}
}

View File

@ -0,0 +1,39 @@
using System;
namespace SL200_RTLogViewer.lib
{
// Token: 0x02000021 RID: 33
public class ACPU200
{
// Token: 0x06000139 RID: 313 RVA: 0x000240A0 File Offset: 0x000222A0
public static ACPU200Class SetData(byte[] data)
{
return new ACPU200Class
{
seq = Convert.ToInt32(data[0]),
ver = Convert.ToInt32((int)(data[1] & 252)),
boardStat = Convert.ToInt32((int)(data[1] & 3)),
source = Convert.ToInt32((int)(data[2] & 240)),
destination = Convert.ToInt32((int)(data[2] & 15)),
pwm = Convert.ToInt32(data[3]),
dr = ((data[4] & 128) > 0),
br = ((data[4] & 64) > 0),
cs = ((data[4] & 32) > 0),
ador = ((data[4] & 16) > 0),
adol = ((data[4] & 8) > 0),
adc = ((data[4] & 4) > 0),
recovery = ((data[4] & 2) > 0),
nomal = ((data[4] & 1) > 0),
aa = ((data[5] & 128) > 0),
am = ((data[5] & 64) > 0),
mm = ((data[5] & 32) > 0),
ats = ((data[5] & 16) > 0),
pgx = ((data[5] & 8) > 0),
pg32 = ((data[5] & 4) > 0),
pg2 = ((data[5] & 2) > 0),
pg1 = ((data[5] & 1) > 0),
distance = Convert.ToDouble((int)data[6] << 8 | (int)data[7])
};
}
}
}

View File

@ -0,0 +1,77 @@
using System;
namespace SL200_RTLogViewer.lib
{
// Token: 0x02000020 RID: 32
public class ACPU200Class
{
// Token: 0x04000213 RID: 531
public int seq;
// Token: 0x04000214 RID: 532
public int ver;
// Token: 0x04000215 RID: 533
public int boardStat;
// Token: 0x04000216 RID: 534
public int source;
// Token: 0x04000217 RID: 535
public int destination;
// Token: 0x04000218 RID: 536
public int pwm;
// Token: 0x04000219 RID: 537
public bool dr;
// Token: 0x0400021A RID: 538
public bool br;
// Token: 0x0400021B RID: 539
public bool cs;
// Token: 0x0400021C RID: 540
public bool adol;
// Token: 0x0400021D RID: 541
public bool ador;
// Token: 0x0400021E RID: 542
public bool adc;
// Token: 0x0400021F RID: 543
public bool recovery;
// Token: 0x04000220 RID: 544
public bool nomal;
// Token: 0x04000221 RID: 545
public bool aa;
// Token: 0x04000222 RID: 546
public bool am;
// Token: 0x04000223 RID: 547
public bool mm;
// Token: 0x04000224 RID: 548
public bool ats;
// Token: 0x04000225 RID: 549
public bool pgx;
// Token: 0x04000226 RID: 550
public bool pg32;
// Token: 0x04000227 RID: 551
public bool pg2;
// Token: 0x04000228 RID: 552
public bool pg1;
// Token: 0x04000229 RID: 553
public double distance;
}
}

View File

@ -0,0 +1,28 @@
using System;
namespace SL200_RTLogViewer.lib
{
// Token: 0x02000023 RID: 35
public class ACPU201
{
// Token: 0x0600013C RID: 316 RVA: 0x00024274 File Offset: 0x00022474
public static ACPU201Class SetData(byte[] data)
{
return new ACPU201Class
{
seq = Convert.ToInt32(data[0]),
atolimit = Convert.ToInt32(data[1]),
ebreq = ((data[2] & 128) > 0),
stationvalid = ((data[2] & 32) > 0),
dcwvalid = ((data[2] & 16) > 0),
dcw = ((data[2] & 8) > 0),
wrongdoor = ((data[2] & 4) > 0),
nextStationDr = ((data[2] & 2) > 0),
prestation = Convert.ToInt32(data[3]),
nextstation = Convert.ToInt32(data[4]),
deststation = Convert.ToInt32(data[5]),
trainnumber = Convert.ToString((int)data[6] << 8 | (int)data[7], 16)
};
}
}
}

View File

@ -0,0 +1,44 @@
using System;
namespace SL200_RTLogViewer.lib
{
// Token: 0x02000022 RID: 34
public class ACPU201Class
{
// Token: 0x0400022A RID: 554
public int seq;
// Token: 0x0400022B RID: 555
public int atolimit;
// Token: 0x0400022C RID: 556
public bool ebreq;
// Token: 0x0400022D RID: 557
public bool stationvalid;
// Token: 0x0400022E RID: 558
public bool dcwvalid;
// Token: 0x0400022F RID: 559
public bool dcw;
// Token: 0x04000230 RID: 560
public bool wrongdoor;
// Token: 0x04000231 RID: 561
public bool nextStationDr;
// Token: 0x04000232 RID: 562
public int prestation;
// Token: 0x04000233 RID: 563
public int nextstation;
// Token: 0x04000234 RID: 564
public int deststation;
// Token: 0x04000235 RID: 565
public string trainnumber;
}
}

View File

@ -0,0 +1,51 @@
using System;
namespace SL200_RTLogViewer.lib
{
// Token: 0x02000025 RID: 37
public class ACPU202
{
// Token: 0x0600013F RID: 319 RVA: 0x00024370 File Offset: 0x00022570
public static ACPU202Class SetData(byte[] data)
{
ACPU202Class acpu202Class = new ACPU202Class();
acpu202Class.seq = Convert.ToInt32(data[0]);
acpu202Class.sel = ((data[1] & 64) > 0);
acpu202Class.kur = ((data[1] & 32) > 0);
acpu202Class.osc = ((data[1] & 16) > 0);
acpu202Class.twctxe = ((data[1] & 8) > 0);
acpu202Class.inching = ((data[1] & 4) > 0);
acpu202Class.tasc = ((data[1] & 2) > 0);
acpu202Class.trainberth = ((data[1] & 1) > 0);
acpu202Class.rlyfbe = ((data[2] & 128) > 0);
acpu202Class.drcle = ((data[2] & 64) > 0);
acpu202Class.drope = ((data[2] & 32) > 0);
acpu202Class.stncodee = ((data[2] & 16) > 0);
acpu202Class.majorover = ((data[2] & 8) > 0);
acpu202Class.minorover = ((data[2] & 4) > 0);
acpu202Class.majorbelow = ((data[2] & 2) > 0);
acpu202Class.minorbelow = ((data[2] & 1) > 0);
acpu202Class.atcdxl = ((data[3] & 128) > 0);
acpu202Class.atcar = ((data[3] & 64) > 0);
acpu202Class.prebr = ((data[3] & 128) > 0);
acpu202Class.limitdr = ((data[3] & 64) > 0);
acpu202Class.tascdb = ((data[3] & 32) > 0);
acpu202Class.pg32miss = ((data[3] & 16) > 0);
acpu202Class.pg2miss = ((data[3] & 8) > 0);
acpu202Class.pg1miss = ((data[3] & 4) > 0);
acpu202Class.atoante = ((data[3] & 2) > 0);
acpu202Class.atostope = ((data[3] & 1) > 0);
bool tasc = acpu202Class.tasc;
if (tasc)
{
acpu202Class.dtg = Convert.ToDouble((int)data[4] << 8 | (int)data[5]) / 100.0;
}
else
{
acpu202Class.dtg = Convert.ToDouble((int)data[4] << 8 | (int)data[5]) / 10.0;
}
acpu202Class.liveoscf = Convert.ToDouble((int)(data[6] ^ 99) << 8 | (int)(data[7] ^ 100)) / 10.0;
return acpu202Class;
}
}
}

Some files were not shown because too many files have changed in this diff Show More