diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de8273c..0c74e2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,13 @@ jobs: - name: Instalar dependências run: uv sync --locked + # Bibliotecas de sistema que o Qt precisa mesmo sem tela (plataforma offscreen) + - name: Instalar bibliotecas do Qt + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + + - name: Rodar testes + run: uv run --locked pytest -q + - name: Gerar executável run: | uv run --locked --with pyinstaller pyinstaller \ diff --git a/.gitignore b/.gitignore index 00f2d38..e2dbd4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ __pycache__/ *.pyc .venv/ +build/ +dist/ +*.spec diff --git a/analysis.py b/analysis.py new file mode 100644 index 0000000..69da3d9 --- /dev/null +++ b/analysis.py @@ -0,0 +1,667 @@ +""" +Análises sobre o tráfego e os logs, inspiradas nas métricas do artigo do +Memphis (Ruaro et al., DAES 2019, Seções 3.4, 5 e 6): + + - aplicação de cada pacote (filtro por aplicação) + - latência de cada mensagem: da injeção na NoC até a entrega no destino + - vazão em Mbps e taxa de injeção (% da capacidade do enlace) + - linha do tempo da admissão de cada aplicação + +Não depende de Qt, para poder ser testado sem interface. +""" + +import os +import re +from collections import Counter, defaultdict +from dataclasses import dataclass, field + +from util.MPSoCConfig import MPSoCConfig + +# ID usado para "tarefa" do kernel/injetor nos campos de tarefa do traffic_router.txt +KERNEL_TASK_ID = 0xFFFF + +LOCAL_PORTS = (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1) + +# Lado de onde o pacote chega em cada porta de entrada: (dx, dy) do vizinho +_PORT_SIDE = { + MPSoCConfig.EAST0: (1, 0), MPSoCConfig.EAST1: (1, 0), + MPSoCConfig.WEST0: (-1, 0), MPSoCConfig.WEST1: (-1, 0), + MPSoCConfig.NORTH0: (0, 1), MPSoCConfig.NORTH1: (0, 1), + MPSoCConfig.SOUTH0: (0, -1), MPSoCConfig.SOUTH1: (0, -1), +} + + +# ========================================== +# APLICAÇÕES +# ========================================== +def known_app_ids(mpsoc_config): + """IDs de aplicação conhecidos (platform.cfg); vazio = aceitar qualquer um.""" + ids = set(mpsoc_config.app_name_hash) + ids.update(task_id >> 8 for task_id in mpsoc_config.task_name_hash) + return ids + + +def app_label(mpsoc_config, app_id): + name = mpsoc_config.app_name_hash.get(app_id) + return f"{app_id} - {name}" if name else f"App {app_id}" + + +def task_app(task_id, known_apps=None): + """Aplicação dona da tarefa, ou None para campo vazio/kernel/ID desconhecido.""" + if task_id is None or task_id < 0 or task_id >= KERNEL_TASK_ID: + return None + app = task_id >> 8 + if known_apps and app not in known_apps: + return None + return app + + +def packet_apps(packet, known_apps=None): + """Aplicações envolvidas no pacote (pela tarefa de origem e/ou de destino).""" + apps = {task_app(packet.task_source, known_apps), task_app(packet.task_target, known_apps)} + apps.discard(None) + return apps + + +# ========================================== +# GEOMETRIA +# ========================================== +def xy(mpsoc_config, address): + xy_address = mpsoc_config.ham_to_xy_addr(address) + return xy_address >> 8, xy_address & 0xFF + + +def hops(mpsoc_config, source, target): + (sx, sy), (tx, ty) = xy(mpsoc_config, source), xy(mpsoc_config, target) + return abs(sx - tx) + abs(sy - ty) + + +def is_peripheral_input(mpsoc_config, router, port): + """Porta de borda sem roteador vizinho: o pacote veio de um periférico.""" + side = _PORT_SIDE.get(port) + if side is None: + return False + x, y = xy(mpsoc_config, router) + nx, ny = x + side[0], y + side[1] + return not (0 <= nx < mpsoc_config.mpsoc_x and 0 <= ny < mpsoc_config.mpsoc_y) + + +def _came_from(mpsoc_config, source, target, port): + """Com roteamento XY, o pacote de source chega em target por esta porta?""" + side = _PORT_SIDE.get(port) + if side is None: + return source == target + (sx, sy), (tx, ty) = xy(mpsoc_config, source), xy(mpsoc_config, target) + if sy == ty: # mesma linha: chega pelo lado de X + return side[1] == 0 and (sx - tx) * side[0] > 0 + return side[0] == 0 and (sy - ty) * side[1] > 0 + + +def median(values): + """Mediana (robusta às poucas latências distorcidas por registros faltando no trace).""" + ordered = sorted(values) + if not ordered: + return 0 + middle = len(ordered) // 2 + return ordered[middle] if len(ordered) % 2 else (ordered[middle - 1] + ordered[middle]) / 2 + + +def link_capacity_mbps(mpsoc_config): + """Um flit por ciclo: flit_size bits a cada clock_period ns.""" + if not mpsoc_config.clock_period_in_ns: + return 0.0 + return mpsoc_config.flit_size * 1000.0 / mpsoc_config.clock_period_in_ns + + +def mbps(mpsoc_config, flits, cycles): + """Vazão média de `flits` transmitidos em `cycles` ciclos.""" + if cycles <= 0 or not mpsoc_config.clock_period_in_ns: + return 0.0 + return flits * mpsoc_config.flit_size * 1000.0 / (cycles * mpsoc_config.clock_period_in_ns) + + +def neighbor(mpsoc_config, address, port): + """Roteador vizinho do lado da porta de entrada (None na borda ou porta local).""" + side = _PORT_SIDE.get(port) + if side is None: + return None + x, y = xy(mpsoc_config, address) + nx, ny = x + side[0], y + side[1] + if 0 <= nx < mpsoc_config.mpsoc_x and 0 <= ny < mpsoc_config.mpsoc_y: + return mpsoc_config.xy_to_ham_addr((nx << 8) | ny) + return None + + +def next_hop(mpsoc_config, address, target): + """Próximo roteador pelo roteamento XY (primeiro X, depois Y); None se já chegou.""" + (x, y), (tx, ty) = xy(mpsoc_config, address), xy(mpsoc_config, target) + if x != tx: + x += 1 if tx > x else -1 + elif y != ty: + y += 1 if ty > y else -1 + else: + return None + return mpsoc_config.xy_to_ham_addr((x << 8) | y) + + +def xy_consistent(mpsoc_config, address, port, target): + """ + Um pacote que entrou em `address` pela porta `port` indo para `target` + está de acordo com o roteamento XY? (Andando em X, o destino tem que + estar à frente em X; andando em Y, já tem que estar na coluna certa.) + """ + side = _PORT_SIDE.get(port) + if side is None: + return True + (x, y), (tx, ty) = xy(mpsoc_config, address), xy(mpsoc_config, target) + dx, dy = -side[0], -side[1] # sentido do movimento + if dy == 0: + return (tx - x) * dx >= 0 + return tx == x and (ty - y) * dy >= 0 + + +# ========================================== +# RASTREAMENTO DOS PACOTES (LATÊNCIA E ROTEAMENTO) +# ========================================== +@dataclass +class Message: + source: int # roteador de origem + target: int + service: int + flits: int # payload + header + injected: int # tick em que o header entrou na NoC + delivered: int = -1 # tick em que o último flit saiu do roteador de destino + task_source: int = -1 + task_target: int = -1 + from_peripheral: bool = False + apps: frozenset = frozenset() + hops: int = 0 + # Último roteador em que o pacote foi visto (para achar pacotes parados) + last_router: int = -1 + last_time: int = -1 + prev_router: int = -1 # roteador anterior ao último (-1 = ainda na origem) + hops_seen: int = 0 + # Registros do caminho: (roteador, porta de entrada, tick), a partir da injeção + path: list = field(default_factory=list) + + @property + def latency(self): + return self.delivered - self.injected if self.delivered >= 0 else -1 + + +@dataclass +class RoutingViolation: + """Registro em que o pacote entrou por uma porta incompatível com o XY.""" + time: int + router: int + port: int + target: int + service: int + task_source: int = -1 + task_target: int = -1 + message: "Message" = None # pacote ao qual o registro pertence, se identificado + + +class TrafficTracker: + """ + Acompanha cada pacote do traffic_router.txt pela NoC. + + - Injeção: entrada pela porta local ou por um periférico na borda. + - Cada registro seguinte (um por roteador do caminho) é associado ao + pacote em trânsito com o mesmo serviço e tamanho visto mais + recentemente no roteador vizinho do lado da porta de entrada. + - Chegada: registro em que roteador == destino. Dá a latência. + - Todo registro intermediário é conferido contra o roteamento XY. + + O traffic_router.txt não tem ID de pacote, e os campos de tarefa podem + mudar entre os hops de um mesmo pacote; por isso o casamento usa só + serviço, tamanho, o roteador vizinho e o tempo. Quando o trace omite + registros, a chegada é casada com a injeção mais recente cuja origem + seja compatível com a porta de entrada; injeções mais antigas do mesmo + fluxo que ficaram para trás são contadas como sem entrega registrada. + """ + + MAX_VIOLATIONS = 5000 # guardadas para a tabela (as contagens não têm limite) + + def __init__(self, mpsoc_config): + self.mpsoc_config = mpsoc_config + self.known_apps = known_app_ids(mpsoc_config) + self.reset() + + def reset(self): + self.messages = [] # na ordem de injeção + self._pending = defaultdict(list) + self.lost = 0 # injeções sem chegada registrada + self.consumed = 0 # pacotes do traffic_router.txt já analisados + self.hop_records = Counter() # serviço -> registros intermediários conferidos + self.hop_violations = Counter() # serviço -> registros fora do XY + self.violations = [] + + @staticmethod + def _key(packet): + # Sem as tarefas: no Memphis-V os campos de tarefa podem mudar de um + # hop para outro do mesmo pacote (valem os da injeção) + return packet.service, packet.size + + def consume(self, packet): + self.consumed += 1 + config = self.mpsoc_config + router = packet.router_address + + injected_locally = packet.input_port in LOCAL_PORTS + from_peripheral = not injected_locally and is_peripheral_input(config, router, packet.input_port) + + if not 0 <= packet.target_router < config.get_pe_number(): + # Destino fora da malha: não é um pacote na NoC (ex.: TASK_TERMINATED + # de tamanho 0 e destino -1 registrado pelo kernel na porta local) + return + + if injected_locally or from_peripheral: + message = Message(router, packet.target_router, packet.service, packet.size + 1, + packet.time, task_source=packet.task_source, task_target=packet.task_target, + from_peripheral=from_peripheral, + apps=frozenset(packet_apps(packet, self.known_apps)), + last_router=router, last_time=packet.time, + path=[(router, packet.input_port, packet.time)]) + self.messages.append(message) + if router == packet.target_router: + # Destino é o próprio roteador: entra e sai no mesmo registro + message.delivered = packet.time + packet.bandwidth_cycles + else: + self._pending[self._key(packet)].append(message) + return + + violation = self._check_routing(packet) + + candidates = self._pending.get(self._key(packet)) + if not candidates: + return + + # Pacote em trânsito visto por último no vizinho de onde este veio + previous = neighbor(config, router, packet.input_port) + match = None + for message in candidates: + if message.last_router == previous and message.last_time <= packet.time: + if match is None or message.last_time > match.last_time: + match = message + if match is not None: + match.prev_router = match.last_router + match.last_router = router + match.last_time = packet.time + match.hops_seen += 1 + match.path.append((router, packet.input_port, packet.time)) + if violation is not None: + violation.message = match + + if router != packet.target_router: + return + + if match is None: + # Faltaram registros no caminho: casa pela origem compatível com a porta + for message in reversed(candidates): + if message.injected <= packet.time and _came_from(config, message.source, router, + packet.input_port): + match = message + break + if match is None: + return + + # Mensagens do mesmo fluxo chegam em ordem: as anteriores não foram registradas + stale = [message for message in candidates + if message.source == match.source and message.injected < match.injected] + for message in stale: + candidates.remove(message) + self.lost += len(stale) + candidates.remove(match) + + match.target = router + match.hops = hops(config, match.source, router) + match.last_router, match.last_time = router, packet.time + match.delivered = packet.time + packet.bandwidth_cycles + + def _check_routing(self, packet): + """Confere o registro contra o XY; retorna a violação guardada, se houver.""" + self.hop_records[packet.service] += 1 + if xy_consistent(self.mpsoc_config, packet.router_address, packet.input_port, packet.target_router): + return None + self.hop_violations[packet.service] += 1 + if len(self.violations) >= self.MAX_VIOLATIONS: + return None + violation = RoutingViolation(packet.time, packet.router_address, packet.input_port, + packet.target_router, packet.service, packet.task_source, packet.task_target) + self.violations.append(violation) + return violation + + def delivered(self): + return [message for message in self.messages if message.delivered >= 0] + + def in_flight(self): + """Injetadas que ainda podem chegar (sem contar as dadas como perdidas).""" + return [message for queue in self._pending.values() for message in queue] + + def pending_count(self): + return sum(len(queue) for queue in self._pending.values()) + + +def read_all_packets(mpsoc_config): + """Todos os pacotes do traffic_router.txt, com um leitor próprio (não mexe na simulação).""" + from information.read_traffic_data import ReadTrafficData + from information.router_neighbors import RouterNeighbors + + reader = ReadTrafficData(mpsoc_config, RouterNeighbors.from_config(mpsoc_config)) + packets = [] + try: + while (packet := reader.get_next_packet()) is not None: + packets.append(packet) + finally: + reader.close() + return packets + + +def track_all(mpsoc_config, packets): + tracker = TrafficTracker(mpsoc_config) + for packet in packets: + tracker.consume(packet) + return tracker + + +# Um pacote que acabou de chegar gera a interrupção logo depois: os pacotes +# entregues um pouco antes do início do trecho também são listados +SLICE_LOOKBACK = 100 + + +def messages_in_slice(messages, pe, start, finish, lookback=SLICE_LOOKBACK): + """ + Pacotes de um PE durante um trecho do escalonamento: [(mensagem, "received" | "sent")], + em ordem de tempo. Recebidos: entregues ao PE entre start - lookback e finish. + Enviados: injetados pelo PE entre start e finish. + """ + result = [] + for message in messages: + if message.target == pe and message.delivered >= 0 and start - lookback <= message.delivered <= finish: + result.append((message.delivered, message, "received")) + if message.source == pe and not message.from_peripheral and start <= message.injected <= finish: + result.append((message.injected, message, "sent")) + result.sort(key=lambda item: item[0]) + return [(message, direction) for _time, message, direction in result] + + +# ========================================== +# VALIDAÇÃO DO ROTEAMENTO E DEADLOCK +# ========================================== +# Um serviço com pelo menos esta fração de registros fora do XY não é uma +# falha pontual: é tráfego que não usa XY (ex.: inundação da rede de +# broadcast de gerência, no canal LOW do Memphis-V) +NON_XY_RATIO = 0.3 +NON_XY_MIN_RECORDS = 3 + + +def routing_summary(tracker): + """ + Retorna (serviços não-XY {serviço: (fora do XY, total)}, violações + pontuais dos demais serviços). + """ + non_xy = {service: (count, tracker.hop_records[service]) + for service, count in tracker.hop_violations.items() + if count >= NON_XY_MIN_RECORDS and count / tracker.hop_records[service] >= NON_XY_RATIO} + anomalies = [violation for violation in tracker.violations if violation.service not in non_xy] + return non_xy, anomalies + + +def blocked_messages(tracker, now, threshold): + """Pacotes em trânsito sem avançar há pelo menos `threshold` ciclos, do mais antigo ao mais novo.""" + stuck = [message for message in tracker.in_flight() if now - message.last_time >= threshold] + stuck.sort(key=lambda message: message.last_time) + return stuck + + +def _held_link(message): + """Enlace ocupado pelo header: (roteador anterior, roteador atual).""" + return (message.prev_router if message.hops_seen else "PE", message.last_router) + + +def _wanted_link(mpsoc_config, message): + """Próximo enlace que o pacote precisa, pelo XY.""" + following = next_hop(mpsoc_config, message.last_router, message.target) + return (message.last_router, following if following is not None else "PE") + + +def find_wait_cycles(mpsoc_config, messages): + """ + Espera circular entre pacotes parados: A espera o enlace que B ocupa, + B espera o de C, ..., até voltar a A. Com roteamento XY isso não deveria + acontecer; um ciclo indica deadlock. Retorna listas de mensagens. + """ + holders = defaultdict(list) + for message in messages: + holders[_held_link(message)].append(message) + + waits = {id(message): holders.get(_wanted_link(mpsoc_config, message), []) for message in messages} + by_id = {id(message): message for message in messages} + + cycles, seen = [], set() + for start in messages: + path, index = [], {} + current = start + while current is not None and id(current) not in seen: + if id(current) in index: + cycle = path[index[id(current)]:] + cycles.append(cycle) + break + index[id(current)] = len(path) + path.append(current) + blockers = waits.get(id(current), []) + current = by_id.get(id(blockers[0])) if blockers else None + seen.update(id(message) for message in path) + return cycles + + +@dataclass +class AppTraffic: + app: int + messages: int = 0 + flits: int = 0 + first_injection: int = -1 + last_delivery: int = -1 + latency_sum: int = 0 + latency_max: int = 0 + hops_sum: int = 0 + latencies: list = field(default_factory=list) + + @property + def active_cycles(self): + return self.last_delivery - self.first_injection if self.messages else 0 + + @property + def average_latency(self): + return self.latency_sum / self.messages if self.messages else 0.0 + + @property + def median_latency(self): + return median(self.latencies) + + @property + def average_hops(self): + return self.hops_sum / self.messages if self.messages else 0.0 + + +def traffic_by_app(messages): + """Totais por aplicação sobre as mensagens entregues.""" + stats = {} + for message in messages: + if message.delivered < 0: + continue + for app in message.apps: + entry = stats.setdefault(app, AppTraffic(app)) + entry.messages += 1 + entry.flits += message.flits + if entry.first_injection < 0 or message.injected < entry.first_injection: + entry.first_injection = message.injected + entry.last_delivery = max(entry.last_delivery, message.delivered) + entry.latency_sum += message.latency + entry.latency_max = max(entry.latency_max, message.latency) + entry.hops_sum += message.hops + entry.latencies.append(message.latency) + return stats + + +# ========================================== +# LINHA DO TEMPO DA ADMISSÃO DE APLICAÇÕES +# ========================================== +@dataclass +class AppTimeline: + """ + Etapas do protocolo de admissão (Seção 3.4 do artigo) de uma aplicação. + Tempos em ticks; -1 = etapa não encontrada. + """ + app: int + name: str = "" + task_count: int = 0 + received: int = -1 # mapper recebeu o pedido (NEW APP) + requester: str = "" + mapped: int = -1 # mapeamento concluído + started: int = -1 # TASK_RELEASE enviado: aplicação liberada para executar + terminated: int = -1 + task_allocated: dict = field(default_factory=dict) # tarefa -> (tick, PE) da chegada do código + task_terminated: dict = field(default_factory=dict) # tarefa -> tick + task_confirmed: set = field(default_factory=set) # TASK_ALLOCATED recebidos pelo mapper + + @property + def first_allocation(self): + return min((time for time, _pe in self.task_allocated.values()), default=-1) + + @property + def last_allocation(self): + return max((time for time, _pe in self.task_allocated.values()), default=-1) + + @property + def last_termination(self): + return max(self.task_terminated.values(), default=-1) + + @property + def end(self): + return self.terminated if self.terminated >= 0 else self.last_termination + + @property + def start(self): + return self.started if self.started >= 0 else self.last_allocation + + @property + def admission_cycles(self): + """Do pedido até a liberação (o artigo cita ~40 mil ciclos para o MPEG).""" + begin = self.received if self.received >= 0 else self.first_allocation + return self.start - begin if begin >= 0 and self.start >= 0 else -1 + + @property + def mapping_cycles(self): + return self.mapped - self.received if self.received >= 0 and self.mapped >= 0 else -1 + + @property + def execution_cycles(self): + return self.end - self.start if self.start >= 0 and self.end >= 0 else -1 + + @property + def first_event(self): + times = [t for t in (self.received, self.mapped, self.started, self.first_allocation) if t >= 0] + return min(times, default=-1) + + +# Mensagens do mapper_task no log do processador (prefixo $$$____) +_NEW_APP = re.compile(r"New app received at (\d+) from (\w+) with (\d+) tasks") +_MAPPED = re.compile(r"Mapped with score .*? at (\d+)") +_TASK_ALLOCATED = re.compile(r"Received task allocated from id (\d+)") +_RELEASE = re.compile(r"Sending TASK_RELEASE for app (\d+)") +_STARTED = re.compile(r"App (\d+) started at (\d+)") +_TERMINATED = re.compile(r"App (\d+) terminated at time (\d+)") + + +def _log_dir(mpsoc_config): + testcase = mpsoc_config.get_testcase_path() or os.path.dirname(os.path.normpath(mpsoc_config.debug_file_path)) + return os.path.join(testcase, "log") + + +def read_mapper_log(mpsoc_config, timelines): + """ + Completa as linhas do tempo com o log do mapper_task. Retorna o caminho + do log usado, ou None se nenhum log tiver mensagens do mapper. + """ + log_dir = _log_dir(mpsoc_config) + try: + names = sorted(name for name in os.listdir(log_dir) if name.endswith(".txt")) + except OSError: + return None + + for name in names: + path = os.path.join(log_dir, name) + try: + with open(path, "r", encoding="utf-8", errors="replace") as log_file: + lines = [line for line in log_file if "New app received" in line or "App " in line + or "Mapped with" in line or "task allocated" in line or "TASK_RELEASE for app" in line] + except OSError: + continue + if not any(_NEW_APP.search(line) for line in lines): + continue + + # O pedido ainda não tem ID de aplicação: ele só aparece no TASK_RELEASE + pending = None + for line in lines: + if match := _NEW_APP.search(line): + pending = {"received": int(match[1]), "requester": match[2], "tasks": int(match[3]), + "mapped": -1, "confirmed": set()} + elif (match := _MAPPED.search(line)) and pending is not None: + pending["mapped"] = int(match[1]) + elif (match := _TASK_ALLOCATED.search(line)) and pending is not None: + pending["confirmed"].add(int(match[1])) + elif match := _RELEASE.search(line): + timeline = timelines.setdefault(int(match[1]), AppTimeline(int(match[1]))) + if pending is not None: + timeline.received = pending["received"] + timeline.requester = pending["requester"] + timeline.task_count = pending["tasks"] + timeline.mapped = pending["mapped"] + timeline.task_confirmed = pending["confirmed"] + pending = None + elif match := _STARTED.search(line): + timelines.setdefault(int(match[1]), AppTimeline(int(match[1]))).started = int(match[2]) + elif match := _TERMINATED.search(line): + timelines.setdefault(int(match[1]), AppTimeline(int(match[1]))).terminated = int(match[2]) + return path + return None + + +def build_timelines(mpsoc_config, packets): + """ + Linha do tempo de cada aplicação a partir de todos os pacotes do + traffic_router.txt (chegada do código das tarefas e término) e do log + do mapper_task (pedido, mapeamento, liberação e término da aplicação). + + Retorna (timelines ordenadas por ID, caminho do log do mapper ou None). + """ + known_apps = known_app_ids(mpsoc_config) + timelines = {} + + for packet in packets: + if packet.router_address != packet.target_router: + continue + # Tarefa chegando ao PE de destino (mesma regra do RouterInformation) + if packet.service in mpsoc_config.task_allocation_services: + app = task_app(packet.task_source, known_apps) + if app is not None: + timeline = timelines.setdefault(app, AppTimeline(app)) + timeline.task_allocated.setdefault( + packet.task_source, (packet.time + packet.bandwidth_cycles, packet.router_address)) + + for packet in packets: + if packet.input_port in LOCAL_PORTS and packet.service in mpsoc_config.task_terminated_services: + task = packet.task_source if packet.task_source >= 0 else packet.task_target + app = task_app(task, known_apps) + if app is not None: + timelines.setdefault(app, AppTimeline(app)).task_terminated.setdefault(task, packet.time) + + log_path = read_mapper_log(mpsoc_config, timelines) + + for app, timeline in timelines.items(): + timeline.name = mpsoc_config.app_name_hash.get(app, "") + if not timeline.task_count: + timeline.task_count = len(timeline.task_allocated) + return [timelines[app] for app in sorted(timelines)], log_path diff --git a/deloream.py b/deloream.py new file mode 100644 index 0000000..11d7ce8 --- /dev/null +++ b/deloream.py @@ -0,0 +1,541 @@ +""" +Deloream — leitor dos logs de depuração das tarefas (Debug Log Reader for MPSoCs). + +Refatoração do pacote "deloream" (DeloreamMainFrame.java e TaskMessage.java) +do GraphicalDebugger original. + +Estrutura esperada de um cenário (testcase) do Memphis-V: + + / + ├─ debug/platform.cfg nomes das tarefas e das aplicações + └─ log/logx.txt saída de cada processador + +As mensagens de depuração das tarefas nos logs têm o formato + + $$$____ + +ex.: "$$$_1x1_3_1_Inicio da aplicacao prod" (tarefa 1 da aplicação 3 no PE 1x1). +""" + +import html +import os +import re +from collections import defaultdict +from dataclasses import dataclass + +from PySide6.QtCore import QSortFilterProxyModel, Qt +from PySide6.QtGui import QAction, QFont, QIcon, QKeySequence, QStandardItem, QStandardItemModel +from PySide6.QtWidgets import (QAbstractItemView, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QLineEdit, + QMainWindow, QMenu, QMessageBox, QSplitter, QStackedWidget, QTableView, + QTabWidget, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator, + QVBoxLayout, QWidget) + +ICON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon", "delorean.png") + +# Marca das mensagens de depuração das tarefas nos logs +MESSAGE_MARK = "$$$" + +# $$$____; a mensagem pode conter "_" +_MESSAGE_PATTERN = re.compile(r"^\$\$\$_(?P[^_]+)_(?P\d+)_(?P\d+)_(?P.*)$") + +# Papéis dos itens da árvore: tipo ("app"/"task") e ID da aplicação/tarefa +_KIND_ROLE = Qt.ItemDataRole.UserRole +_ID_ROLE = Qt.ItemDataRole.UserRole + 1 + + +@dataclass(frozen=True) +class TaskMessage: + """Uma linha "$$$" de um log de processador.""" + + proc: str + app_id: int + task_id: int + message: str + + @classmethod + def parse(cls, line): + match = _MESSAGE_PATTERN.match(line.rstrip("\r\n")) + if match is None: + return None + return cls(match["proc"], int(match["app"]), int(match["task"]), match["message"]) + + @property + def proc_number(self): + """Endereço XY do processador (x << 8 | y).""" + x, y = (int(value) for value in self.proc.split("x")) + return (x << 8) | y + + +def read_name_relations(platform_path): + """ + Lê do platform.cfg as relações ID -> nome das tarefas e das aplicações. + Retorna (tarefas, aplicações). + """ + tasks, apps = {}, {} + section = None + + with open(platform_path, "r", encoding="utf-8") as platform_file: + for line in platform_file: + fields = line.split() + if not fields: + continue + + match fields[0]: + case "BEGIN_task_name_relation": + section = tasks + case "BEGIN_app_name_relation": + section = apps + case "END_task_name_relation" | "END_app_name_relation": + section = None + case name if section is not None and len(fields) >= 2: + try: + section[int(fields[1])] = name + except ValueError: + continue + + return tasks, apps + + +def read_all_messages(log_dir): + """ + Todas as mensagens "$$$" dos logs do cenário, agrupadas por + (aplicação, tarefa local), na ordem dos arquivos (por nome) e das linhas. + """ + messages = defaultdict(list) + for file_name in sorted(os.listdir(log_dir)): + if "log" not in file_name: + continue + path = os.path.join(log_dir, file_name) + if not os.path.isfile(path): + continue + + with open(path, "r", encoding="utf-8", errors="replace") as log_file: + for line in log_file: + if not line.startswith(MESSAGE_MARK): + continue + message = TaskMessage.parse(line) + if message is not None: + messages[(message.app_id, message.task_id)].append(message) + + return dict(messages) + + +def read_task_messages(log_dir, app_id, task_id): + """Mensagens de uma tarefa em todos os logs do cenário.""" + return read_all_messages(log_dir).get((app_id, task_id), []) + + +class MessagesTab(QWidget): + """ + Aba com as mensagens de uma tarefa (ou de todas as tarefas de uma + aplicação): cabeçalho com o resumo, campo de busca e tabela + #, processador, [tarefa,] mensagem. + """ + + def __init__(self, kind, item_id, parent=None): + super().__init__(parent) + self.kind = kind + self.item_id = item_id + + self.header = QLabel() + self.header.setTextFormat(Qt.TextFormat.RichText) + self.header.setWordWrap(True) + + self.search = QLineEdit() + self.search.setPlaceholderText("Filter messages…") + self.search.setClearButtonEnabled(True) + self.search.setMaximumWidth(260) + + self.model = QStandardItemModel(0, 4, self) + self.model.setHorizontalHeaderLabels(["#", "Processor", "Task", "Message"]) + self.proxy = QSortFilterProxyModel(self) + self.proxy.setSourceModel(self.model) + self.proxy.setFilterKeyColumn(-1) + self.proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.search.textChanged.connect(self._filter) + + self.table = QTableView() + self.table.setModel(self.proxy) + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setWordWrap(False) + self.table.verticalHeader().hide() + self.table.verticalHeader().setDefaultSectionSize(22) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.setStretchLastSection(True) + header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + font = QFont("DejaVu Sans Mono") + font.setStyleHint(QFont.StyleHint.Monospace) + self.table.setFont(font) + # Coluna "Task" só faz sentido na aba de uma aplicação + self.table.setColumnHidden(2, kind == "task") + + self.empty = QLabel() + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty.setWordWrap(True) + self.stack = QStackedWidget() + self.stack.addWidget(self.table) + self.stack.addWidget(self.empty) + + top = QHBoxLayout() + top.addWidget(self.header, 1) + top.addWidget(self.search) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.addLayout(top) + layout.addWidget(self.stack, 1) + + def set_messages(self, title, subtitle, messages, empty_text): + """`messages` é uma lista de (nome da tarefa, TaskMessage).""" + self.model.removeRows(0, self.model.rowCount()) + for number, (task_name, message) in enumerate(messages, start=1): + index_item = QStandardItem() + index_item.setData(number, Qt.ItemDataRole.DisplayRole) + row = [index_item, QStandardItem(message.proc), QStandardItem(task_name), + QStandardItem(message.message)] + for item in row: + item.setToolTip(message.message) + self.model.appendRow(row) + + procs = list(dict.fromkeys(message.proc for _task, message in messages)) + details = [html.escape(subtitle), f"{len(messages)} message{'s' if len(messages) != 1 else ''}"] + if procs: + label = "processor" if len(procs) == 1 else "processors" + details.append(f"{label}: {html.escape(', '.join(procs))}") + self.header.setText(f"{html.escape(title)}" + f"
{' · '.join(details)}") + + self.empty.setText(empty_text) + self.stack.setCurrentWidget(self.table if messages else self.empty) + self.search.setEnabled(bool(messages)) + + def _filter(self, text): + self.proxy.setFilterFixedString(text) + + +class DeloreamWindow(QMainWindow): + """ + Árvore Aplicações -> Tarefas à esquerda; um clique numa tarefa (ou + aplicação) abre as mensagens dela numa aba à direita, ou volta para a aba + já aberta. + """ + + def __init__(self, testcase_path, parent=None): + super().__init__(parent) + self.setWindowTitle("Deloream - Debug Log Reader for MPSoCs") + if os.path.isfile(ICON_PATH): + self.setWindowIcon(QIcon(ICON_PATH)) + self.resize(1000, 700) + + self.task_names, self.app_names, self.messages = {}, {}, {} + self._error = None + + self.tree_filter = QLineEdit() + self.tree_filter.setPlaceholderText("Search tasks…") + self.tree_filter.setClearButtonEnabled(True) + self.tree_filter.textChanged.connect(self._filter_tree) + + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Task", "Messages"]) + self.tree.setRootIsDecorated(True) + self.tree.setUniformRowHeights(True) + self.tree.header().setStretchLastSection(False) + self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + # Um clique abre a aba; Enter também (navegar com as setas não abre) + self.tree.itemClicked.connect(self._on_item_activated) + self.tree.itemActivated.connect(self._on_item_activated) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(6, 6, 0, 6) + left_layout.addWidget(self.tree_filter) + left_layout.addWidget(self.tree) + + self.tabs = QTabWidget() + self.tabs.setTabsClosable(True) + self.tabs.setMovable(True) + self.tabs.setDocumentMode(True) + self.tabs.tabCloseRequested.connect(self.close_tab) + self.tabs.currentChanged.connect(self._sync_tree_selection) + self.tabs.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tabs.tabBar().customContextMenuRequested.connect(self._tab_menu) + self.tabs.tabBar().installEventFilter(self) + + self.placeholder = QLabel() + self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.placeholder.setWordWrap(True) + self.right = QStackedWidget() + self.right.addWidget(self.placeholder) + self.right.addWidget(self.tabs) + + splitter = QSplitter() + splitter.addWidget(left) + splitter.addWidget(self.right) + splitter.setStretchFactor(1, 1) + splitter.setSizes([280, 720]) + self.setCentralWidget(splitter) + + self._build_menu() + self.load_testcase(testcase_path) + + def _build_menu(self): + options = self.menuBar().addMenu("Options") + + new_testcase = QAction("New testcase", self) + new_testcase.setShortcut(QKeySequence("Ctrl+N")) + new_testcase.triggered.connect(self._choose_testcase) + options.addAction(new_testcase) + + reload_action = QAction("Reload", self) + reload_action.setShortcut(QKeySequence("F5")) + reload_action.triggered.connect(lambda: self.load_testcase(self.testcase_path)) + options.addAction(reload_action) + + options.addSeparator() + close_tab = QAction("Close tab", self) + close_tab.setShortcut(QKeySequence("Ctrl+W")) + close_tab.triggered.connect(lambda: self.close_tab(self.tabs.currentIndex())) + options.addAction(close_tab) + + close_all = QAction("Close all tabs", self) + close_all.setShortcut(QKeySequence("Ctrl+Shift+W")) + close_all.triggered.connect(self.close_all_tabs) + options.addAction(close_all) + + options.addSeparator() + exit_action = QAction("Exit", self) + exit_action.setShortcut(QKeySequence("Ctrl+E")) + exit_action.triggered.connect(self.close) + options.addAction(exit_action) + + help_menu = self.menuBar().addMenu("Help") + about = QAction("About", self) + about.triggered.connect(lambda: QMessageBox.about( + self, "Deloream", + "Deloream - Debug Log Reader for MPSoCs\n\n" + "Click a task to read its debug messages ($$$) from the processor logs " + "of the testcase in a new tab; click an application to see the messages " + "of all its tasks.")) + help_menu.addAction(about) + + # ========================================== + # CARREGAMENTO + # ========================================== + @property + def platform_path(self): + return os.path.join(self.testcase_path, "debug", "platform.cfg") + + @property + def log_dir(self): + return os.path.join(self.testcase_path, "log") + + def _choose_testcase(self): + path = QFileDialog.getExistingDirectory(self, "Open testcase (folder with debug/ and log/)", + self.testcase_path) + if path: + if path != self.testcase_path: + self.close_all_tabs() + self.load_testcase(path) + + def load_testcase(self, testcase_path): + """ + Lê os nomes do platform.cfg e as mensagens dos logs, monta a árvore + de aplicações e atualiza as abas abertas. + """ + self.testcase_path = testcase_path + self.setWindowTitle(f"Deloream - {testcase_path}") + self.tree.clear() + self._error = None + + try: + self.task_names, self.app_names = read_name_relations(self.platform_path) + except OSError: + self.task_names, self.app_names, self.messages = {}, {}, {} + self._error = f"platform.cfg not found in {self.platform_path}" + self.close_all_tabs() + self._update_placeholder() + return + + try: + self.messages = read_all_messages(self.log_dir) + except OSError: + self.messages = {} + + # Aplicações do BEGIN_app_name_relation; tarefas sem aplicação conhecida + # também aparecem, com o ID da aplicação no lugar do nome + app_ids = sorted(set(self.app_names) | {task_id >> 8 for task_id in self.task_names}) + for app_id in app_ids: + app_item = QTreeWidgetItem(self.tree, [self._app_title(app_id)]) + app_item.setData(0, _KIND_ROLE, "app") + app_item.setData(0, _ID_ROLE, app_id) + bold = app_item.font(0) + bold.setBold(True) + app_item.setFont(0, bold) + + app_total = 0 + for task_id in sorted(self.task_names): + if task_id >> 8 != app_id: + continue + count = len(self._task_messages(task_id)) + app_total += count + task_item = QTreeWidgetItem(app_item, [self._task_title(task_id), str(count) if count else "—"]) + task_item.setData(0, _KIND_ROLE, "task") + task_item.setData(0, _ID_ROLE, task_id) + task_item.setTextAlignment(1, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + if not count: + task_item.setForeground(0, self.palette().placeholderText()) + task_item.setForeground(1, self.palette().placeholderText()) + task_item.setToolTip(0, "No debug message found in the logs") + + app_item.setText(1, str(app_total) if app_total else "—") + app_item.setTextAlignment(1, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + app_item.setToolTip(0, "Click to see the messages of all tasks of this application") + + self.tree.expandAll() + self._filter_tree(self.tree_filter.text()) + + # Abas abertas passam a mostrar os logs recarregados + for index in range(self.tabs.count()): + self._fill_tab(self.tabs.widget(index)) + self._update_placeholder() + + def _app_title(self, app_id): + return f"{self.app_names.get(app_id, 'app')}[{app_id}]" + + def _task_title(self, task_id): + return f"{self.task_names.get(task_id, 'task')}[{task_id}]" + + def _task_messages(self, task_id): + return self.messages.get((task_id >> 8, task_id & 0xFF), []) + + def _filter_tree(self, text): + """Mostra só as tarefas (e aplicações) cujo nome contém o texto.""" + text = text.strip().lower() + for app_index in range(self.tree.topLevelItemCount()): + app_item = self.tree.topLevelItem(app_index) + app_matches = text in app_item.text(0).lower() + any_task = False + for task_index in range(app_item.childCount()): + task_item = app_item.child(task_index) + visible = app_matches or text in task_item.text(0).lower() + task_item.setHidden(not visible) + any_task |= visible + app_item.setHidden(not (app_matches or any_task)) + + # ========================================== + # ABAS + # ========================================== + def _on_item_activated(self, item, _column=0): + kind = item.data(0, _KIND_ROLE) + if kind is not None: + self.open_tab(kind, item.data(0, _ID_ROLE)) + + def show_task_messages(self, task_id): + """Abre (ou mostra) a aba com as mensagens da tarefa.""" + return self.open_tab("task", task_id) + + def find_tab(self, kind, item_id): + for index in range(self.tabs.count()): + tab = self.tabs.widget(index) + if tab.kind == kind and tab.item_id == item_id: + return index + return -1 + + def open_tab(self, kind, item_id): + index = self.find_tab(kind, item_id) + if index < 0: + tab = MessagesTab(kind, item_id) + self._fill_tab(tab) + index = self.tabs.addTab(tab, "") + self._update_tab_title(index) + self.tabs.setCurrentIndex(index) + self._update_placeholder() + return self.tabs.widget(index) + + def _fill_tab(self, tab): + if tab.kind == "task": + task_id = tab.item_id + title = self._task_title(task_id) + subtitle = f"application {self._app_title(task_id >> 8)}" + name = self.task_names.get(task_id, str(task_id)) + messages = [(name, message) for message in self._task_messages(task_id)] + empty = "Log file not created yet or no message found for this task." + else: + app_id = tab.item_id + title = self._app_title(app_id) + task_ids = [task_id for task_id in sorted(self.task_names) if task_id >> 8 == app_id] + subtitle = f"{len(task_ids)} task{'s' if len(task_ids) != 1 else ''}" + messages = [(self.task_names[task_id], message) + for task_id in task_ids for message in self._task_messages(task_id)] + empty = "Log file not created yet or no message found for this application." + tab.set_messages(title, subtitle, messages, empty) + + def _update_tab_title(self, index): + tab = self.tabs.widget(index) + if tab.kind == "task": + title = self._task_title(tab.item_id) + tooltip = f"{title} - application {self._app_title(tab.item_id >> 8)}" + else: + title = self._app_title(tab.item_id) + tooltip = f"All tasks of {title}" + self.tabs.setTabText(index, title) + self.tabs.setTabToolTip(index, tooltip) + + def close_tab(self, index): + if index < 0: + return + tab = self.tabs.widget(index) + self.tabs.removeTab(index) + tab.deleteLater() + self._update_placeholder() + + def close_all_tabs(self, keep=None): + for index in reversed(range(self.tabs.count())): + if self.tabs.widget(index) is not keep: + self.close_tab(index) + + def _tab_menu(self, position): + index = self.tabs.tabBar().tabAt(position) + if index < 0: + return + tab = self.tabs.widget(index) + menu = QMenu(self) + menu.addAction("Close", lambda: self.close_tab(self.tabs.indexOf(tab))) + menu.addAction("Close other tabs", lambda: self.close_all_tabs(keep=tab)) + menu.addAction("Close all tabs", self.close_all_tabs) + menu.exec(self.tabs.tabBar().mapToGlobal(position)) + + def eventFilter(self, obj, event): + # Botão do meio fecha a aba, como nos navegadores + if obj is self.tabs.tabBar() and event.type() == event.Type.MouseButtonRelease \ + and event.button() == Qt.MouseButton.MiddleButton: + self.close_tab(obj.tabAt(event.position().toPoint())) + return True + return super().eventFilter(obj, event) + + def _sync_tree_selection(self, index): + """Seleciona na árvore o item da aba atual.""" + if index < 0: + return + tab = self.tabs.widget(index) + iterator = QTreeWidgetItemIterator(self.tree) + while iterator.value(): + item = iterator.value() + if item.data(0, _KIND_ROLE) == tab.kind and item.data(0, _ID_ROLE) == tab.item_id: + self.tree.setCurrentItem(item) + return + iterator += 1 + + def _update_placeholder(self): + if self._error: + self.placeholder.setText(f"{html.escape(self._error)}") + elif not self.task_names: + self.placeholder.setText("No task found in platform.cfg.") + else: + self.placeholder.setText("Click a task to read its debug messages" + "
Click an application to see the messages of all its tasks.") + self.right.setCurrentWidget(self.tabs if self.tabs.count() and not self._error else self.placeholder) diff --git a/documentation/apresentacao_memphis_debugger.pptx b/documentation/apresentacao_memphis_debugger.pptx new file mode 100644 index 0000000..24319e9 Binary files /dev/null and b/documentation/apresentacao_memphis_debugger.pptx differ diff --git a/documentation/implementacoes.md b/documentation/implementacoes.md index 1779347..cb83114 100644 --- a/documentation/implementacoes.md +++ b/documentation/implementacoes.md @@ -13,9 +13,10 @@ Este documento descreve as funcionalidades portadas do [GraphicalDebugger origin 7. [Matriz de roteadores — `router_matrix.py`](#7-matriz-de-roteadores--router_matrixpy) 8. [Simulação — `simulation.py`](#8-simulação--simulationpy) 9. [Interface da simulação — `main.py`](#9-interface-da-simulação--mainpy) -10. [Diferenças e correções em relação ao Java](#10-diferenças-e-correções-em-relação-ao-java) -11. [Limitações e próximos passos](#11-limitações-e-próximos-passos) -12. [Como testar](#12-como-testar) +10. [Communication Overview, Task Mapping e Message Log — `overview_windows.py`](#10-communication-overview-task-mapping-e-message-log--overview_windowspy) +11. [Diferenças e correções em relação ao Java](#11-diferenças-e-correções-em-relação-ao-java) +12. [Limitações e próximos passos](#12-limitações-e-próximos-passos) +13. [Como testar](#13-como-testar) --- @@ -41,6 +42,20 @@ diretório debug/ |---|---|---| | `main.py` | Janela principal: menus, botões, tabelas, ligação com a simulação | `MainFrame.java` | | `simulation.py` | Controle da simulação e janelas de checkpoint | `MainFrame.java` (parte de simulação), `CheckpointController.java` | +| `overview_windows.py` | Janelas Communication Overview, Task Mapping Overview e Message Log, atualizadas durante a simulação | `CommunicationOverview.java`, `TaskMappingFrame.java`, `MainFrame.showMessageList` | +| `slave_matrix.py` / `pe_matrix.py` | Células das grades das duas janelas acima | — | +| `router_info_window.py` | Janela "Router Information" (clique num roteador): abas Log, Applications, Scheduling e Traffic, janela Task Information e gráfico de escalonamento | `RouterInfoFrame.java`, `PETextLog.java`, `SchedulingTab.java`, `scheduling/SchedulingPanel.java`, `TaskInfoFrame.java` | +| `platform_setup.py` | Janela Platform Setup (menu Edit): flit size, período de clock e largura da janela de checkpoint | `PlatformSetupFrame.java` | +| `filter_window.py` | Janela Service and PE Filter (menu Filters, Ctrl+F): escolhe quais pacotes a simulação processa, por serviço (Only/Except) e por PE | `FilterForm.java` | +| `projects.py` | Projetos salvos (`.hdf`): File → Save / Open / Delete Project | `MainFrame.java` (save/open/deleteMenuItem) | +| `help_dialogs.py` | Help → About e Help → Packet Format | `util/AboutFrame.java`, `MainFrame.packetFormatMenuItemActionPerformed` | +| `analysis.py` | Análises sem interface: aplicação de cada pacote, latência (casamento injeção → entrega), vazão em Mbps e linha do tempo da admissão de aplicações | — (métricas do artigo do Memphis, DAES 2019) | +| `statistics_windows.py` | Janelas Traffic Statistics (Links, Applications, Messages, Routing, Blocked) e Application Timeline (menu Tools) | — | +| `path_view.py` | Liga as janelas de análise à malha principal: duplo clique num pacote pinta o caminho dele e destaca o roteador | — | +| `testcase_files.py` | Leitura dos periféricos do YAML do testcase (`/.yaml`, seção `hw → Peripherals`) | — | +| `export.py` | Exportação de tabelas em CSV e de janelas/gráficos em PNG (botões e menu do botão direito) | — | +| `tests/` | Testes automatizados (pytest), com o cenário extraído do `DEBUG EXAMPLE.zip` | — | +| `deloream.py` | Deloream: leitor das mensagens `$$$` das tarefas nos logs dos processadores | `deloream/DeloreamMainFrame.java`, `deloream/TaskMessage.java` | | `theme.py` | Paletas dos temas claro/escuro, cores da grade e preferência salva | — | | `router_matrix.py` | Desenho da grade e de cada roteador (imagens, setas, %) | `Roteador.java`, `UJPanelImagem.java`, `MainFrame.createNoCPanel` | | `util/MPSoCConfig.py` | Leitura de `platform.cfg` e `services.cfg`, constantes e endereçamento | `util/MPSoCConfig.java` | @@ -346,7 +361,7 @@ Divide o tempo em janelas de **0,5 ms** (`window_size_ms`). `set_time(ticks)` co - `theme.py` define as paletas do Fusion para os dois temas, incluindo as cores de itens **desabilitados** e do texto de *placeholder* (sem elas, botões desabilitados ficavam ilegíveis no modo escuro). - `MinhaJanela.set_dark_mode(dark)` aplica a paleta na aplicação inteira, atualiza a marcação do menu e recolore a grade (`RouterMatrixWidget.apply_theme`). Uma grade criada depois (novo debug ou reset) já nasce com o tema atual. - A escolha é salva com `QSettings` (organização `GAPH`, aplicação `MemphisGraphicalDebugger`) e restaurada ao abrir o programa. -- As janelas Communication Overview e Task Mapping mantêm suas cores fixas. +- As células das janelas Communication Overview e Task Mapping mantêm suas cores próprias (mapa de calor e cores das aplicações); o restante da janela segue o tema. ### 9.3 Mensagens @@ -359,7 +374,97 @@ Divide o tempo em janelas de **0,5 ms** (`window_size_ms`). `set_time(ticks)` co --- -## 10. Diferenças e correções em relação ao Java +## 10. Communication Overview, Task Mapping e Message Log — `overview_windows.py` + +**Menus:** `Tools → Communication Overview`, `Tools → Task Mapping Overview` e `Tools → Message Log` + +As três janelas mostram o estado da simulação **até o tempo atual** e se atualizam sozinhas enquanto ela avança (play, `>||`, **Back To**). + +### 10.1 Atualização durante a simulação (`_LiveOverview`) + +- A janela se conecta ao sinal `time_changed` do `SimulationController` e só **marca** que os dados mudaram. +- Um `QTimer` redesenha no máximo a cada **500 ms** (`REFRESH_INTERVAL_MS`), e só se a janela estiver visível e houver mudança. Assim o modo rápido (velocidade 99–100) não é travado pelas janelas abertas. No Java era uma `Thread` com `sleep(1000)`. +- Abrir o menu de novo com a janela já aberta só a traz para frente (não cria outra). +- **New Debugging** e **Reset Simulation** chamam `set_information(...)` nas janelas abertas: elas recriam a grade e passam a seguir o novo debug/controlador. + +### 10.2 Communication Overview (`CommunicationOverviewWindow`) + +Mapa de calor do tráfego acumulado em cada roteador. + +| Controle | Efeito | +|---|---| +| **All Services** (padrão) | Considera todos os serviços; desabilita os filtros abaixo | +| Lista de serviços | Considera só o serviço escolhido | +| **Global** | % = tráfego do serviço no roteador / tráfego do serviço em toda a NoC | +| **All traffic** | % = tráfego do serviço no roteador / todo o tráfego da NoC | +| **Router** | % = tráfego do serviço no roteador / todo o tráfego do próprio roteador | +| **Volume** (padrão) / **Bandwidth** | Mede em flits (`size + 1`) ou em ciclos de enlace | + +- Cada célula mostra o tipo do PE (`Global M`, `Cluster M`, `Slave`), o rótulo do roteador, a porcentagem com 3 casas e a unidade (`flits`/`cycles`). +- Cor: mesma escala do Java, matiz HSB de `0.65` (azul, 0%) até `0` (vermelho, 100%), saturação 1 e brilho 0.8. A barra **Color Length** mostra a escala. +- **Statistics:** roteador com a maior porcentagem (**Bigger**), média entre todos os roteadores (**Average**) e roteador com a menor (**Small**). +- O fundo da célula é desenhado em `SlaveWidget.paintEvent` (trocar a folha de estilo do próprio widget não repintava o fundo). + +### 10.3 Task Mapping Overview (`TaskMappingWindow`) + +Tarefas de cada PE, montadas a partir dos eventos de `RouterInformation.get_tasks_information()`: + +- `ALLOCATED` → **RUN**, `TERMINATED` → **TER** (vale o último evento de cada tarefa). +- Cada tarefa aparece como `nome ID STATUS`; o nome vem do `platform.cfg` (`BEGIN_task_name_relation`) ou, se não existir, `app tarefa`. +- Cor por aplicação (`task_id >> 8`), sorteada com semente fixa (20, como no Java), então cada aplicação mantém a cor. O texto fica preto ou branco conforme a claridade do fundo. + +| Controle | Efeito | +|---|---| +| **All tasks status** | Todas as tarefas | +| **Only Running** (padrão) | Só as que estão rodando | +| **Only Terminated** | Só as terminadas (o `.ui` repetia "All tasks status" neste botão) | +| **Updating / Update** | Ligado: atualiza sozinho. Desligado: congela a visão; ao religar, atualiza na hora | +| **Without Task ID** | Esconde o ID numérico (no Java a opção existia, mas o botão não atualizava a tela) | + +Se o `platform.cfg` não tiver nomes de tarefas, a janela avisa uma vez que os IDs serão mostrados como números. + +### 10.4 Message Log (`MessageLogWindow`) + +Lista (500×600, como no original) das mensagens entre PEs, na ordem do `traffic_router.txt`: + +| Condição do pacote | Linha | +|---|---| +| Entrou pela porta `LOCAL0`/`LOCAL1` (saindo do PE) | `---->>> send message to service ` | +| `router_address == target_router` (chegou ao destino) | `<<<---- receive message with service ` | + +Se as duas condições valem (mensagem para o próprio PE), fica a linha de recebimento, como no Java. + +- Mostra todos os pacotes **já lidos** e acrescenta os novos conforme a simulação avança. Voltar no tempo não apaga linhas, pois os pacotes continuam lidos (mesmo comportamento do original). +- A lista acompanha o fim automaticamente, a menos que o usuário tenha rolado para cima. +- Os roteadores aparecem como `XxY` com endereçamento XY (o Java mostrava o endereço hamiltoniano numérico) ou pelo número com endereçamento HAMILTONIAN, igual ao resto da interface. + +### 10.5 Deloream (`deloream.py`) + +**Menu:** `Tools → Deloream` — *Debug Log Reader for MPSoCs*. Janela independente (800×700) que lê a pasta do cenário, a que contém o diretório `debug/` aberto (`MPSoCConfig.get_testcase_path()`): + +``` +/ + ├─ debug/platform.cfg nomes das tarefas (BEGIN_task_name_relation) e das aplicações (BEGIN_app_name_relation) + └─ log/logx.txt saída de cada processador +``` + +- **Árvore** `Applications → aplicação[id] → tarefa[id]`; a tarefa pertence à aplicação `id >> 8`. +- **Duplo clique** (ou Enter) numa tarefa: procura em todos os arquivos de `log/` as linhas no formato + + ``` + $$$____ + ``` + + com a aplicação e a tarefa escolhidas, e mostra as mensagens agrupadas por processador (`-- Processor: 1x1`), na ordem dos arquivos e das linhas. +- Sem mensagens (ou sem a pasta `log/`): *"Log file not created yet or no message found!"* em vermelho. +- **Options → New testcase** (Ctrl+N) abre outro cenário; **Reload** (F5) relê o `platform.cfg`; **Exit** (Ctrl+E) fecha só a janela. +- Abrir o menu de novo reaproveita a janela; se o debug carregado for de outro cenário, a árvore é recarregada. + +Classes/funções: `TaskMessage.parse(line)`, `read_name_relations(platform_path)`, `read_task_messages(log_dir, app_id, task_id)` e `DeloreamWindow`. + +--- + +## 11. Diferenças e correções em relação ao Java | Local | Java | Python | |---|---|---| @@ -375,20 +480,36 @@ Divide o tempo em janelas de **0,5 ms** (`window_size_ms`). `set_time(ticks)` co | Thread de simulação | `Thread` + `sleep()` alterando a interface | `QTimer` na thread principal | | Getters/setters | Métodos `getX()`/`setX()` | Atributos e `dataclass` | | `TaskInformation.remote_task_id` | `0` quando ausente | `-1` quando ausente | +| Task Mapping: atualização | `Thread` recriando todos os painéis a cada 1 s | `QTimer` que só redesenha se chegou pacote novo, reaproveitando os widgets | +| Task Mapping: ordem das tarefas | Ordem do `HashMap` (aleatória) | Ordenadas pelo ID | +| Task Mapping: cor do texto | Sempre preto (ilegível em cores escuras) | Preto ou branco conforme o fundo | +| Communication Overview: atualização | Calculado só ao abrir ou ao mudar um filtro | Acompanha a simulação | +| Deloream: mensagem | Juntava as partes sem os `_` (`valor_de_x` virava `valordex`) | Mantém a mensagem inteira | +| Deloream: busca | Procurava `_app_tarefa_` em qualquer ponto da linha (podia casar com o texto da mensagem) | Compara os campos de aplicação e tarefa | +| Deloream: Exit / New testcase | `Exit` fechava o debugger inteiro (`System.exit`); `New testcase` lia um caminho `null` | Fecha só a janela; abre um seletor de pasta | +| `get_testcase_path` | Cortava o caminho na primeira ocorrência de "debug" (quebrava em pastas como `debugger/`) | Pasta pai do diretório `debug` | +| Message Log | Retrato dos pacotes lidos no momento em que a janela abria; endereço hamiltoniano | Acrescenta as mensagens novas durante a simulação; endereço `XxY` com endereçamento XY | --- -## 11. Limitações e próximos passos +## 12. Limitações e próximos passos - **Back To** só vai até tempos já simulados (mesmo comportamento do Java). -- **Filtro de serviços/PEs** (`Filters → Service and PE Filter`): ainda não implementado. O controlador já aceita `SimulationController.packet_filter`. -- **Janela de informações do roteador** (`RouterInfoFrame`): o `RouterWidget` já emite `clicked(router_address)`, falta a janela. -- **Communication Overview**, **Task Mapping Overview** e **Message Log** ainda não usam os dados do `MPSoCInformation`. -- O relatório de uso de link por roteador (`printRouterTotalLinkUsage`, oculto no Java) não foi portado. +- **Latência e caminho dos pacotes** (Tools → Traffic Statistics): o `traffic_router.txt` não tem ID de pacote e os campos de tarefa podem mudar entre os hops de um mesmo pacote. Cada pacote é seguido hop a hop por serviço, tamanho, roteador vizinho (pela porta de entrada) e tempo; as tarefas da injeção definem a aplicação. No exemplo, 1 388 de 1 388 pacotes são entregues e todos os que têm hops são seguidos pelo caminho inteiro. A latência é só a da rede (sem o tempo do kernel). +- **Validação do roteamento** (aba Routing): cada registro intermediário é conferido contra o XY (primeiro X, depois Y). Serviços com muitos registros fora do XY não são tratados como erro, e sim listados como não-XY: no Memphis-V são os serviços 0–3 do canal LOW, que se espalham pela malha inteira (inundação da rede de gerência). +- **Pacotes parados e deadlock** (aba Blocked): lista os pacotes em trânsito sem avançar há N ciclos e procura espera circular (cada pacote esperando o enlace ocupado pelo seguinte). Com XY não deveria haver ciclo. +- **Ordem do `traffic_router.txt`**: o arquivo não está estritamente em ordem de tempo (a linha de um pacote longo pode ser gravada depois de registros mais novos). O **Back To** usa busca binária pelo tempo e herda essa pequena imprecisão do Java. +- **Caminho do pacote na malha**: duplo clique nas abas Messages, Routing e Blocked (Traffic Statistics) ou na tabela de pacotes do gráfico de escalonamento pinta o caminho registrado na janela principal e destaca o roteador (destino, onde parou ou onde violou o XY). A simulação é parada para o desenho não ser apagado; `File → Reset Graphical Path` limpa. +- **Escalonamento × pacotes**: clicar num trecho do gráfico de escalonamento lista os pacotes que o PE recebeu (entregues até 100 ciclos antes do trecho ou durante ele) e enviou (injetados durante ele) — ex.: o pacote que causou uma interrupção. +- **Periféricos**: os nomes vêm do YAML do testcase, lido linha a linha (sem PyYAML). Sem o YAML, a borda é marcada como "Peripheral" quando passa o primeiro pacote vindo de fora da malha. +- **Follow Live Trace** (`File`): no fim do `traffic_router.txt` a simulação não para; tenta ler novas linhas a cada 250 ms. Uma linha sem `\n` no fim é considerada incompleta e só é lida depois de terminar de ser gravada. A Application Timeline e as análises do trace inteiro são atualizadas com **Reload**. +- **Registros sem destino na malha**: `TASK_TERMINATED` de tamanho 0 com destino -1 é um aviso do kernel na porta local, não um pacote; é ignorado nas estatísticas. +- **Application Timeline**: o pedido, o mapeamento, a liberação e o término das aplicações vêm das mensagens do `mapper_task` no log (`log/logx.txt`). Sem esse log, só aparecem a chegada do código e o término das tarefas. +- **Término de tarefa**: no Memphis-V o `TASK_TERMINATED` traz a tarefa no último campo (o Java lia o penúltimo, do formato do HeMPS, e as tarefas nunca apareciam como terminadas). Corrigido em `RouterInformation`. --- -## 12. Como testar +## 13. Como testar 1. Extraia o `DEBUG EXAMPLE.zip`. 2. Execute `uv run main.py` (ou `.venv/bin/python main.py`). @@ -396,5 +517,15 @@ Divide o tempo em janelas de **0,5 ms** (`window_size_ms`). `set_time(ticks)` co 4. Use `>||` para avançar pacote a pacote, `>` para rodar e o slider para a velocidade (99–100 = modo rápido). 5. Digite um tempo já simulado em **Back To** e clique em **Go**. 6. `File → Reset Simulation` volta ao início. +7. Abra `Tools → Communication Overview`, `Tools → Task Mapping Overview` e `Tools → Message Log` e rode a simulação: as porcentagens, as tarefas e as mensagens mudam conforme os pacotes avançam (2 755 mensagens ao fim do exemplo). + +### Testes automatizados + +``` +uv sync # instala também o pytest (grupo dev) +uv run pytest # ~4 s +``` + +Os testes rodam sem tela (Qt offscreen), extraem o cenário do `DEBUG EXAMPLE.zip` e usam QSettings e pasta de projetos temporárias. O workflow `.github/workflows/build.yml` roda os testes antes de gerar o executável. Resultados esperados com o exemplo: 3 101 pacotes, último pacote em 6 251 176 ticks (62,51176 ms com clock de 10 ns), 42 serviços e 11 tarefas. diff --git a/documentation/roteiro_apresentacao.md b/documentation/roteiro_apresentacao.md new file mode 100644 index 0000000..11add1f --- /dev/null +++ b/documentation/roteiro_apresentacao.md @@ -0,0 +1,84 @@ +# Roteiro da apresentação — Memphis-V Graphical Debugger (v0.1.0-alpha) + +Slides: `documentation/apresentacao_memphis_debugger.pptx` (15 slides). O texto completo de cada fala está nas **notas do apresentador** de cada slide; este roteiro resume o tempo, os pontos-chave e os momentos de demonstração. + +**Duração total:** ~20 minutos + perguntas. + +--- + +## Antes de começar (checklist) + +- [ ] Extrair o `DEBUG EXAMPLE.zip` e anotar o caminho de `LOGS/example_testcase/example_scenario/debug/` +- [ ] Abrir o debugger (`./dist/MemphisVDebugger/MemphisVDebugger` ou `uv run main.py`) e deixar minimizado +- [ ] Deixar o tema **claro** (Edit → Dark Mode desmarcado), para a demonstração começar igual aos slides +- [ ] Testar o projetor/compartilhamento de tela com a janela do debugger (a grade de roteadores precisa estar legível) +- [ ] Ter a release `v0.1.0-alpha` aberta no navegador, para mostrar no final + +--- + +## Parte 1 — Contexto (≈ 3,5 min) + +| Slide | Tempo | Pontos-chave | +|---|---|---| +| 1. Capa | 1 min | Refatoração Java → Python/PySide6; é uma **alpha**: muito já funciona, mas não tudo. A imagem é o sistema novo em execução. | +| 2. Memphis-V e o Graphical Debugger | 1,5 min | Memphis-V simula MPSoCs com NoC; o debugger reproduz os arquivos gravados pela simulação. Cenário de exemplo: 9 PEs, 3.101 pacotes, 11 tarefas. | +| 3. Por que migrar | 1 min | Os três objetivos do README: interface moderna, manutenção simples, execução sem JVM. | + +## Parte 2 — Como o sistema funciona (≈ 7,5 min) + +| Slide | Tempo | Pontos-chave | +|---|---|---| +| 4. Arquitetura | 2 min | Arquivos → `MPSoCConfig` / `ReadTrafficData` → `MPSoCInformation` → controlador e janelas. Leitura **sob demanda** e cache para voltar no tempo. Nomes das classes iguais aos do Java. | +| 5. traffic_router.txt | 1,5 min | Ler a linha de exemplo campo a campo. Abertura validada: sem os 3 arquivos, nada é carregado. | +| 6. Janela principal | 2 min | Os 5 elementos numerados. **→ Demonstração 1** | +| 7. Como a simulação funciona | 2 min | Ciclo de 4 passos; checkpoint de 0,5 ms; velocidades 1–98 vs. 99–100; QTimer no lugar da thread do Java. | + +### 🎬 Demonstração 1 (no slide 6, ~2 min dentro do tempo do slide) + +1. **File → New Debugging** → selecionar a pasta `debug/` do exemplo. +2. Clicar **>||** umas 5 vezes: mostrar a tabela *Current Packet* e o tempo mudando. +3. Colocar a velocidade em ~90 e dar **>** (play): mostrar as setas vermelhas se movendo. +4. **STOP**, olhar o tempo em ticks no *Speed Control*, digitar em **Back To** um valor menor que ele (ex.: metade) e clicar **Go**. +5. *(Opcional)* Mostrar o erro ao abrir uma pasta sem os arquivos (ex.: `LOGS/example_testcase/debug/`, que não tem `traffic_router.txt`). + +## Parte 3 — Janelas de análise (≈ 5,5 min) + +| Slide | Tempo | Pontos-chave | +|---|---|---| +| 8. Communication Overview | 1,5 min | Mapa de calor azul → vermelho; filtros de serviço e volume/banda; *Statistics*. **Novo:** acompanha a simulação (o Java era uma foto). | +| 9. Task Mapping e Message Log | 1,5 min | RUN/TER por PE, uma cor por aplicação, botão para congelar; Message Log com `---->>>` (envio) e `<<<----` (recebimento), 2.755 linhas no fim. | +| 10. Deloream | 1,5 min | Lê `log/`; formato `$$$_proc_app_tarefa_mensagem`; duplo clique na tarefa. Correções: `_` preservado, Exit não fecha o debugger. | +| 11. Modo escuro e melhorias | 1 min | **→ Demonstração 2** | + +### 🎬 Demonstração 2 (no slide 11, ~1,5 min) + +1. Com a simulação parada no meio, abrir **Tools → Communication Overview** e **Tools → Task Mapping Overview** lado a lado. +2. Dar **>** na janela principal e mostrar as duas janelas se atualizando sozinhas. +3. **Tools → Deloream** → duplo clique em `prod[769]`. +4. **Ctrl+T** para alternar para o modo escuro (e de volta). + +## Parte 4 — Situação e próximos passos (≈ 4 min) + +| Slide | Tempo | Pontos-chave | +|---|---|---| +| 12. Bugs corrigidos | 1,5 min | Seis exemplos; a lista completa está em `documentation/implementacoes.md` (seção 11). | +| 13. Status | 1,5 min | **10 já refatorados × 6 faltando** (contagem por funcionalidade). Deixar claro: por isso é alpha. | +| 14. Próximos passos | 1 min | Estabilizar (CI Linux + Windows) → filtros e detalhes do roteador → projetos → beta. | + +## Encerramento (≈ 1 min + perguntas) + +| Slide | Tempo | Pontos-chave | +|---|---|---| +| 15. Como experimentar | 1 min | Executável da release, `uv run main.py`, primeiro teste com o `DEBUG EXAMPLE.zip`. Pedir relatos nas Issues. Abrir para perguntas. | + +--- + +## Perguntas prováveis + +| Pergunta | Resposta curta | +|---|---| +| Funciona no Windows? | O código não tem nada específico de Linux, mas o executável atual é só Linux. O build de Windows (PyInstaller em uma máquina Windows ou no CI) é o primeiro item do roteiro. | +| Por que o Back To não avança para o futuro? | Ele só volta para tempos já lidos do arquivo — mesmo comportamento do original. Para avançar, usa-se o play em velocidade 99–100. | +| Os resultados batem com o debugger Java? | A lógica foi portada classe a classe. As diferenças são as correções de bugs listadas no slide 12 (e na documentação). | +| Arquivos grandes de tráfego ficam lentos? | O arquivo é lido sob demanda e as janelas redesenham no máximo 2×/s; o modo rápido processa lotes de 200 pacotes. Ainda não foi testado com cenários muito maiores que o exemplo. | +| O que acontece com cenários de outro tamanho (4×4, 8×8)? | A grade, as janelas e os cálculos usam `mpsoc_x`/`mpsoc_y` do `platform.cfg`; clusters são desenhados com borda. | diff --git a/export.py b/export.py new file mode 100644 index 0000000..7d275c9 --- /dev/null +++ b/export.py @@ -0,0 +1,95 @@ +""" +Exportação de tabelas (CSV) e janelas/gráficos (PNG). + +Qualquer QTableWidget/QTableView exporta o que está visível (com filtros e +ordenação aplicados). install_export_actions() coloca "Export CSV…" e/ou +"Save as PNG…" no menu de contexto (botão direito) de um widget. +""" + +import csv +import os + +from PySide6.QtCore import QSettings, Qt +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QFileDialog, QMessageBox + +_SETTINGS_KEY = "export/last_dir" + + +def _settings(): + return QSettings("GAPH", "MemphisGraphicalDebugger") + + +def _ask_path(parent, title, default_name, file_filter, extension): + directory = _settings().value(_SETTINGS_KEY, os.path.expanduser("~")) + path, _ = QFileDialog.getSaveFileName(parent, title, os.path.join(directory, default_name), file_filter) + if not path: + return None + if not path.lower().endswith(extension): + path += extension + _settings().setValue(_SETTINGS_KEY, os.path.dirname(path)) + return path + + +def model_rows(model): + """Cabeçalhos e linhas (texto exibido) de um modelo de tabela.""" + columns = range(model.columnCount()) + headers = [str(model.headerData(column, Qt.Orientation.Horizontal) or "") for column in columns] + rows = [[str(model.data(model.index(row, column)) or "") for column in columns] + for row in range(model.rowCount())] + return headers, rows + + +def write_csv(model, path): + headers, rows = model_rows(model) + with open(path, "w", newline="", encoding="utf-8") as csv_file: + writer = csv.writer(csv_file) + writer.writerow(headers) + writer.writerows(rows) + return len(rows) + + +def save_png(widget, path): + # Vistas com zoom/rolagem (ex.: a malha) exportam o conteúdo inteiro + image = widget.to_image() if hasattr(widget, "to_image") else widget.grab() + if not image.save(path, "PNG"): + raise OSError(f"could not write {path}") + + +def export_csv(parent, view, default_name): + """Pergunta o arquivo e salva a tabela `view` (QTableWidget ou QTableView).""" + path = _ask_path(parent, "Export CSV", default_name + ".csv", "CSV (*.csv)", ".csv") + if path is None: + return None + try: + write_csv(view.model(), path) + except OSError as error: + QMessageBox.critical(parent, "Export CSV", f"Could not save the file:\n{error}") + return None + return path + + +def export_png(parent, widget, default_name): + """Pergunta o arquivo e salva uma imagem do `widget` como ele aparece na tela.""" + path = _ask_path(parent, "Save as PNG", default_name + ".png", "PNG image (*.png)", ".png") + if path is None: + return None + try: + save_png(widget, path) + except OSError as error: + QMessageBox.critical(parent, "Save as PNG", f"Could not save the image:\n{error}") + return None + return path + + +def install_export_actions(widget, default_name, csv_view=None, png_widget=None): + """Menu de contexto com as exportações disponíveis para o widget.""" + widget.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + if csv_view is not None: + action = QAction("Export CSV…", widget) + action.triggered.connect(lambda: export_csv(widget.window(), csv_view, default_name)) + widget.addAction(action) + if png_widget is not None: + action = QAction("Save as PNG…", widget) + action.triggered.connect(lambda: export_png(widget.window(), png_widget, default_name)) + widget.addAction(action) diff --git a/filter_window.py b/filter_window.py new file mode 100644 index 0000000..787a868 --- /dev/null +++ b/filter_window.py @@ -0,0 +1,210 @@ +""" +Janela "Service and PE Filter" (menu Filters, Ctrl+F). + +Refatoração de FilterForm.java do GraphicalDebugger original. Escolhe quais +pacotes do traffic_router.txt a simulação processa: + + - aba Services: só os serviços da lista ("Only") ou todos menos eles ("Except") + - aba PE: só os pacotes capturados nos roteadores da lista + - aba Application: só os pacotes de/para tarefas das aplicações da lista + (não existe no Java; o artigo do Memphis cita o filtro por aplicação) + +A lista montada numa aba só passa a valer ao clicar em "Apply" (lista vazia +desliga o filtro daquela aba). Pacotes rejeitados não atualizam os roteadores +nem são desenhados; ver MPSoCInformation.get_next_packet. +""" + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import (QComboBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QMessageBox, QPushButton, QRadioButton, QTabWidget, QVBoxLayout, QWidget) + +import analysis +from util.MPSoCConfig import MPSoCConfig + +# Serviço sempre descartado pelo filtro do Java +IGNORED_SERVICE = "SLACK_TIME_REPORT" + + +class _FilterTab(QWidget): + """Combo de itens + lista dos adicionados, com Add / Remove / Remove All / Apply / Close.""" + + def __init__(self, caption, added_caption, items, on_apply, on_close, extra_widget=None): + super().__init__() + self.on_apply = on_apply + + self.combo = QComboBox() + for label, value in items: + self.combo.addItem(label, value) + + self.added_list = QListWidget() + + add_button = QPushButton("Add") + remove_button = QPushButton("Remove") + remove_all_button = QPushButton("Remove All") + apply_button = QPushButton("Apply") + close_button = QPushButton("Close") + add_button.clicked.connect(self.add_selected) + remove_button.clicked.connect(self.remove_selected) + remove_all_button.clicked.connect(self.added_list.clear) + apply_button.clicked.connect(self._apply) + close_button.clicked.connect(on_close) + + left = QVBoxLayout() + left.addWidget(QLabel(caption)) + left.addWidget(self.combo) + left.addWidget(add_button) + left.addWidget(remove_button) + left.addWidget(remove_all_button) + if extra_widget is not None: + left.addWidget(extra_widget) + left.addStretch() + + right = QVBoxLayout() + right.addWidget(QLabel(added_caption)) + right.addWidget(self.added_list) + + lists = QHBoxLayout() + lists.addLayout(left) + lists.addLayout(right, 1) + + buttons = QHBoxLayout() + buttons.addStretch() + buttons.addWidget(apply_button) + buttons.addWidget(close_button) + + layout = QVBoxLayout(self) + layout.addLayout(lists) + layout.addLayout(buttons) + + def added_values(self): + return [self.added_list.item(row).data(Qt.ItemDataRole.UserRole) for row in range(self.added_list.count())] + + def add_selected(self): + value = self.combo.currentData() + if value is None or value in self.added_values(): + return + item = QListWidgetItem(self.combo.currentText()) + item.setData(Qt.ItemDataRole.UserRole, value) + self.added_list.addItem(item) + + def remove_selected(self): + for item in self.added_list.selectedItems(): + self.added_list.takeItem(self.added_list.row(item)) + + def _apply(self): + answer = QMessageBox.question(self, "Attention", "You are sure?") + if answer == QMessageBox.StandardButton.Yes: + self.on_apply(self.added_values()) + + +class FilterWindow(QWidget): + """ + Guarda o filtro aplicado e o expõe em filter(packet), usado como + SimulationController.packet_filter. A janela é criada uma vez por debug + carregado e apenas escondida ao fechar, mantendo as listas montadas. + """ + + def __init__(self, mpsoc_config, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.mpsoc_config = mpsoc_config + self.setWindowTitle("Service and PE Filter") + self.resize(480, 340) + + self.filtered_services = set() + self.filtered_pes = set() + self.filtered_apps = set() + self.except_services = False + self.known_apps = analysis.known_app_ids(mpsoc_config) + self.ignored_service = mpsoc_config.get_service_value(IGNORED_SERVICE) + + services = [(mpsoc_config.get_string_service_name(service), service) + for service in mpsoc_config.service_reference] + pes = [(self._pe_label(address), address) for address in range(mpsoc_config.get_pe_number())] + + self.only_radio = QRadioButton("Only") + self.except_radio = QRadioButton("Except") + self.only_radio.setChecked(True) + mode = QWidget() + mode_layout = QHBoxLayout(mode) + mode_layout.setContentsMargins(0, 0, 0, 0) + mode_layout.addWidget(self.only_radio) + mode_layout.addWidget(self.except_radio) + mode_layout.addStretch() + + self.services_tab = _FilterTab("Services", "Added Services", services, + self._apply_services, self.close, mode) + self.pe_tab = _FilterTab("PEs", "Added PEs", pes, self._apply_pes, self.close) + apps = [(analysis.app_label(mpsoc_config, app), app) for app in sorted(self.known_apps)] + self.app_tab = _FilterTab("Applications", "Added Applications", apps, self._apply_apps, self.close) + self.app_tab.setToolTip("Keeps only packets whose source or target task belongs to the selected " + "applications.\nKernel packets without task IDs are hidden.") + + self.status_label = QLabel() + self.status_label.setWordWrap(True) + + tabs = QTabWidget() + tabs.addTab(self.services_tab, "Services") + tabs.addTab(self.pe_tab, "PE") + tabs.addTab(self.app_tab, "Application") + + layout = QVBoxLayout(self) + layout.addWidget(tabs) + layout.addWidget(self.status_label) + self._update_status() + + def _pe_label(self, address): + if self.mpsoc_config.router_addressing == MPSoCConfig.XY: + return self.mpsoc_config.ham_address_to_xy_label(address) + return str(address) + + # ------------------------------------------ + # Apply + # ------------------------------------------ + def _apply_services(self, services): + self.filtered_services = set(services) + self.except_services = self.except_radio.isChecked() + self._update_status() + + def _apply_pes(self, pes): + self.filtered_pes = set(pes) + self._update_status() + + def _apply_apps(self, apps): + self.filtered_apps = set(apps) + self._update_status() + + def _update_status(self): + config = self.mpsoc_config + parts = [] + if self.filtered_services: + names = ", ".join(sorted(config.get_string_service_name(service) for service in self.filtered_services)) + parts.append(f"{'Except' if self.except_services else 'Only'} services: {names}") + if self.filtered_pes: + names = ", ".join(self._pe_label(address) for address in sorted(self.filtered_pes)) + parts.append(f"Only PEs: {names}") + if self.filtered_apps: + names = ", ".join(analysis.app_label(config, app) for app in sorted(self.filtered_apps)) + parts.append(f"Only applications: {names}") + self.status_label.setText("Active filter — " + "; ".join(parts) if parts else "No filter applied") + + # ------------------------------------------ + # Filtro + # ------------------------------------------ + def filter(self, packet): + """True se o pacote deve ser processado pela simulação.""" + if packet.service == self.ignored_service: + return False + + if self.filtered_services: + if (packet.service in self.filtered_services) == self.except_services: + return False + + # No Java o filtro de PE era ignorado quando havia filtro de serviço; + # aqui os dois valem juntos + if self.filtered_pes and packet.router_address not in self.filtered_pes: + return False + + if self.filtered_apps and not analysis.packet_apps(packet, self.known_apps) & self.filtered_apps: + return False + + return True diff --git a/help_dialogs.py b/help_dialogs.py new file mode 100644 index 0000000..c038269 --- /dev/null +++ b/help_dialogs.py @@ -0,0 +1,84 @@ +""" +Janelas do menu Help: "About" (util/AboutFrame.java) e "Packet Format" +(MainFrame.packetFormatMenuItemActionPerformed). +""" + +import os + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QPixmap +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QMessageBox, QVBoxLayout + +ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon") + +# Formato de cada linha do traffic_router.txt, como lido em information/read_traffic_data.py +PACKET_FORMAT = """\ +

Each line of traffic_router.txt is a packet header captured by a router, with tab-separated fields:

+

Time | Current Router Address | Service | Payload size | Packet bandwidth | Input port | +Target Router | Source task <optional> | Target task <optional>

+

Field description:

+
    +
  • Time: integer decimal value, the tick counter when the header entered the router by the input port.
  • +
  • Current Router Address: integer decimal value. With XY addressing, bits 0-7 (0xFF) are the +Y address and bits 8-15 (0xFF00) the X address; with Hamiltonian addressing, it is the router number.
  • +
  • Service: integer decimal value with the packet service (see services.cfg).
  • +
  • Payload size: integer decimal value, number of payload flits (the header adds 1 flit).
  • +
  • Packet bandwidth: integer decimal value, clock cycles from the moment the header enters the +router until the last flit of the packet leaves it.
  • +
  • Input port: integer decimal value, the port where the packet entered the router: +0-EAST 0, 1-EAST 1, 2-WEST 0, 3-WEST 1, 4-NORTH 0, 5-NORTH 1, 6-SOUTH 0, 7-SOUTH 1, 8-LOCAL 0, 9-LOCAL 1. +With channel_number 1 the port is 0-EAST, 1-WEST, 2-NORTH, 3-SOUTH, 4-LOCAL.
  • +
  • Target Router: same format as Current Router Address; the address in the header, i.e. the +router the packet is going to.
  • +
  • Source task: OPTIONAL, integer decimal ID of the producer task of TASK_ALLOCATION, +TASK_TERMINATED, MESSAGE_REQUEST and MESSAGE_DELIVERY packets.
  • +
  • Target task: OPTIONAL, same format as Source task, with the consumer task of those packets.
  • +
+

Original author: Marcelo Ruaro (mceloruaro@gmail.com)

+""" + + +def show_packet_format(parent): + box = QMessageBox(parent) + box.setWindowTitle("Packet Format") + box.setIcon(QMessageBox.Icon.Information) + box.setTextFormat(Qt.TextFormat.RichText) + box.setText(PACKET_FORMAT) + box.exec() + + +class AboutDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About") + + logo = QLabel() + pixmap = QPixmap(os.path.join(ICON_DIR, "gaph_logo.png")) + if not pixmap.isNull(): + logo.setPixmap(pixmap.scaledToWidth(min(pixmap.width(), 260), + Qt.TransformationMode.SmoothTransformation)) + logo.setAlignment(Qt.AlignmentFlag.AlignCenter) + + title = QLabel("GAPH") + title_font = QFont() + title_font.setPointSize(20) + title_font.setBold(True) + title.setFont(title_font) + + lines = [title, + QLabel("Grupo de Apoio de Pesquisa ao Hardware"), + QLabel("Pontifical Catholic University of Rio Grande do Sul (PUCRS)"), + QLabel("Support contact: fernando.moraes@pucrs.br"), + QLabel("Porto Alegre - Brasil"), + QLabel("Memphis-V Graphical Debugger — Python/PySide6 port")] + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(logo) + for label in lines: + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(label) + layout.addWidget(buttons) + layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetFixedSize) diff --git a/information/__init__.py b/information/__init__.py new file mode 100644 index 0000000..6f5585f --- /dev/null +++ b/information/__init__.py @@ -0,0 +1,25 @@ +""" +Modelo de dados do tráfego da NoC lido de traffic_router.txt. + +Refatoração em Python das classes do pacote "source" do GraphicalDebugger +original em Java (MPSoCInformation, RouterInformation, PacketInformation, +TaskInformation, PortInformation, RouterNeighbors e ReadTrafficData). +""" + +from information.packet_information import PacketInformation +from information.task_information import TaskInformation +from information.port_information import PortInformation +from information.router_neighbors import RouterNeighbors +from information.router_information import RouterInformation +from information.read_traffic_data import ReadTrafficData +from information.mpsoc_information import MPSoCInformation + +__all__ = [ + "PacketInformation", + "TaskInformation", + "PortInformation", + "RouterNeighbors", + "RouterInformation", + "ReadTrafficData", + "MPSoCInformation", +] diff --git a/information/mpsoc_information.py b/information/mpsoc_information.py new file mode 100644 index 0000000..acb8b0e --- /dev/null +++ b/information/mpsoc_information.py @@ -0,0 +1,82 @@ +from information.read_traffic_data import ReadTrafficData +from information.router_information import RouterInformation +from information.router_neighbors import RouterNeighbors + + +class MPSoCInformation: + """Estado de todos os roteadores do MPSoC, alimentado pelos pacotes de traffic_router.txt.""" + + def __init__(self, mpsoc_config): + self.mpsoc_config = mpsoc_config + self.initialize_pe_information() + self.neighbors = RouterNeighbors.from_config(mpsoc_config) + self.read_traffic = ReadTrafficData(mpsoc_config, self.neighbors) + + def initialize_pe_information(self): + self.pe_information = { + address: RouterInformation(self.mpsoc_config, address) + for address in range(self.mpsoc_config.get_pe_number()) + } + + def close(self): + self.read_traffic.close() + + def get_packet_counter_by_time(self, time): + """Retorna o número de pacotes até chegar no tempo desejado.""" + counter = self.read_traffic.get_packet_counter_by_time(time) + + if counter != -1: + self.read_traffic.reset_packet_counter() + + return counter + + def get_router_information(self, router_address): + return self.pe_information.get(router_address) + + def get_router_information_xy(self, x, y): + return self.get_router_information(self.neighbors.xy_to_ham_addr((x << 8) | y)) + + # ========================================== + # TOTAIS DA NOC + # ========================================== + def get_total_noc_volume(self): + return sum(router.get_router_total_volume_in_flits() for router in self.pe_information.values()) + + def get_total_noc_bandwidth(self): + return sum(router.get_router_total_bandwidth_in_cycles() for router in self.pe_information.values()) + + def get_total_noc_service_volume(self, services): + return sum(router.get_router_total_services_volume_in_flits(services) + for router in self.pe_information.values()) + + def get_total_noc_service_bandwidth(self, services): + return sum(router.get_router_total_services_bandwidth_in_cycles(services) + for router in self.pe_information.values()) + + def get_next_packet(self, packet_filter=None, limit_time=0): + """ + Lê o próximo pacote e o registra no roteador em que foi capturado. + + packet_filter é uma função (packet -> bool); pacotes rejeitados são + pulados enquanto estiverem antes de limit_time (0 = sem limite). + Retorna None quando não há mais pacotes. + """ + while True: + packet = self.read_traffic.get_next_packet() + + if packet is None: + return None + + # Serviços desconhecidos são repassados sem atualizar os roteadores + if packet.service not in self.mpsoc_config.services_hash: + return packet + + if packet_filter is not None and not packet_filter(packet): + if limit_time == 0 or limit_time > packet.time: + continue + + router = self.pe_information.get(packet.router_address) + if router is not None: + router.add_packet(packet) + + return packet diff --git a/information/packet_information.py b/information/packet_information.py new file mode 100644 index 0000000..ff5ae6c --- /dev/null +++ b/information/packet_information.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, field + +from util.MPSoCConfig import MPSoCConfig + + +@dataclass +class PacketInformation: + """Um pacote registrado em traffic_router.txt ao passar por um roteador.""" + + router_address: int + time: int + service: int + size: int + bandwidth_cycles: int + input_port: int + target_router: int + task_source: int = -1 + task_target: int = -1 + channel: int = field(init=False) + + def __post_init__(self): + # O canal é definido pela porta de entrada (portas pares = HIGH) + self.channel = MPSoCConfig.get_channel(self.input_port) + + def get_input_port_string(self): + return MPSoCConfig.get_port_string(self.input_port) + + def get_channel_string(self): + return "HIGH" if self.channel == MPSoCConfig.HIGH else "LOW" + + def print_packet(self): + print("*********PACKET************") + print(f"router_address: {self.router_address}") + print(f"time: {self.time}") + print(f"service: {self.service}") + print(f"size: {self.size}") + print(f"bandwidth cycles: {self.bandwidth_cycles}") + print(f"channel: {self.get_channel_string()}") + print(f"input_port: {self.get_input_port_string()}") + print(f"target_router: {self.target_router}") + print(f"task_source: {self.task_source}") + print(f"task_target: {self.task_target}") diff --git a/information/port_information.py b/information/port_information.py new file mode 100644 index 0000000..a80465f --- /dev/null +++ b/information/port_information.py @@ -0,0 +1,40 @@ +from collections import defaultdict + + +class PortInformation: + """Volume e largura de banda acumulados em uma porta de um roteador.""" + + def __init__(self, mpsoc_config, channel): + self.channel = channel + self.total_volume = 0 + self.total_bandwidth = 0 + self.throughput = 0 + self.bandwidth_throughput = 0 + + # Serviços desconhecidos começam em zero em vez de gerar erro + self.service_volume = defaultdict(int, dict.fromkeys(mpsoc_config.service_reference, 0)) + self.service_bandwidth = defaultdict(int, dict.fromkeys(mpsoc_config.service_reference, 0)) + + def add_new_information(self, packet): + volume = packet.size + 1 # +1 por causa do header + + self.total_volume += volume + self.throughput += volume + + self.total_bandwidth += packet.bandwidth_cycles + self.bandwidth_throughput += packet.bandwidth_cycles + + self.service_volume[packet.service] += volume + self.service_bandwidth[packet.service] += packet.bandwidth_cycles + + def get_service_volume_in_flits(self, service): + return self.service_volume.get(service, 0) + + def get_service_bandwidth_in_cycles(self, service): + return self.service_bandwidth.get(service, 0) + + def reset_port_throughput(self): + self.throughput = 0 + + def reset_port_bandwidth_throughput(self): + self.bandwidth_throughput = 0 diff --git a/information/read_traffic_data.py b/information/read_traffic_data.py new file mode 100644 index 0000000..00a8666 --- /dev/null +++ b/information/read_traffic_data.py @@ -0,0 +1,96 @@ +import os +from bisect import bisect_right + +from util.MPSoCConfig import MPSoCConfig +from information.packet_information import PacketInformation + + +class ReadTrafficData: + """ + Lê os pacotes de traffic_router.txt sob demanda. + + Os pacotes já lidos ficam em all_packets, assim é possível voltar no tempo + (reset_packet_counter) sem precisar reler o arquivo. + """ + + def __init__(self, mpsoc_config, neighbors): + self.mpsoc_config = mpsoc_config + self.neighbors = neighbors + self.packet_read_control = 0 + self.all_packets = [] + # Arquivo ainda sendo gravado pelo simulador: uma linha sem "\n" no + # fim está incompleta e só é lida quando terminar de ser gravada + self.follow = False + + traffic_path = os.path.join(mpsoc_config.debug_file_path, "traffic_router.txt") + # Gera FileNotFoundError caso o arquivo não exista + self.traffic = open(traffic_path, "r", encoding="utf-8") + + def close(self): + self.traffic.close() + + def reset_packet_counter(self): + self.packet_read_control = 0 + + def get_packet_counter_by_time(self, time): + """Retorna quantos pacotes já lidos ocorreram até o tempo informado (-1 se fora do intervalo).""" + if not self.all_packets or time < 0 or self.all_packets[-1].time < time: + return -1 + + return bisect_right(self.all_packets, time, key=lambda packet: packet.time) + + def get_next_packet(self): + # Ainda não lido: busca a próxima linha válida do arquivo + if self.packet_read_control == len(self.all_packets): + while True: + # tell() em arquivo texto é lento: só é preciso para desfazer uma linha incompleta + position = self.traffic.tell() if self.follow else None + line = self.traffic.readline() + if not line: + return None # fim do arquivo (por enquanto, no modo follow) + if self.follow and not line.endswith("\n"): + self.traffic.seek(position) + return None + packet = self._parse_packet(line) + if packet is not None: + self.all_packets.append(packet) + self.packet_read_control += 1 + return packet + + packet = self.all_packets[self.packet_read_control] + self.packet_read_control += 1 + return packet + + def _parse_packet(self, line): + fields = line.strip().split("\t") + if fields == [""]: + return None + + try: + time = int(fields[0]) + router_address = self._extract_router_address(fields[1]) + service = int(fields[2]) + size = int(fields[3]) + bandwidth_cycles = int(fields[4]) + + if self.mpsoc_config.channel_number == 1: + # Suporte a canal físico duplicado + input_port = int(fields[5]) * 2 + 1 + else: + input_port = int(fields[5]) + + target_router = self._extract_router_address(fields[6]) + task_source = int(fields[7]) if len(fields) > 7 else -1 + task_target = int(fields[8]) if len(fields) > 8 else -1 + except (ValueError, IndexError): + print(f"WARNING: Wrong packet format in traffic_router.txt: {line.strip()}") + return None + + return PacketInformation(router_address, time, service, size, bandwidth_cycles, + input_port, target_router, task_source, task_target) + + def _extract_router_address(self, value): + address = int(value) + if self.mpsoc_config.router_addressing == MPSoCConfig.XY: + return self.neighbors.xy_to_ham_addr(address) + return address diff --git a/information/router_information.py b/information/router_information.py new file mode 100644 index 0000000..62f5675 --- /dev/null +++ b/information/router_information.py @@ -0,0 +1,104 @@ +from util.MPSoCConfig import MPSoCConfig +from information.port_information import PortInformation +from information.task_information import TaskInformation + + +class RouterInformation: + """Pacotes, portas e eventos de tarefa acumulados em um roteador (PE).""" + + def __init__(self, mpsoc_config, router_address): + self.router_address = router_address + self.mpsoc_config = mpsoc_config + self.task_counter = 0 + self.packets = [] + self.tasks = [] + self.port_informations = [ + PortInformation(mpsoc_config, MPSoCConfig.get_channel(port)) + for port in range(MPSoCConfig.NPORT) + ] + + def add_packet(self, packet): + self.packets.append(packet) + self.port_informations[packet.input_port].add_new_information(packet) + self._update_task_information(packet) + + def _update_task_information(self, packet): + config = self.mpsoc_config + + # Pacote está entrando no PE + if packet.target_router == self.router_address: + if packet.service in config.task_allocation_services: + self.task_counter += 1 + self.tasks.append(TaskInformation(packet.task_source, "ALLOCATED", packet.time)) + elif packet.service == config.get_service_value("MESSAGE_DELIVERY"): + self.tasks.append(TaskInformation(packet.task_target, "MESSAGE_DELIVERY", + packet.time, packet.task_source)) + + # Pacote está saindo do PE + elif packet.input_port in (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1): + if packet.service in config.task_terminated_services: + # No Memphis-V a tarefa terminada vem no último campo (task_source = -1); + # no formato do HeMPS, usado pelo Java, vinha no penúltimo + task_id = packet.task_source if packet.task_source >= 0 else packet.task_target + self.tasks.append(TaskInformation(task_id, "TERMINATED", packet.time)) + elif packet.service == config.get_service_value("MESSAGE_REQUEST"): + self.tasks.append(TaskInformation(packet.task_target, "MESSAGE_REQUEST", + packet.time, packet.task_source)) + + def get_tasks_information(self): + return self.tasks + + def get_task_number(self): + return self.task_counter + + # ========================================== + # TOTAIS DO ROTEADOR (SOMA DE TODAS AS PORTAS) + # ========================================== + def get_router_total_throughput_in_flits(self): + return sum(port.throughput for port in self.port_informations) + + def get_router_total_bandwidth_throughput_in_cycles(self): + return sum(port.bandwidth_throughput for port in self.port_informations) + + def get_router_total_volume_in_flits(self): + return sum(port.total_volume for port in self.port_informations) + + def get_router_total_bandwidth_in_cycles(self): + return sum(port.total_bandwidth for port in self.port_informations) + + def get_router_total_services_volume_in_flits(self, services): + return sum(self.get_port_volume_in_flits(port, services) for port in range(MPSoCConfig.NPORT)) + + def get_router_total_services_bandwidth_in_cycles(self, services): + return sum(self.get_port_bandwidth_in_cycles(port, services) for port in range(MPSoCConfig.NPORT)) + + # ========================================== + # INFORMAÇÕES POR PORTA + # ========================================== + def get_port_total_volume_in_flits(self, port): + return self.port_informations[port].total_volume + + def get_port_total_bandwidth_in_cycles(self, port): + return self.port_informations[port].total_bandwidth + + def get_port_volume_in_flits(self, port, services): + port_info = self.port_informations[port] + return sum(port_info.get_service_volume_in_flits(service) for service in services) + + def get_port_bandwidth_in_cycles(self, port, services): + port_info = self.port_informations[port] + return sum(port_info.get_service_bandwidth_in_cycles(service) for service in services) + + def get_port_throughput_in_flits(self, port): + return self.port_informations[port].throughput + + def get_port_bandwidth_throughput_in_cycles(self, port): + return self.port_informations[port].bandwidth_throughput + + def reset_throughput(self): + for port in self.port_informations: + port.reset_port_throughput() + + def reset_bandwidth_throughput(self): + for port in self.port_informations: + port.reset_port_bandwidth_throughput() diff --git a/information/router_neighbors.py b/information/router_neighbors.py new file mode 100644 index 0000000..fdd67e7 --- /dev/null +++ b/information/router_neighbors.py @@ -0,0 +1,59 @@ +class RouterNeighbors: + """ + Calcula os vizinhos de um roteador a partir do seu endereço hamiltoniano. + Os métodos retornam -1 quando o vizinho não existe (borda da malha). + """ + + def __init__(self, x_dimension, y_dimension): + self.x_dimension = x_dimension + self.y_dimension = y_dimension + + @classmethod + def from_config(cls, mpsoc_config): + return cls(mpsoc_config.mpsoc_x, mpsoc_config.mpsoc_y) + + def xy_to_ham_addr(self, xy_addr): + xy_addr &= 0xFFFF # limpa o header + y = xy_addr & 0xFF + x = xy_addr >> 8 + + if y % 2 == 1: + return (y * self.x_dimension) + (self.x_dimension - x) - 1 + return (y * self.x_dimension) + x + + def ham_to_xy_addr(self, ham_addr): + ham_addr &= 0xFF # limpa o header + y, addr = divmod(ham_addr, self.x_dimension) + + x = self.x_dimension - addr - 1 if y % 2 == 1 else addr + return (x << 8) | y + + def xy_address_to_xy_label(self, ham_addr): + xy_addr = self.ham_to_xy_addr(ham_addr) + return f"{xy_addr >> 8}{xy_addr & 0xFF}" + + def get_x_coordinate(self, ham_addr): + return self.ham_to_xy_addr(ham_addr) >> 8 + + def get_y_coordinate(self, ham_addr): + return self.ham_to_xy_addr(ham_addr) & 0xFF + + def _neighbor(self, ham_addr, dx, dy): + x = self.get_x_coordinate(ham_addr) + dx + y = self.get_y_coordinate(ham_addr) + dy + + if 0 <= x < self.x_dimension and 0 <= y < self.y_dimension: + return self.xy_to_ham_addr((x << 8) | y) + return -1 + + def get_vizinho_cima(self, ham_addr): + return self._neighbor(ham_addr, 0, 1) + + def get_vizinho_baixo(self, ham_addr): + return self._neighbor(ham_addr, 0, -1) + + def get_vizinho_esquerda(self, ham_addr): + return self._neighbor(ham_addr, -1, 0) + + def get_vizinho_direita(self, ham_addr): + return self._neighbor(ham_addr, 1, 0) diff --git a/information/task_information.py b/information/task_information.py new file mode 100644 index 0000000..9c88457 --- /dev/null +++ b/information/task_information.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass +class TaskInformation: + """Um evento de tarefa (alocação, término, mensagem) ocorrido em um PE.""" + + id: int + service: str + time: int + remote_task_id: int = -1 + + @property + def task_id(self): + # Os 8 bits menos significativos identificam a tarefa dentro da aplicação + return self.id & 0xFF + + @property + def app_id(self): + return self.id >> 8 diff --git a/main.py b/main.py index fb349f0..bb48294 100644 --- a/main.py +++ b/main.py @@ -2,20 +2,27 @@ import sys from PySide6.QtGui import Qt, QPalette, QColor, QAction, QKeySequence -from PySide6.QtWidgets import (QApplication, QMainWindow, QFileDialog, QMessageBox, QWidget, - QVBoxLayout, QHBoxLayout, QScrollArea, QTableWidgetItem, QTableWidget, QLabel, +from PySide6.QtWidgets import (QApplication, QMainWindow, QFileDialog, QMessageBox, QWidget, QInputDialog, + QVBoxLayout, QHBoxLayout, QTableWidgetItem, QTableWidget, QLabel, QHeaderView) import theme from ui_mainwindow import MainWindow -from commsUi import Ui_Form -from taskmap import Ui_TaskMap from util import MPSoCConfig from information import MPSoCInformation from router_matrix import RouterMatrixWidget from simulation import SimulationController, MAX_REPAINT_SPEED, ticks_to_ms -from slave_matrix import SlaveMatrixWidget -from pe_matrix import PEMatrixWidget +from overview_windows import CommunicationOverviewWindow, MessageLogWindow, TaskMappingWindow +from deloream import DeloreamWindow +from statistics_windows import ApplicationTimelineWindow, TrafficStatisticsWindow +from router_info_window import RouterInfoWindow +from platform_setup import PlatformSetupDialog +from filter_window import FilterWindow +from help_dialogs import AboutDialog, show_packet_format +import projects +import export +import path_view +import testcase_files class MinhaJanela(QMainWindow, MainWindow): @@ -23,8 +30,18 @@ class MinhaJanela(QMainWindow, MainWindow): mpconfig = None mpsoc_information = None router_matrix_widget = None - frame_scroll_area = None simulation = None + communication_window = None + task_mapping_window = None + message_log_window = None + deloream_window = None + router_info_windows = None + simulation_ticks = 0 + filter_window = None + traffic_statistics_window = None + timeline_window = None + + WINDOW_TITLE = "Memphis-V Graphical Debugger" # Arquivos obrigatórios em um diretório de debug REQUIRED_FILES = ("platform.cfg", "services.cfg", "traffic_router.txt") @@ -35,6 +52,7 @@ class MinhaJanela(QMainWindow, MainWindow): def __init__(self): super().__init__() self.setupUi(self) + self.setWindowTitle(self.WINDOW_TITLE) # --------------------------------------------------------- # SISTEMA DE TEMA (DARK/LIGHT MODE) @@ -59,6 +77,50 @@ def __init__(self): self.actionMessage_Log.setEnabled(False) self.actionTask_List.setEnabled(False) + # Ferramentas baseadas nas métricas do artigo do Memphis (não existem no Java) + self.actionTraffic_Statistics = QAction("Traffic Statistics", self) + self.actionApplication_Timeline = QAction("Application Timeline", self) + self.menuTools.insertAction(self.actionDeloream, self.actionTraffic_Statistics) + self.menuTools.insertAction(self.actionDeloream, self.actionApplication_Timeline) + self.menuTools.insertSeparator(self.actionDeloream) + for action in (self.actionTraffic_Statistics, self.actionApplication_Timeline): + action.setEnabled(False) + self.actionTraffic_Statistics.triggered.connect(self.open_traffic_statistics) + self.actionApplication_Timeline.triggered.connect(self.open_application_timeline) + + # Atalhos do menu File iguais aos do Java (o .ui só define Ctrl+N e Ctrl+S) + self.actionRest_Graphical_Path.setText("Reset Graphical Path") + for action, shortcut in ((self.actionOpen_Project, "Ctrl+O"), (self.actionDelete_Project, "Ctrl+D"), + (self.actionReset_Simulation, "Ctrl+R"), (self.actionRest_Graphical_Path, "Ctrl+G"), + (self.actionExit, "Ctrl+Q")): + action.setShortcut(QKeySequence(shortcut)) + + self.actionSave_Project.triggered.connect(self.save_project) + self.actionOpen_Project.triggered.connect(self.open_project) + self.actionDelete_Project.triggered.connect(self.delete_project) + self.actionAbout.triggered.connect(lambda: AboutDialog(self).exec()) + self.actionPacket_Format.triggered.connect(lambda: show_packet_format(self)) + + # Imagem da malha inteira (mesmo a parte fora da área visível) + self.actionSave_Mesh_PNG = QAction("Save Mesh as PNG…", self) + self.menuFile.insertAction(self.actionExit, self.actionSave_Mesh_PNG) + self.actionSave_Mesh_PNG.triggered.connect(self.save_mesh_png) + + # Acompanhar um traffic_router.txt que o simulador ainda está gravando + self.actionFollow_Live_Trace = QAction("Follow Live Trace", self) + self.actionFollow_Live_Trace.setCheckable(True) + self.actionFollow_Live_Trace.setToolTip("At the end of traffic_router.txt, keep waiting for new packets " + "instead of stopping (for a simulation still running)") + self.menuFile.insertAction(self.actionSave_Mesh_PNG, self.actionFollow_Live_Trace) + self.actionFollow_Live_Trace.toggled.connect(self.set_follow_live_trace) + + # As janelas de análise mostram caminhos de pacotes na malha desta janela + path_view.set_handler(self.show_packet_path) + + self.actionPrint_Link_Usage = QAction("Print Router Total Link Usage", self) + self.menuEdit.addAction(self.actionPrint_Link_Usage) + self.actionPrint_Link_Usage.triggered.connect(self.configure_link_usage_print) + self.actionExit.triggered.connect(self.close) self.actionNew_Debugging.triggered.connect(self.open_file) self.horizontalSlider.valueChanged.connect(self.update_slide) @@ -67,6 +129,11 @@ def __init__(self): self.actionTask_Mapping_Overview.triggered.connect(self.open_taskmap) self.actionTask_List.triggered.connect(self.taskList) self.actionServices_List.triggered.connect(self.servicesList) + self.actionMessage_Log.triggered.connect(self.open_message_log) + self.actionDeloream.triggered.connect(self.open_deloream) + self.actionPlatform_Setup.triggered.connect(self.open_platform_setup) + self.actionService_and_PE_Filter.setShortcut(QKeySequence("Ctrl+F")) + self.actionService_and_PE_Filter.triggered.connect(self.open_filter) # --------------------------------------------------------- # CONTROLE DA SIMULAÇÃO @@ -124,15 +191,120 @@ def open_communication(self): if self.filePath == "": QMessageBox.warning(self, "Attention", "Please, load a debugging before") return - self.janela_secundaria = NovaJanela(self.mpconfig) - self.janela_secundaria.show() + # Reaproveita a janela se já estiver aberta + if self.communication_window is None or not self.communication_window.isVisible(): + self.communication_window = CommunicationOverviewWindow( + self.mpconfig, self.mpsoc_information, self.simulation) + self.communication_window.show() + self.communication_window.raise_() + self.communication_window.activateWindow() + + def open_deloream(self): + if self.filePath == "": + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + + testcase_path = self.mpconfig.get_testcase_path() + if testcase_path is None: + QMessageBox.warning(self, "Deloream", + "The debug directory must be named \"debug\" and be inside the " + "testcase folder (the one that contains the log/ folder).") + return + + # Mesma janela enquanto estiver aberta; outro cenário recarrega a árvore + if self.deloream_window is None or not self.deloream_window.isVisible(): + self.deloream_window = DeloreamWindow(testcase_path) + elif self.deloream_window.testcase_path != testcase_path: + self.deloream_window.load_testcase(testcase_path) + self.deloream_window.show() + self.deloream_window.raise_() + self.deloream_window.activateWindow() + + def open_platform_setup(self): + if self.simulation is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + if PlatformSetupDialog(self.mpconfig, self.simulation.checkpoint, self).exec(): + # O tempo em ms depende do período de clock + self.update_simulation_time(self.simulation_ticks) + + def open_filter(self): + if self.simulation is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + # Como no Java, apaga o caminho desenhado antes de trocar o filtro + self.simulation.reset_graphical_path() + self.filter_window.show() + self.filter_window.raise_() + self.filter_window.activateWindow() + + def _show_window(self, window): + window.show() + window.raise_() + window.activateWindow() + + def open_traffic_statistics(self): + if self.simulation is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + window = self.traffic_statistics_window + if window is None or not window.isVisible(): + window = TrafficStatisticsWindow(self.mpconfig, self.mpsoc_information, self.simulation) + self.traffic_statistics_window = window + self._show_window(window) + + def open_application_timeline(self): + if self.simulation is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + window = self.timeline_window + if window is None or not window.isVisible(): + window = ApplicationTimelineWindow(self.mpconfig, self.simulation, self) + self.timeline_window = window + self._show_window(window) + + def open_message_log(self): + if self.filePath == "": + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + if self.message_log_window is None or not self.message_log_window.isVisible(): + self.message_log_window = MessageLogWindow( + self.mpconfig, self.mpsoc_information, self.simulation) + self.message_log_window.show() + self.message_log_window.raise_() + self.message_log_window.activateWindow() def open_taskmap(self): if self.filePath == "": QMessageBox.warning(self, "Attention", "Please, load a debugging before") return - self.janela_taskmap = TaskMapWindow(self.mpconfig) - self.janela_taskmap.show() + if self.task_mapping_window is None or not self.task_mapping_window.isVisible(): + self.task_mapping_window = TaskMappingWindow( + self.mpconfig, self.mpsoc_information, self.simulation) + self.task_mapping_window.show() + self.task_mapping_window.raise_() + self.task_mapping_window.activateWindow() + + def open_router_info(self, router_address): + """Abre a janela "Router Information" do roteador clicado (uma por roteador).""" + if self.router_info_windows is None: + self.router_info_windows = {} + + window = self.router_info_windows.get(router_address) + if window is None: + window = RouterInfoWindow(self.mpconfig, self.mpsoc_information, router_address, self.simulation) + window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + window.destroyed.connect(lambda *_, address=router_address: + self.router_info_windows.pop(address, None)) + self.router_info_windows[router_address] = window + window.show() + window.raise_() + window.activateWindow() + + def close_router_info_windows(self): + windows = self.router_info_windows or {} + while windows: + windows.popitem()[1].close() def update_slide(self, valor): self.label_2.setText(str(valor)) @@ -141,9 +313,11 @@ def update_slide(self, valor): def open_file(self): caminho = QFileDialog.getExistingDirectory(self, "Open Debug Directory", "./") - if caminho == "": - return + if caminho != "": + self.load_debug(caminho) + def load_debug(self, caminho, title=None): + """Carrega um diretório de debug. Retorna False se ele não for válido.""" # Verifica se o diretório possui todos os arquivos necessários faltando = [arquivo for arquivo in self.REQUIRED_FILES if not os.path.isfile(os.path.join(caminho, arquivo))] @@ -154,25 +328,151 @@ def open_file(self): "O diretório selecionado não é um diretório de debug valido.\n\n" "Selecione um diretório valido" ) - return + return False self.filePath = caminho - if self.filePath != "": - self.actionCommunication_Overview.setEnabled(True) - self.actionDeloream.setEnabled(True) - self.actionTask_Mapping_Overview.setEnabled(True) - self.actionServices_List.setEnabled(True) - self.actionMessage_Log.setEnabled(True) - self.actionTask_List.setEnabled(True) - - self.mpconfig = MPSoCConfig.MPSoCConfig(self.filePath) - - # Fecha o traffic_router.txt do debug anterior antes de abrir o novo - if self.mpsoc_information is not None: - self.mpsoc_information.close() - self.mpsoc_information = MPSoCInformation(self.mpconfig) - self.build_router_matrix() - self.start_simulation() + self.actionCommunication_Overview.setEnabled(True) + self.actionDeloream.setEnabled(True) + self.actionTask_Mapping_Overview.setEnabled(True) + self.actionServices_List.setEnabled(True) + self.actionMessage_Log.setEnabled(True) + self.actionTask_List.setEnabled(True) + self.actionTraffic_Statistics.setEnabled(True) + self.actionApplication_Timeline.setEnabled(True) + + self.mpconfig = MPSoCConfig.MPSoCConfig(self.filePath) + + # Fecha o traffic_router.txt do debug anterior antes de abrir o novo + if self.mpsoc_information is not None: + self.mpsoc_information.close() + self.mpsoc_information = MPSoCInformation(self.mpconfig) + self.build_router_matrix() + self.start_simulation() + + self.setWindowTitle(f"{self.WINDOW_TITLE}: {title or self.mpconfig.get_testcase_path() or caminho}") + return True + + # ========================================== + # PROJETOS (.hdf) + # ========================================== + def save_project(self): + if self.mpconfig is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + + name = "" + while True: + name, ok = QInputDialog.getText(self, "Save Project", "Inform the debugging project name", text=name) + if not ok: + return + name = name.strip() + error = projects.validate_name(name) + if error is None: + break + QMessageBox.critical(self, "Error", error) + + if os.path.exists(projects.project_path(name)): + answer = QMessageBox.question(self, "Save Project", f"Project {name} already exists. Overwrite it?") + if answer != QMessageBox.StandardButton.Yes: + return + + try: + projects.save_project(name, self.filePath) + except OSError as error: + QMessageBox.critical(self, "Save Project", f"Could not save the project {name}:\n{error}") + return + self.setWindowTitle(f"{self.WINDOW_TITLE}: {name}") + self.statusbar.showMessage(f"Project saved in {projects.project_path(name)}", 5000) + + def _choose_project(self, title, accept_label=None): + dialog = QFileDialog(self, title, projects.projects_dir(), projects.PROJECT_FILTER) + dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + if accept_label is not None: + dialog.setLabelText(QFileDialog.DialogLabel.Accept, accept_label) + if dialog.exec() and dialog.selectedFiles(): + return dialog.selectedFiles()[0] + return None + + def open_project(self): + path = self._choose_project("Open Project") + if path is None: + return + try: + debug_dir = projects.read_project(path) + except OSError: + QMessageBox.critical(self, "Error", "File not exists") + return + + if not os.path.isdir(debug_dir): + QMessageBox.critical(self, "Open Project", + f"The debug directory of project {projects.project_name(path)} no longer exists:\n" + f"{debug_dir or '(empty file)'}") + return + self.stop_simulation() + self.load_debug(debug_dir, projects.project_name(path)) + + def delete_project(self): + path = self._choose_project("Delete Project", "Delete") + if path is None: + return + name = projects.project_name(path) + answer = QMessageBox.question(self, "Delete Project", f"Delete the project {name}?") + if answer != QMessageBox.StandardButton.Yes: + return + try: + os.remove(path) + except OSError: + QMessageBox.warning(self, "Attention", f"Error during the deletion of project: {name}") + return + QMessageBox.information(self, "Delete Project", f"Project {name} successfully deleted!") + + def configure_link_usage_print(self): + """ + "Print Router Total Link Usage" do Java: a cada checkpoint imprime no + terminal a soma do uso dos enlaces do roteador escolhido (ou o tempo). + """ + if self.simulation is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + + current = self.simulation.link_usage_router + default = "" if current < 0 else self._router_label(current) + label, ok = QInputDialog.getText(self, "Print Router Total Link Usage", + "Inform the router address (empty to stop printing)", text=default) + if not ok: + return + label = label.strip() + if label == "": + self.simulation.link_usage_router = -1 + return + try: + if self.mpconfig.router_addressing == MPSoCConfig.MPSoCConfig.XY: + address = self.mpconfig.xy_label_to_ham_address(label) + else: + address = int(label) + if not 0 <= address < self.mpconfig.get_pe_number(): + raise ValueError + except ValueError: + QMessageBox.warning(self, "Attention", f"Invalid router address: {label}") + return + + mode, ok = QInputDialog.getItem(self, "Print Router Total Link Usage", "Inform the printing type:", + ["1 - Link Usage", "2 - Time"], 0, False) + if not ok: + return + self.simulation.link_usage_router = address + self.simulation.print_link_usage = mode.startswith("1") + + def save_mesh_png(self): + if self.router_matrix_widget is None: + QMessageBox.warning(self, "Attention", "Please, load a debugging before") + return + export.export_png(self, self.router_matrix_widget, f"mesh_{self.simulation_ticks}_ticks") + + def _router_label(self, address): + if self.mpconfig.router_addressing == MPSoCConfig.MPSoCConfig.XY: + return self.mpconfig.ham_address_to_xy_label(address) + return str(address) def build_router_matrix(self): """ @@ -183,6 +483,9 @@ def build_router_matrix(self): if self.mpconfig is None: return + # Janelas de roteador pertencem ao debug anterior + self.close_router_info_windows() + # Cria a matriz de roteadores com base na configuração carregada self.router_matrix_widget = RouterMatrixWidget( self.mpconfig.mpsoc_x, @@ -192,35 +495,25 @@ def build_router_matrix(self): mpsoc_config=self.mpconfig, ) self.router_matrix_widget.apply_theme(self.is_dark_mode) - - # Primeira vez: cria um QScrollArea dentro do self.frame para - # comportar malhas maiores que a área visível da janela. - if self.frame_scroll_area is None: - self.frame_scroll_area = QScrollArea(self.frame) - # IMPORTANTE: widgetResizable=False. Com True, o Qt estica a - # matriz para preencher todo o frame e, sem stretch factor nas - # colunas/linhas do QGridLayout, esse espaço extra é distribuído - # como gaps entre os roteadores. Com False, a matriz mantém seu - # tamanho natural (roteadores colados) e o scroll aparece só se - # ela for maior que a área visível. - self.frame_scroll_area.setWidgetResizable(False) - self.frame_scroll_area.setAlignment(Qt.AlignmentFlag.AlignCenter) - + for router in self.router_matrix_widget.routers.values(): + router.clicked.connect(self.open_router_info) + + # Nomes dos periféricos nas bordas, do YAML do testcase (sem ele, a borda é + # marcada quando passar o primeiro pacote vindo de um periférico) + peripherals, _yaml_path = testcase_files.read_peripherals(self.mpconfig) + self.router_matrix_widget.set_peripherals(peripherals) + + # A malha é uma QGraphicsView (tem rolagem e zoom próprios) e ocupa + # todo o self.frame, substituindo a matriz de um debug anterior + frame_layout = self.frame.layout() + if frame_layout is None: frame_layout = QVBoxLayout(self.frame) frame_layout.setContentsMargins(0, 0, 0, 0) - frame_layout.addWidget(self.frame_scroll_area) - self.frame.setLayout(frame_layout) - - # Troca (ou define) o widget interno do scroll pela nova matriz, - # substituindo uma matriz antiga caso um novo arquivo seja aberto. - old_widget = self.frame_scroll_area.takeWidget() - if old_widget is not None: - old_widget.deleteLater() - - # Garante que a matriz assuma seu tamanho real (layout aplicado) - # antes de ser exibida dentro do scroll area sem resize forçado. - self.router_matrix_widget.adjustSize() - self.frame_scroll_area.setWidget(self.router_matrix_widget) + while frame_layout.count(): + old_widget = frame_layout.takeAt(0).widget() + if old_widget is not None: + old_widget.deleteLater() + frame_layout.addWidget(self.router_matrix_widget) # ========================================== # SIMULAÇÃO DOS ROTEADORES @@ -234,11 +527,28 @@ def start_simulation(self): self.simulation = SimulationController(self.mpconfig, self.mpsoc_information, self.router_matrix_widget, self) self.simulation.set_speed(self.horizontalSlider.value()) + + # Filtro de serviços/PEs: um por debug carregado (as listas voltam a vazio) + if self.filter_window is not None: + self.filter_window.close() + self.filter_window.deleteLater() + self.filter_window = FilterWindow(self.mpconfig, self) + self.simulation.packet_filter = self.filter_window.filter + + # Janelas de visão geral abertas passam a acompanhar o novo debug + for window in (self.communication_window, self.task_mapping_window, self.message_log_window, + self.traffic_statistics_window): + if window is not None and window.isVisible(): + window.set_information(self.mpconfig, self.mpsoc_information, self.simulation) + if self.timeline_window is not None and self.timeline_window.isVisible(): + self.timeline_window.set_information(self.mpconfig, self.simulation) self.simulation.time_changed.connect(self.update_simulation_time) self.simulation.packet_changed.connect(self.update_current_packet_table) self.simulation.running_changed.connect(self.on_simulation_running_changed) self.simulation.finished.connect(self.on_simulation_finished) self.simulation.unknown_service.connect(self.on_unknown_service) + self.simulation.waiting_for_data.connect(self.on_waiting_for_data) + self.simulation.set_follow(self.actionFollow_Live_Trace.isChecked()) self.update_simulation_time(0) self.reset_current_packet_table() @@ -319,6 +629,29 @@ def on_simulation_running_changed(self, running): def on_simulation_finished(self): self.statusbar.showMessage("End of traffic_router.txt reached", 5000) + def set_follow_live_trace(self, follow): + if self.simulation is not None: + self.simulation.set_follow(follow) + + def on_waiting_for_data(self, waiting): + if waiting: + self.statusbar.showMessage("Following traffic_router.txt: waiting for new packets…") + else: + self.statusbar.clearMessage() + + def show_packet_path(self, hops, target, highlight, description): + """Pinta na malha o caminho vindo de uma janela de análise (ver path_view).""" + if self.simulation is None: + return + self.simulation.show_path(hops, target, highlight) + if self.router_matrix_widget is not None and highlight is not None: + router = self.router_matrix_widget.get_router_by_address(highlight) + if router is not None: + self.router_matrix_widget.ensure_router_visible(router) + self.statusbar.showMessage(description or "Packet path", 10000) + self.raise_() + self.activateWindow() + def on_unknown_service(self, packet): if self.mpconfig.router_addressing == MPSoCConfig.MPSoCConfig.XY: router = self.mpconfig.ham_address_to_xy_label(packet.router_address) @@ -359,6 +692,7 @@ def build_speed_control(self): self.horizontalLayout_4.addLayout(speed_layout) def update_simulation_time(self, ticks): + self.simulation_ticks = ticks clock_period = self.mpconfig.clock_period_in_ns if self.mpconfig else 0 self.simulation_time_label.setText(f"{ticks_to_ms(ticks, clock_period):.5f} ms") self.simulation_ticks_label.setText(f"{ticks} ticks") @@ -417,69 +751,6 @@ def create_table_window(self, title, headers, rows): return table_frame -class NovaJanela(QWidget, Ui_Form): - def __init__(self, mpconfig=None): - super().__init__() - self.setupUi(self) - self.mpconfig = mpconfig - self.checkBox.checkStateChanged.connect(self.on_checkbox_changed) - - self.build_slave_matrix() - - def build_slave_matrix(self): - """ - Monta a matriz de slaves dentro do scrollArea gerado por commsUi.py - (self.scrollAreaWidgetContents), usando mpsoc_x/mpsoc_y do arquivo - de configuração atual — igual ao print do Communication Overview. - """ - if self.mpconfig is None: - return - - matrix = SlaveMatrixWidget(self.mpconfig.mpsoc_x, self.mpconfig.mpsoc_y) - - layout = QVBoxLayout(self.scrollAreaWidgetContents) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(matrix) - self.scrollAreaWidgetContents.setLayout(layout) - - def on_checkbox_changed(self): - if (self.checkBox.isChecked()): - self.radioButton.setEnabled(False) - self.radioButton_2.setEnabled(False) - self.radioButton_3.setEnabled(False) - self.comboBox.setEnabled(False) - else: - self.radioButton.setEnabled(True) - self.radioButton_2.setEnabled(True) - self.radioButton_3.setEnabled(True) - self.comboBox.setEnabled(True) - - -class TaskMapWindow(QWidget, Ui_TaskMap): - def __init__(self, mpconfig=None): - super().__init__() - self.setupUi(self) - self.mpconfig = mpconfig - - self.build_pe_matrix() - - def build_pe_matrix(self): - """ - Monta a matriz de PEs dentro do scrollArea gerado por taskmap.py - (self.scrollAreaWidgetContents), usando mpsoc_x/mpsoc_y do arquivo - de configuração atual — igual ao print do Task Mapping Overview. - """ - if self.mpconfig is None: - return - - matrix = PEMatrixWidget(self.mpconfig.mpsoc_x, self.mpconfig.mpsoc_y) - - layout = QVBoxLayout(self.scrollAreaWidgetContents) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(matrix) - self.scrollAreaWidgetContents.setLayout(layout) - - if __name__ == "__main__": app = QApplication(sys.argv) diff --git a/overview_windows.py b/overview_windows.py new file mode 100644 index 0000000..c0ae0e0 --- /dev/null +++ b/overview_windows.py @@ -0,0 +1,475 @@ +""" +Janelas "Communication Overview", "Task Mapping Overview" e "Message Log". + +Refatoração de CommunicationOverview.java, TaskMappingFrame.java e da +janela de Message Log (MainFrame.showMessageList) do GraphicalDebugger +original. As duas janelas leem o estado dos roteadores +(MPSoCInformation) e se atualizam sozinhas enquanto a simulação da janela +principal avança. +""" + +import os +import random + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QColor, QPixmap +from PySide6.QtWidgets import (QComboBox, QHBoxLayout, QHeaderView, QLabel, QListWidget, QMessageBox, QTableWidgetItem, QVBoxLayout, + QWidget) + +import analysis +import export +from commsUi import Ui_Form as Ui_CommunicationOverview +from pe_matrix import PEMatrixWidget +from slave_matrix import SlaveMatrixWidget +from taskmap import Ui_TaskMap +from util.MPSoCConfig import MPSoCConfig + +IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images") + +# Intervalo mínimo entre duas atualizações da janela durante a simulação +REFRESH_INTERVAL_MS = 500 + +_PE_TYPE_NAMES = { + MPSoCConfig.GLOBAL_MASTER: "Global M", + MPSoCConfig.CLUSTER_MASTER: "Cluster M", + MPSoCConfig.SLAVE: "Slave", +} + + +def _router_label(mpsoc_config, address): + if mpsoc_config.router_addressing == MPSoCConfig.HAMILTONIAN: + return str(address) + return mpsoc_config.ham_address_to_xy_label(address) + + +class _LiveOverview(QWidget): + """ + Base das janelas que acompanham a simulação. + + Em vez de redesenhar a cada pacote (o que travaria o modo rápido), a + janela só marca que os dados mudaram; um QTimer redesenha no máximo a + cada REFRESH_INTERVAL_MS, e apenas se a janela estiver visível. + """ + + def __init__(self, mpsoc_config, mpsoc_information, simulation=None): + super().__init__() + self.mpsoc_config = mpsoc_config + self.mpsoc_information = mpsoc_information + self.simulation = None + self.matrix = None + self.live_update = True + self._dirty = True + + self._refresh_timer = QTimer(self) + self._refresh_timer.timeout.connect(self._refresh_if_dirty) + self._refresh_timer.start(REFRESH_INTERVAL_MS) + + self._connect_simulation(simulation) + + def _connect_simulation(self, simulation): + if self.simulation is not None: + try: + self.simulation.time_changed.disconnect(self.mark_dirty) + except (RuntimeError, TypeError): + pass # controlador antigo já destruído + self.simulation = simulation + if simulation is not None: + simulation.time_changed.connect(self.mark_dirty) + + def set_information(self, mpsoc_config, mpsoc_information, simulation=None): + """Troca a fonte de dados (novo debug ou Reset Simulation).""" + self.mpsoc_config = mpsoc_config + self.mpsoc_information = mpsoc_information + self._connect_simulation(simulation) + self.rebuild() + + def mark_dirty(self, *_): + self._dirty = True + + def _refresh_if_dirty(self): + if self._dirty and self.live_update and self.isVisible(): + self.refresh() + + def showEvent(self, event): + super().showEvent(event) + self.refresh() + + def closeEvent(self, event): + self._refresh_timer.stop() + self._connect_simulation(None) + super().closeEvent(event) + + def _set_matrix(self, matrix): + """Coloca a grade dentro do scrollArea gerado pelo .ui, trocando a anterior.""" + layout = self.scrollAreaWidgetContents.layout() + if layout is None: + layout = QVBoxLayout(self.scrollAreaWidgetContents) + layout.setContentsMargins(0, 0, 0, 0) + while layout.count(): + old = layout.takeAt(0).widget() + if old is not None: + old.deleteLater() + layout.addWidget(matrix) + + def rebuild(self): + raise NotImplementedError + + def refresh(self): + raise NotImplementedError + + +class CommunicationOverviewWindow(_LiveOverview, Ui_CommunicationOverview): + """ + Mapa de calor do tráfego: cada célula mostra a porcentagem do volume + (flits) ou da banda (ciclos) que passou pelo roteador. + + Filtros (iguais ao Java): + - All Services: todo o tráfego; senão, só o serviço escolhido, com a + porcentagem calculada sobre + Global -> tráfego desse serviço em toda a NoC + All traffic -> todo o tráfego da NoC + Router -> todo o tráfego do próprio roteador + - Volume / Bandwidth: flits ou ciclos de enlace + """ + + def __init__(self, mpsoc_config, mpsoc_information, simulation=None): + super().__init__(mpsoc_config, mpsoc_information, simulation) + self.setupUi(self) + self.setWindowTitle("Communication Overview") + + # O .ui usa um caminho relativo que só funciona rodando da pasta do projeto + self.label.setPixmap(QPixmap(os.path.join(IMAGES_DIR, "color_spectrum.png"))) + self.label_3.setText("high") + self.label_3.setAlignment(Qt.AlignmentFlag.AlignRight) + + # Estatísticas: uma linha, colunas Bigger / Average / Small + self.tableWidget.setRowCount(1) + self.tableWidget.verticalHeader().setVisible(False) + self.tableWidget.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + # Largura do original (379 px) para caber "R. 2x2: 19.472%" sem cortar + self.groupBox_3.setMinimumWidth(380) + + # Estado inicial igual ao Java: todos os serviços, "Global", volume + self.checkBox.setChecked(True) + self.radioButton_2.setChecked(True) + self.radioButton_4.setChecked(True) + + export.install_export_actions(self, "communication_overview", png_widget=self) + export.install_export_actions(self.tableWidget, "communication_statistics", csv_view=self.tableWidget, + png_widget=self) + + self.checkBox.toggled.connect(self._on_filter_changed) + self.comboBox.currentIndexChanged.connect(self._on_filter_changed) + for radio in (self.radioButton, self.radioButton_2, self.radioButton_3, + self.radioButton_4, self.radioButton_5): + radio.toggled.connect(self._on_radio_toggled) + + self.rebuild() + + def rebuild(self): + """Recria a grade e a lista de serviços para a configuração atual.""" + config = self.mpsoc_config + + self.comboBox.blockSignals(True) + self.comboBox.clear() + for service in config.service_reference: + self.comboBox.addItem(config.get_string_service_name(service), service) + self.comboBox.blockSignals(False) + + self.matrix = SlaveMatrixWidget(config.mpsoc_x, config.mpsoc_y) + self._set_matrix(self.matrix) + self._update_filter_state() + self.refresh() + + def _on_radio_toggled(self, checked): + # Cada troca dispara dois toggled (um desmarca, outro marca); basta um + if checked: + self.refresh() + + def _on_filter_changed(self, *_): + self._update_filter_state() + self.refresh() + + def _update_filter_state(self): + filter_by_service = not self.checkBox.isChecked() + for widget in (self.comboBox, self.radioButton, self.radioButton_2, self.radioButton_3): + widget.setEnabled(filter_by_service) + if not filter_by_service: + self.radioButton_2.setChecked(True) + + def selected_services(self): + """Lista de serviços filtrados; vazia = todos.""" + if self.checkBox.isChecked() or self.comboBox.currentIndex() < 0: + return [] + return [self.comboBox.currentData()] + + def refresh(self): + self._dirty = False + if self.matrix is None: + return + + config = self.mpsoc_config + info = self.mpsoc_information + services = self.selected_services() + use_volume = self.radioButton_4.isChecked() + unit = "flits" if use_volume else "cycles" + + def router_total(router): + if use_volume: + return router.get_router_total_volume_in_flits() + return router.get_router_total_bandwidth_in_cycles() + + def router_value(router): + if not services: + return router_total(router) + if use_volume: + return router.get_router_total_services_volume_in_flits(services) + return router.get_router_total_services_bandwidth_in_cycles(services) + + addresses = range(config.get_pe_number()) + values = {address: router_value(info.get_router_information(address)) for address in addresses} + + # Base da porcentagem + if self.radioButton.isChecked() and services: # All traffic + noc_total = info.get_total_noc_volume() if use_volume else info.get_total_noc_bandwidth() + else: # Global + noc_total = sum(values.values()) + + percents = {} + for address in addresses: + if self.radioButton_3.isChecked() and services: # Router + total = router_total(info.get_router_information(address)) + else: + total = noc_total + percents[address] = values[address] * 100.0 / total if total else 0.0 + + for (x, y), cell in self.matrix.slaves.items(): + address = config.xy_to_ham_addr((x << 8) | y) + percent = percents[address] + + # Mesma escala do Java: azul (0%) até vermelho (100%) + hue = 0.65 - percent * 0.65 / 100.0 + color = QColor.fromHsvF(max(0.0, hue), 1.0, 0.8) + + title = f"{_PE_TYPE_NAMES[config.get_pe_type(address)]} {_router_label(config, address)}" + cell.set_values(title, f"{percent:.3f}%", unit, color) + + self._update_statistics(percents) + + def _update_statistics(self, percents): + if not percents: + return + config = self.mpsoc_config + + # Em empate, fica o primeiro roteador na ordem de endereço + bigger = max(percents, key=lambda address: percents[address]) + smaller = min(percents, key=lambda address: percents[address]) + average = sum(percents.values()) / len(percents) + + values = ( + f"R. {_router_label(config, bigger)}: {percents[bigger]:.3f}%", + f"{average:.3f}%", + f"R. {_router_label(config, smaller)}: {percents[smaller]:.3f}%", + ) + for column, value in enumerate(values): + self.tableWidget.setItem(0, column, QTableWidgetItem(value)) + + +class TaskMappingWindow(_LiveOverview, Ui_TaskMap): + """ + Mostra em cada PE as tarefas alocadas (RUN) e terminadas (TER) até o + tempo atual da simulação, com uma cor por aplicação. + """ + + # Semente fixa: cada aplicação mantém a mesma cor entre execuções + COLOR_SEED = 20 + + def __init__(self, mpsoc_config, mpsoc_information, simulation=None): + super().__init__(mpsoc_config, mpsoc_information, simulation) + self.setupUi(self) + + export.install_export_actions(self, "task_mapping", png_widget=self) + + # O .ui repete "All tasks status" no terceiro botão + self.radioButton_3.setText("Only Terminated") + + # "Only Running" é o filtro inicial, como no Java + self.radioButton_2.setChecked(True) + for radio in (self.radioButton, self.radioButton_2, self.radioButton_3): + radio.toggled.connect(self._on_radio_toggled) + self.checkBox.toggled.connect(self.refresh) + + # Botão alterna entre atualização automática e manual + self.pushButton.setCheckable(True) + self.pushButton.setChecked(True) + self.pushButton.toggled.connect(self._on_update_toggled) + self._on_update_toggled(True) + + self._warned_missing_names = False + self.rebuild() + + def rebuild(self): + config = self.mpsoc_config + self.setWindowTitle(f"Task Mapping Overview: {config.get_testcase_path() or config.debug_file_path}") + + self.app_colors = {} + self.color_generator = random.Random(self.COLOR_SEED) + + self.matrix = PEMatrixWidget(config.mpsoc_x, config.mpsoc_y) + self._set_matrix(self.matrix) + self.refresh() + + def _on_radio_toggled(self, checked): + if checked: + self.refresh() + + def _on_update_toggled(self, checked): + self.live_update = checked + self.pushButton.setText("Updating" if checked else "Update") + self.refresh() + + def showEvent(self, event): + super().showEvent(event) + if not self.mpsoc_config.task_name_hash and not self._warned_missing_names: + self._warned_missing_names = True + QMessageBox.warning(self, "Warning", "Task name representation not informed\n" + "The task ID will be represented by numbers") + + def _app_color(self, app_id): + if app_id not in self.app_colors: + self.app_colors[app_id] = QColor.fromRgb(self.color_generator.randrange(0xFFFFFF)) + return self.app_colors[app_id] + + def refresh(self): + self._dirty = False + if self.matrix is None: + return + + config = self.mpsoc_config + task_names = config.task_name_hash + only_running = self.radioButton_2.isChecked() + only_terminated = self.radioButton_3.isChecked() + without_id = self.checkBox.isChecked() + + for (x, y), cell in self.matrix.pes.items(): + address = config.xy_to_ham_addr((x << 8) | y) + router = self.mpsoc_information.get_router_information(address) + + # Último estado de cada tarefa neste PE + status = {} + for task in router.get_tasks_information(): + if task.service == "ALLOCATED": + status[task.id] = "RUN" + elif task.service == "TERMINATED": + status[task.id] = "TER" + + tasks = [] + for task_id in sorted(status): + if only_running and status[task_id] == "TER": + continue + if only_terminated and status[task_id] == "RUN": + continue + + name = task_names.get(task_id, f"{task_id >> 8} {task_id & 0xFF}") + text = f"{name} {status[task_id]}" if without_id else f"{name} {task_id} {status[task_id]}" + tasks.append((text, self._app_color(task_id >> 8))) + + cell.set_tasks(f"PE {_router_label(config, address)}", tasks) + + +class MessageLogWindow(_LiveOverview): + """ + Lista as mensagens trocadas entre os PEs, na ordem em que aparecem no + traffic_router.txt: + + ---->>> origem send message to destino service SERVIÇO + (pacote entrando na NoC pela porta local do PE de origem) + <<<---- destino receive message with service SERVIÇO + (pacote chegando ao roteador de destino) + + Mostra todos os pacotes já lidos e acrescenta os novos conforme a + simulação avança. A caixa "Application" mostra só as mensagens de uma + aplicação (pelas tarefas de origem/destino do pacote). + """ + + def __init__(self, mpsoc_config, mpsoc_information, simulation=None): + super().__init__(mpsoc_config, mpsoc_information, simulation) + self.setWindowTitle("Message Log") + self.resize(500, 600) + + self.app_combo = QComboBox() + self.app_combo.currentIndexChanged.connect(lambda _index: self.rebuild()) + app_row = QHBoxLayout() + app_row.setContentsMargins(6, 6, 6, 0) + app_row.addWidget(QLabel("Application:")) + app_row.addWidget(self.app_combo, 1) + + self.list_widget = QListWidget(self) + self.list_widget.setUniformItemSizes(True) # listas longas continuam rápidas + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(app_row) + layout.addWidget(self.list_widget) + + self._fill_app_combo() + + self._packets_listed = 0 + self.rebuild() + + def _fill_app_combo(self): + self.known_apps = analysis.known_app_ids(self.mpsoc_config) + self.app_combo.blockSignals(True) + self.app_combo.clear() + self.app_combo.addItem("All", None) + for app in sorted(self.known_apps): + self.app_combo.addItem(analysis.app_label(self.mpsoc_config, app), app) + self.app_combo.blockSignals(False) + + def set_information(self, mpsoc_config, mpsoc_information, simulation=None): + self.mpsoc_config = mpsoc_config + self._fill_app_combo() + super().set_information(mpsoc_config, mpsoc_information, simulation) + + def rebuild(self): + self.list_widget.clear() + self._packets_listed = 0 + self.refresh() + + def _message(self, packet): + config = self.mpsoc_config + service = config.get_string_service_name(packet.service) + router = _router_label(config, packet.router_address) + message = None + + if packet.input_port in (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1): + target = _router_label(config, packet.target_router) + message = f"---->>> {router} send message to {target} service {service}" + + if packet.router_address == packet.target_router: + message = f"<<<---- {router} receive message with service {service}" + + return message + + def refresh(self): + self._dirty = False + + # all_packets só cresce (voltar no tempo reaproveita os pacotes já lidos), + # então basta acrescentar os pacotes novos desde a última atualização + packets = self.mpsoc_information.read_traffic.all_packets + new_packets = packets[self._packets_listed:] + if not new_packets: + return + self._packets_listed = len(packets) + + scrollbar = self.list_widget.verticalScrollBar() + at_bottom = scrollbar.value() == scrollbar.maximum() + + app = self.app_combo.currentData() + if app is not None: + new_packets = [packet for packet in new_packets if app in analysis.packet_apps(packet, self.known_apps)] + messages = [message for message in map(self._message, new_packets) if message is not None] + self.list_widget.addItems(messages) + + # Acompanha o fim da lista, a menos que o usuário tenha rolado para cima + if at_bottom: + self.list_widget.scrollToBottom() diff --git a/path_view.py b/path_view.py new file mode 100644 index 0000000..29646fb --- /dev/null +++ b/path_view.py @@ -0,0 +1,35 @@ +""" +Mostrar na malha da janela principal o caminho de um pacote analisado. + +As janelas de análise (Traffic Statistics, gráfico de escalonamento...) +chamam show_message()/show_record(); a janela principal registra com +set_handler() a função que pinta as setas. Assim as janelas não precisam +conhecer a janela principal. +""" + +_handler = None + + +def set_handler(handler): + """handler(hops, target, highlight, description); hops = [(roteador, porta de entrada), ...].""" + global _handler + _handler = handler + + +def show_message(message, highlight=None, description=""): + """Caminho registrado de uma analysis.Message, destacando `highlight` (padrão: onde o pacote está).""" + if _handler is None: + return False + hops = [(router, port) for router, port, _time in message.path] + if highlight is None: + highlight = message.last_router + _handler(hops, message.target, highlight, description) + return True + + +def show_record(router, port, target, description=""): + """Um único registro do trace (quando o pacote não foi identificado).""" + if _handler is None: + return False + _handler([(router, port)], target, router, description) + return True diff --git a/pe_matrix.py b/pe_matrix.py index 012dd65..c42993a 100644 --- a/pe_matrix.py +++ b/pe_matrix.py @@ -17,6 +17,7 @@ """ from PySide6.QtCore import Qt +from PySide6.QtGui import QColor from PySide6.QtWidgets import QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget @@ -32,8 +33,20 @@ background-color: transparent; color: #000000; } +QLabel#peTitle { + font-weight: bold; +} """ +# Uma linha de tarefa: fundo com a cor da aplicação +_TASK_STYLESHEET = "QLabel {{ background-color: {background}; color: {text}; font-weight: bold; padding: 2px; }}" + + +def _text_color_for(background: QColor) -> str: + """Texto preto em fundos claros e branco em fundos escuros.""" + luminance = 0.299 * background.red() + 0.587 * background.green() + 0.114 * background.blue() + return "#000000" if luminance > 140 else "#ffffff" + class PEWidget(QWidget): """ @@ -62,13 +75,46 @@ def __init__(self, pos_x: int, pos_y: int, parent=None): layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(2) self.pe_label = QLabel(f"PE{pos_x}x{pos_y}") + self.pe_label.setObjectName("peTitle") self.pe_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.pe_label) + # Linhas de tarefas (preenchidas por set_tasks), centralizadas na célula + layout.addStretch(1) + self.tasks_layout = QVBoxLayout() + self.tasks_layout.setSpacing(2) + layout.addLayout(self.tasks_layout) + layout.addStretch(1) + + self.task_labels: list[QLabel] = [] + self.setLayout(layout) + def set_tasks(self, title: str, tasks: list[tuple[str, QColor]]) -> None: + """ + Mostra o título do PE e uma linha por tarefa, cada uma com o texto + e a cor da aplicação a que pertence. + """ + self.pe_label.setText(title) + + # Reaproveita os rótulos existentes e cria/remove só a diferença + while len(self.task_labels) < len(tasks): + label = QLabel(self) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.tasks_layout.addWidget(label) + self.task_labels.append(label) + while len(self.task_labels) > len(tasks): + self.task_labels.pop().deleteLater() + + for label, (text, color) in zip(self.task_labels, tasks): + label.setText(text) + style = _TASK_STYLESHEET.format(background=color.name(), text=_text_color_for(color)) + if label.styleSheet() != style: + label.setStyleSheet(style) + def set_task(self, task_name: str | None) -> None: """ Atualiza o rótulo para mostrar a tarefa alocada neste PE, além da diff --git a/platform_setup.py b/platform_setup.py new file mode 100644 index 0000000..bb96e7d --- /dev/null +++ b/platform_setup.py @@ -0,0 +1,73 @@ +""" +Janela "Platform Setup" (menu Edit). + +Refatoração de PlatformSetupFrame.java do GraphicalDebugger original: permite +trocar o tamanho do flit, o período de clock e a largura da janela de +checkpoint (intervalo em que a % de uso dos enlaces é recalculada) sem +editar o platform.cfg. As mudanças valem até o próximo "Reset Simulation" +ou "New Debugging", que releem os arquivos. +""" + +from PySide6.QtCore import QLocale +from PySide6.QtGui import QDoubleValidator, QIntValidator +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QMessageBox, QVBoxLayout + + +class PlatformSetupDialog(QDialog): + def __init__(self, mpsoc_config, checkpoint, parent=None): + super().__init__(parent) + self.mpsoc_config = mpsoc_config + self.checkpoint = checkpoint + self.setWindowTitle("Platform Setup") + + self.flit_size_edit = QLineEdit(str(mpsoc_config.flit_size)) + self.flit_size_edit.setValidator(QIntValidator(1, 1024, self)) + + self.clock_edit = QLineEdit(str(mpsoc_config.clock_period_in_ns)) + self.clock_edit.setValidator(QIntValidator(1, 1_000_000, self)) + + # Largura da janela em us, como no Java (guardada em ms no controlador) + self.window_width_edit = QLineEdit(f"{checkpoint.window_size_ms * 1000:g}") + window_validator = QDoubleValidator(0.001, 1e9, 3, self) + window_validator.setNotation(QDoubleValidator.Notation.StandardNotation) + window_validator.setLocale(QLocale.c()) # aceita ponto decimal em qualquer idioma + self.window_width_edit.setValidator(window_validator) + + form = QGridLayout() + rows = (("Flit Size", self.flit_size_edit, "bits"), + ("Clock Period", self.clock_edit, "ns"), + ("Window Width", self.window_width_edit, "us")) + for row, (caption, edit, unit) in enumerate(rows): + form.addWidget(QLabel(caption), row, 0) + form.addWidget(edit, row, 1) + form.addWidget(QLabel(unit), row, 2) + + buttons = QDialogButtonBox() + buttons.addButton("Confirm", QDialogButtonBox.ButtonRole.AcceptRole) + buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetFixedSize) # não redimensionável, como no Java + + def accept(self): + edits = (self.flit_size_edit, self.clock_edit, self.window_width_edit) + invalid = next((edit for edit in edits if not edit.hasAcceptableInput()), None) + if invalid is not None: + QMessageBox.warning(self, "Platform Setup", "Enter only valid positive numbers.") + invalid.setFocus() + invalid.selectAll() + return + + clock_period = int(self.clock_edit.text()) + self.mpsoc_config.flit_size = int(self.flit_size_edit.text()) + self.mpsoc_config.clock_period_in_ns = clock_period + # O Java esquecia de repassar o clock ao CheckpointController + self.checkpoint.clock_period_ns = clock_period + self.checkpoint.window_size_ms = float(self.window_width_edit.text()) / 1000.0 + + QMessageBox.information(self, "Platform Setup", "Settings applied") + super().accept() diff --git a/projects.py b/projects.py new file mode 100644 index 0000000..60067db --- /dev/null +++ b/projects.py @@ -0,0 +1,55 @@ +""" +Projetos de debug salvos (File -> Save / Open / Delete Project). + +Como no Java (MainFrame.saveMenuItemActionPerformed e afins), um projeto é +um arquivo .hdf ("HeMPS Debugging File") cuja primeira linha é o +caminho do diretório de debug. O Java gravava em /projects, +que muda conforme de onde o programa é executado (e não existe no +executável do PyInstaller); aqui a pasta fica nos dados do usuário. +""" + +import os + +from PySide6.QtCore import QStandardPaths + +PROJECT_EXTENSION = ".hdf" +PROJECT_FILTER = "HeMPS Debugging File (*.hdf)" + + +def projects_dir(): + """Pasta dos projetos, criada se ainda não existir.""" + # Local fixo, independente do nome dado ao QApplication (~/.local/share no Linux) + base = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericDataLocation) + path = os.path.join(base, "GAPH", "MemphisGraphicalDebugger", "projects") + os.makedirs(path, exist_ok=True) + return path + + +def project_path(name): + return os.path.join(projects_dir(), name + PROJECT_EXTENSION) + + +def validate_name(name): + """Mensagem de erro para o nome informado, ou None se ele for válido.""" + if not name: + return "Name is empty" + if " " in name: + return "Name contains space" + if os.sep in name or (os.altsep and os.altsep in name) or name in (".", ".."): + return "Name contains invalid characters" + return None + + +def save_project(name, debug_dir): + with open(project_path(name), "w", encoding="utf-8") as project: + project.write(os.path.abspath(debug_dir) + "\n") + + +def read_project(path): + """Diretório de debug guardado no projeto (primeira linha do arquivo).""" + with open(path, "r", encoding="utf-8") as project: + return project.readline().strip() + + +def project_name(path): + return os.path.splitext(os.path.basename(path))[0] diff --git a/pyproject.toml b/pyproject.toml index 3f7833c..565a567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,12 @@ dependencies = [ "pyside6>=6.7", "sortedcontainers>=2.4.0", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/router_info_window.py b/router_info_window.py new file mode 100644 index 0000000..0c2c876 --- /dev/null +++ b/router_info_window.py @@ -0,0 +1,1031 @@ +""" +Janela "Router Information", aberta ao clicar em um roteador da malha. + +Refatoração de RouterInfoFrame.java, PETextLog.java, SchedulingTab.java, +scheduling/SchedulingPanel.java, TaskInfoFrame.java e dos renderizadores +RouterInfoTableCellRender/CellRenderer do GraphicalDebugger original. + +Abas da janela (mesma ordem do Java): + - Log: log do processador (log/logx.txt do cenário) + - Applications: tarefas alocadas/terminadas no PE; duplo clique abre a + janela "Task Information" + - Scheduling: gráfico de escalonamento do PE (debug/scheduling_report.txt) + - Traffic: volume por porta de entrada, com filtro de serviços +""" + +import bisect +import os +from dataclasses import dataclass + +from PySide6.QtCore import QEvent, QRect, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QFont, QFontDatabase, QPainter, QPen, QPixmap +from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QInputDialog, QLabel, QMessageBox, QPlainTextEdit, + QPushButton, QScrollArea, QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, + QVBoxLayout, QWidget) + +import analysis +import export +import path_view +from overview_windows import _LiveOverview, _router_label +from util.MPSoCConfig import MPSoCConfig + +ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon") + +# Intervalo do "Continuous Read" do log e da atualização da Task Information +LOG_READ_INTERVAL_MS = 1000 +TASK_INFO_REFRESH_MS = 500 + +# Eventos de CPU usados quando o debug/cpu.cfg não existe (padrão do Java) +DEFAULT_CPU_EVENTS = {65536: "Interruption", 262144: "Scheduler", 524288: "Idle"} + +# Marcas da régua do gráfico de escalonamento: a cada 1, 2, 5, 10... unidades +_RULER_STEPS = [base * 10 ** power for power in range(7) for base in (1, 2, 5)] + +# Cores das células destacadas (mesmas do Java) +RUNNING_BACKGROUND = QColor(Qt.GlobalColor.blue) +CHECKED_BACKGROUND = QColor(Qt.GlobalColor.blue) +PENDING_BACKGROUND = QColor(Qt.GlobalColor.red) + + +def _read_only_table(headers): + table = QTableWidget(0, len(headers)) + table.setHorizontalHeaderLabels(headers) + table.verticalHeader().setVisible(False) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + return table + + +def _highlight(item, background): + item.setBackground(background) + item.setForeground(QColor(Qt.GlobalColor.white)) + + +def _task_name(mpsoc_config, task_id): + return mpsoc_config.task_name_hash.get(task_id, str(task_id)) + + +def _slice_task(mpsoc_config, task_id): + if task_id < 0: + return "-" + if task_id >= analysis.KERNEL_TASK_ID: + return "kernel" + return _task_name(mpsoc_config, task_id) + + +def _pe_xy_address(mpsoc_config, router_address): + """Endereço (x << 8) | y do PE, usado nos nomes dos arquivos de log.""" + return mpsoc_config.ham_to_xy_addr(router_address) + + +def _pe_report_address(mpsoc_config, router_address): + """Endereço do PE como aparece no scheduling_report.txt (XY ou hamiltoniano).""" + if mpsoc_config.router_addressing == MPSoCConfig.XY: + return _pe_xy_address(mpsoc_config, router_address) + return router_address + + +def _task_lifetime(tasks, task_id): + """(tempo de alocação, tempo de término ou None) da última execução da tarefa.""" + allocated, terminated = 0, None + for task in tasks: + if task.id != task_id: + continue + if task.service == "ALLOCATED": + allocated, terminated = task.time, None + elif task.service == "TERMINATED": + terminated = task.time + return allocated, terminated + + +# ========================================== +# JANELA PRINCIPAL DO ROTEADOR +# ========================================== +class RouterInfoWindow(_LiveOverview): + """ + Informações de um roteador/PE. Diferente do Java (que mostrava uma foto + do momento em que a janela foi aberta), as tabelas acompanham a + simulação, igual às demais janelas de visão geral. + """ + + def __init__(self, mpsoc_config, mpsoc_information, router_address, simulation=None): + self.router_address = router_address + self.task_windows = [] + super().__init__(mpsoc_config, mpsoc_information, simulation) + + self.setWindowTitle(f"Router {_router_label(mpsoc_config, router_address)} Information") + self.setMinimumSize(440, 365) + self.resize(560, 540) + + self.tabs = QTabWidget(self) + self.log_tab = PELogTab(mpsoc_config, router_address) + self.applications_tab = self._build_applications_tab() + self.scheduling_tab = SchedulingTab(mpsoc_config, router_address) + self.traffic_tab = self._build_traffic_tab() + + self.tabs.addTab(self.log_tab, "Log") + self.tabs.addTab(self.applications_tab, "Applications") + self.tabs.addTab(self.scheduling_tab, "Scheduling") + self.tabs.addTab(self.traffic_tab, "Traffic") + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.tabs) + + self.refresh() + + @property + def router_info(self): + # Buscado a cada uso: o "Go" da simulação recria os RouterInformation + return self.mpsoc_information.get_router_information(self.router_address) + + def rebuild(self): + self.refresh() + + def refresh(self): + self._dirty = False + if not hasattr(self, "traffic_table"): + return # chamado pela base antes da interface existir + self._update_task_table() + self._update_traffic_table() + + def closeEvent(self, event): + self.log_tab.stop_continuous_read() + for window in self.task_windows: + window.close() + super().closeEvent(event) + + # ------------------------------------------ + # Aba Applications + # ------------------------------------------ + def _build_applications_tab(self): + self.task_table = _read_only_table(["Task Name", "Task", "Service", "Time"]) + self.task_table.setToolTip("Double click a task to see its messages.\n" + "ALLOCATED* = task still running") + self.task_table.cellDoubleClicked.connect(self._open_task_info) + export.install_export_actions(self.task_table, f"router_{self.router_address}_applications", + csv_view=self.task_table) + return self.task_table + + def _update_task_table(self): + tasks = self.router_info.get_tasks_information() + terminated = {task.id for task in tasks if task.service == "TERMINATED"} + + # Primeiro as alocações, depois os términos (mesma ordem do Java) + rows = [] + for task in tasks: + if task.service == "ALLOCATED": + running = task.id not in terminated + rows.append((task, "ALLOCATED*" if running else "ALLOCATED", running)) + rows.extend((task, "TERMINATED", False) for task in tasks if task.service == "TERMINATED") + + table = self.task_table + table.setRowCount(len(rows)) + for row, (task, service, running) in enumerate(rows): + values = (_task_name(self.mpsoc_config, task.id), str(task.id), service, str(task.time)) + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setData(Qt.ItemDataRole.UserRole, task.id) + if running and column == 2: + _highlight(item, RUNNING_BACKGROUND) + table.setItem(row, column, item) + + def _open_task_info(self, row, _column): + item = self.task_table.item(row, 0) + if item is None: + return + window = TaskInfoWindow(self.mpsoc_config, self, item.data(Qt.ItemDataRole.UserRole)) + window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + window.destroyed.connect(lambda *_, w=window: self._forget_task_window(w)) + self.task_windows.append(window) + window.show() + + def _forget_task_window(self, window): + if window in self.task_windows: + self.task_windows.remove(window) + + # ------------------------------------------ + # Aba Traffic + # ------------------------------------------ + def _build_traffic_tab(self): + tab = QWidget() + + filter_box = QGroupBox("Service Filter", tab) + self.services_to_add = QComboBox() + self.services_to_add.addItem("None") + for service_name in self.mpsoc_config.get_services_hash().values(): + self.services_to_add.addItem(service_name) + + self.services_added = QComboBox() + self.services_added.addItem("Show all") + self.services_added.currentIndexChanged.connect(self._update_traffic_table) + + add_button = QPushButton("Add >>") + remove_button = QPushButton("Remove <<") + add_button.clicked.connect(self._add_service) + remove_button.clicked.connect(self._remove_service) + + filter_layout = QGridLayout(filter_box) + filter_layout.addWidget(QLabel("Services to add"), 0, 0) + filter_layout.addWidget(QLabel("Added services"), 0, 2) + filter_layout.addWidget(self.services_to_add, 1, 0) + filter_layout.addWidget(add_button, 0, 1) + filter_layout.addWidget(remove_button, 1, 1) + filter_layout.addWidget(self.services_added, 1, 2) + filter_layout.setColumnStretch(0, 1) + filter_layout.setColumnStretch(2, 1) + + self.traffic_table = _read_only_table(["Input Port", "Total Volume", "Service Volume", "Percentual"]) + self.traffic_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + export.install_export_actions(self.traffic_table, f"router_{self.router_address}_traffic", + csv_view=self.traffic_table) + + layout = QVBoxLayout(tab) + layout.addWidget(filter_box) + layout.addWidget(self.traffic_table) + return tab + + def selected_services(self): + """Serviço escolhido em "Added services"; em "Show all", todos os adicionados.""" + combo = self.services_added + if combo.currentIndex() > 0: + names = [combo.currentText()] + else: + names = [combo.itemText(index) for index in range(1, combo.count())] + return [self.mpsoc_config.get_service_value(name) for name in names] + + def _add_service(self): + if self.services_to_add.currentIndex() == 0: + return + name = self.services_to_add.currentText() + if self.services_added.findText(name) == -1: + self.services_added.addItem(name) + # Volta para "Show all" (o sinal atualiza a tabela) + self.services_added.setCurrentIndex(0) + self._update_traffic_table() + + def _remove_service(self): + index = self.services_added.currentIndex() + if index > 0: + self.services_added.removeItem(index) + self._update_traffic_table() + + def _update_traffic_table(self, *_): + router = self.router_info + services = self.selected_services() + + def percent(part, total): + return f"{part * 100 // total}%" if total else "0%" + + rows = [] + router_total = router_services = 0 + for port in range(MPSoCConfig.NPORT): + total = router.get_port_total_volume_in_flits(port) + service = router.get_port_volume_in_flits(port, services) if services else 0 + router_total += total + router_services += service + rows.append((MPSoCConfig.get_port_string(port), total, service)) + rows.append(("TOTAL", router_total, router_services)) + + table = self.traffic_table + table.setRowCount(len(rows)) + bold = QFont() + bold.setBold(True) + for row, (port, total, service) in enumerate(rows): + values = (port, f"{total} flits", f"{service} flits", percent(service, total)) + for column, value in enumerate(values): + item = QTableWidgetItem(value) + if port == "TOTAL": + item.setFont(bold) + table.setItem(row, column, item) + + +# ========================================== +# ABA LOG +# ========================================== +class PELogTab(QWidget): + """Mostra o log do processador (log/logx.txt), lido uma vez ou continuamente.""" + + def __init__(self, mpsoc_config, router_address, parent=None): + super().__init__(parent) + xy = _pe_xy_address(mpsoc_config, router_address) + testcase = mpsoc_config.get_testcase_path() or os.path.dirname( + os.path.normpath(mpsoc_config.debug_file_path)) + self.log_path = os.path.join(testcase, "log", f"log{xy >> 8}x{xy & 0xFF}.txt") + + self.read_once_button = QPushButton("Read Log Once") + self.continuous_button = QPushButton("Continuous Read") + self.continuous_button.setCheckable(True) + self.read_once_button.clicked.connect(self.update_log) + self.continuous_button.toggled.connect(self._on_continuous_toggled) + + self.text = QPlainTextEdit() + self.text.setReadOnly(True) + self.text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + font.setPointSize(11) + self.text.setFont(font) + + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_log) + + buttons = QHBoxLayout() + buttons.addWidget(self.read_once_button) + buttons.addWidget(self.continuous_button) + buttons.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(buttons) + layout.addWidget(self.text) + + def _on_continuous_toggled(self, checked): + font = self.continuous_button.font() + font.setBold(checked) + self.continuous_button.setFont(font) + self.continuous_button.setText("Reading..." if checked else "Continuous Read") + if checked: + self.update_log() + self.timer.start(LOG_READ_INTERVAL_MS) + else: + self.timer.stop() + + def stop_continuous_read(self): + self.continuous_button.setChecked(False) + + def update_log(self): + try: + with open(self.log_path, "r", encoding="utf-8", errors="replace") as log_file: + content = log_file.read() + except OSError: + content = f"Log file not found:\n{self.log_path}" + + if content == self.text.toPlainText(): + return # evita perder a rolagem/seleção sem necessidade + self.text.setPlainText(content) + # Mantém a rolagem no fim, como no Java + scroll = self.text.verticalScrollBar() + scroll.setValue(scroll.maximum()) + + +# ========================================== +# ABA SCHEDULING +# ========================================== +class SchedulingTab(QWidget): + """Botões que abrem o gráfico de escalonamento do PE ou de uma aplicação.""" + + def __init__(self, mpsoc_config, router_address, parent=None): + super().__init__(parent) + self.mpsoc_config = mpsoc_config + self.router_address = router_address + self.graph_windows = [] + + pe_button = QPushButton("Open Scheduling Graph") + app_button = QPushButton("Open Application Scheduling") + for button in (pe_button, app_button): + button.setMinimumWidth(220) + pe_button.clicked.connect(lambda: self._open_graph(only_app=False)) + app_button.clicked.connect(lambda: self._open_graph(only_app=True)) + + layout = QVBoxLayout(self) + layout.addStretch(1) + layout.addWidget(pe_button, 0, Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(app_button, 0, Qt.AlignmentFlag.AlignHCenter) + layout.addStretch(3) + + def _open_graph(self, only_app): + app_id = -1 + if only_app: + app_id, ok = QInputDialog.getInt(self, "Application Scheduling", + "Please, inform the application ID:", 0, 0, 0xFF) + if not ok: + return + + report = os.path.join(self.mpsoc_config.debug_file_path, "scheduling_report.txt") + if not os.path.isfile(report): + QMessageBox.warning(self, "Scheduling", f"File not found:\n{report}") + return + + window = SchedulingGraphWindow(self.mpsoc_config, self.router_address, app_id) + window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + window.destroyed.connect(lambda *_, w=window: self.graph_windows.remove(w)) + self.graph_windows.append(window) + window.show() + + +@dataclass +class SchedulingEvent: + code: int + name: str + start: int + finish: int + rect: QRect = None # posição no último desenho, para o clique + pe: int = -1 # endereço hamiltoniano do PE que executou o trecho + + +def read_cpu_events(debug_dir): + """Nomes dos eventos de CPU (debug/cpu.cfg: nomecódigo).""" + events = {} + try: + with open(os.path.join(debug_dir, "cpu.cfg"), "r", encoding="utf-8") as cpu_file: + for line in cpu_file: + fields = line.split() + if len(fields) >= 2: + events[int(fields[1])] = fields[0] + except (OSError, ValueError): + events = {} + return events or dict(DEFAULT_CPU_EVENTS) + + +def read_scheduling_report(path, pe_filter=None): + """ + Lê o scheduling_report.txt (PEcódigotempo). Cada linha marca + o início de um trecho que termina na próxima linha do mesmo PE; o último + trecho de cada PE ganha 10 ticks, como no Java. + + Retorna {PE: [(código, início, fim), ...]}. + """ + slices = {} + current = {} + with open(path, "r", encoding="utf-8") as report: + for line in report: + fields = line.split("\t") + if len(fields) < 3: + continue + try: + pe, code, time = int(fields[0]), int(fields[1]), int(fields[2]) + except ValueError: + continue + if pe_filter is not None and pe != pe_filter: + continue + + if pe in current: + last_code, last_start = current[pe] + slices.setdefault(pe, []).append((last_code, last_start, time)) + current[pe] = (code, time) + + for pe, (code, start) in current.items(): + slices.setdefault(pe, []).append((code, start, start + 10)) + return slices + + +class SchedulingGraph(QWidget): + """ + Gráfico de Gantt do escalonamento (SchedulingPanel.java). + + - clique num trecho: mostra os dados dele em "Execution Slice" + - linha vermelha segue o mouse e mostra o tempo em ticks + - Ctrl + roda do mouse: zoom horizontal + """ + + # Trecho clicado (SchedulingEvent) ou None + selection_changed = Signal(object) + + TOP = 50 # INITI_Y + ROW_HEIGHT = 26 # NAME_SPACE + CPU_COLOR = QColor(21, 119, 40) + TASK_COLOR = QColor(Qt.GlobalColor.blue) + + def __init__(self, mpsoc_config, router_address, app_filter=-1, parent=None): + super().__init__(parent) + self.mpsoc_config = mpsoc_config + self.router_address = router_address + self.app_filter = app_filter + self.cpu_events = read_cpu_events(mpsoc_config.debug_file_path) + + self.events = self._load_events() + self.last_time = max((event.finish for event in self.events), default=0) + + # Uma linha por código, na ordem em que aparecem + self.rows = {} + usage = {} + for event in self.events: + if event.code not in self.rows: + self.rows[event.code] = (len(self.rows) + 1) * self.ROW_HEIGHT + usage[event.code] = usage.get(event.code, 0) + event.finish - event.start + self.usage = usage + self.total_cpu_time = sum(usage.values()) + self.names = {event.code: event.name for event in self.events} + + # Início de cada trecho, para achar o trecho clicado com bisect + self.events.sort(key=lambda event: event.start) + self.starts = [event.start for event in self.events] + + self.selected = None + self.mouse_x = None + self._cache = None + self.setMouseTracking(True) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + self.time_line = len(self.rows) * self.ROW_HEIGHT + self.TOP + self.ROW_HEIGHT // 2 + self.report_top = self.time_line + 50 + report_lines = len(self.rows) + 3 if app_filter == -1 else 6 + self.setMinimumHeight(self.report_top + report_lines * 15 + 20) + + def _load_events(self): + config = self.mpsoc_config + path = os.path.join(config.debug_file_path, "scheduling_report.txt") + + if self.app_filter == -1: + pe = _pe_report_address(config, self.router_address) + slices = [(self.router_address, *piece) for piece in read_scheduling_report(path, pe).get(pe, [])] + else: + # Tarefas da aplicação em todos os PEs (os eventos de CPU têm bits acima do 16) + def ham(report_pe): + return config.xy_to_ham_addr(report_pe) if config.router_addressing == MPSoCConfig.XY else report_pe + slices = [(ham(report_pe), *piece) for report_pe, pe_slices in read_scheduling_report(path).items() + for piece in pe_slices + if piece[0] >> 16 == 0 and piece[0] >> 8 == self.app_filter] + + return [SchedulingEvent(code, self._event_name(code), start, finish, pe=pe) + for pe, code, start, finish in slices] + + def _event_name(self, code): + if code in self.cpu_events: + return self.cpu_events[code] + return self.mpsoc_config.task_name_hash.get(code, "Undefined") + + # ------------------------------------------ + # Geometria + # ------------------------------------------ + def _name_width(self): + longest = max((len(name) for name in self.names.values()), default=0) + return max(longest * 10, 60) + + def _proportion(self): + if not self.last_time: + return 0.0 + return (self.width() - self._name_width()) / self.last_time + + def _time_scale(self): + """(ticks por unidade, nome da unidade) para a régua de tempo.""" + period = self.mpsoc_config.clock_period_in_ns or 10 + units = ((1_000_000, "ms"), (1_000, "us"), (period, "ns")) + for unit_ns, name in units: + ticks = max(1, round(unit_ns / period)) + if self.last_time >= ticks or name == "ns": + return ticks, name + return 1, "ns" + + # ------------------------------------------ + # Desenho + # ------------------------------------------ + def resizeEvent(self, event): + self._cache = None + super().resizeEvent(event) + + def changeEvent(self, event): + if event.type() == QEvent.Type.PaletteChange: + self._cache = None # troca de tema + super().changeEvent(event) + + def paintEvent(self, event): + painter = QPainter(self) + if self._cache is None: + self._cache = self._render_static() + painter.drawPixmap(0, 0, self._cache) + + text_color = self.palette().text().color() + if self.events: + self._draw_mouse_line(painter) + self._draw_report(painter, text_color) + else: + painter.setPen(text_color) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No scheduling events for this PE") + + def _render_static(self): + """Nomes, trechos e régua (só muda com tamanho, zoom ou tema).""" + ratio = self.devicePixelRatioF() + pixmap = QPixmap(self.size() * ratio) + pixmap.setDevicePixelRatio(ratio) + pixmap.fill(self.palette().base().color()) + if not self.events: + return pixmap + + painter = QPainter(pixmap) + width = self.width() + name_width = self._name_width() + proportion = self._proportion() + text_color = self.palette().text().color() + grid_color = QColor(Qt.GlobalColor.gray) + + painter.setPen(text_color) + if self.app_filter == -1: + painter.setFont(QFont("Arial", 13, QFont.Weight.Bold)) + title = f"PE {_router_label(self.mpsoc_config, self.router_address)} Scheduling Graph" + painter.drawText(QRect(0, 0, width, 30), Qt.AlignmentFlag.AlignCenter, title) + painter.setFont(QFont("Arial", 9)) + + for code, y in self.rows.items(): + painter.drawText(10, self.TOP + y + 5, self.names[code]) + + # Linhas horizontais de cada instância + painter.setPen(grid_color) + for y in self.rows.values(): + top = self.TOP + y - self.ROW_HEIGHT // 2 + painter.drawLine(name_width, top, width, top) + + # Trechos: verde para eventos da CPU, azul para tarefas + painter.setPen(Qt.PenStyle.NoPen) + for event in self.events: + x = int(event.start * proportion) + name_width + y = self.TOP + self.rows[event.code] - self.ROW_HEIGHT // 2 + slice_width = max(1, int((event.finish - event.start) * proportion)) + event.rect = QRect(x, y, slice_width, self.ROW_HEIGHT) + painter.fillRect(event.rect, self.CPU_COLOR if event.code in self.cpu_events else self.TASK_COLOR) + + # Eixos + painter.setPen(QPen(text_color, 2)) + painter.drawLine(name_width, self.TOP, name_width, self.time_line + 10) + painter.drawLine(name_width, self.time_line, width, self.time_line) + + # Régua: uma marca por unidade, pulando marcas que ficariam coladas + painter.setPen(text_color) + tick_scale, scale_name = self._time_scale() + step_px = tick_scale * proportion + skip = next(step for step in _RULER_STEPS if step * step_px >= 45 or step == _RULER_STEPS[-1]) + mark = skip + while mark * step_px <= width - name_width: + x = int(mark * step_px) + name_width + painter.drawLine(x, self.time_line, x, self.time_line + 10) + painter.drawText(QRect(x - 50, self.time_line + 10, 100, 16), + Qt.AlignmentFlag.AlignHCenter, f"{mark} {scale_name}") + mark += skip + + painter.end() + return pixmap + + def _draw_mouse_line(self, painter): + if self.mouse_x is None: + return + proportion = self._proportion() + ticks = int((self.mouse_x - self._name_width()) / proportion) if proportion else 0 + painter.setPen(QColor(Qt.GlobalColor.red)) + painter.drawLine(self.mouse_x, self.TOP, self.mouse_x, self.time_line) + label = f"{ticks} ticks" + x = self.mouse_x - 6 * len(label) if self.mouse_x >= self.width() - 100 else self.mouse_x - 2 * len(label) + painter.drawText(x, self.TOP - 10, label) + + def _draw_report(self, painter, text_color): + painter.setPen(text_color) + painter.setFont(QFont("Arial", 9)) + top = self.report_top + value_x = 200 + + if self.app_filter == -1: + painter.drawText(10, top, "----------------------- CPU utilization ------------------------") + painter.drawText(10, top + 15, "Total CPU time: ") + painter.drawText(value_x, top + 15, f"{self.total_cpu_time} ticks") + for line, code in enumerate(self.rows, start=2): + percent = self.usage[code] * 100.0 / self.total_cpu_time if self.total_cpu_time else 0.0 + painter.drawText(10, top + 15 * line, f"{self.names[code]} : ") + painter.drawText(value_x, top + 15 * line, f"{percent:.3f}%") + + if self.selected is None: + return + event = self.selected + x = 400 if self.app_filter == -1 else 10 + if self.mouse_x is not None and self.mouse_x > x: + x = self.mouse_x - 300 if self.mouse_x >= self.width() - 300 else self.mouse_x + lines = (("Name : ", event.name), + ("Start time : ", f"{event.start} ticks"), + ("Finish time : ", f"{event.finish} ticks"), + ("Slice Time : ", f"{event.finish - event.start} ticks")) + painter.drawText(x, top, "----------------------- Execution Slice------------------------") + for line, (label, value) in enumerate(lines, start=1): + painter.drawText(x, top + 15 * line, label) + painter.drawText(x + 200, top + 15 * line, value) + + # ------------------------------------------ + # Mouse + # ------------------------------------------ + def _event_at(self, point): + proportion = self._proportion() + if not proportion: + return None + # Trechos com início até a posição do mouse (com 1 px de tolerância) + tick = (point.x() + 1 - self._name_width()) / proportion + index = bisect.bisect_right(self.starts, tick) + for event in reversed(self.events[max(0, index - 64):index]): + if event.rect is not None and event.rect.adjusted(0, 0, 1, 1).contains(point): + return event + return None + + def mouseMoveEvent(self, event): + point = event.position().toPoint() + hit = self._event_at(point) + self.setCursor(Qt.CursorShape.PointingHandCursor if hit else Qt.CursorShape.ArrowCursor) + inside = point.x() >= self._name_width() and self.TOP <= point.y() <= self.time_line + self.mouse_x = point.x() if inside else None + self.update() + + def leaveEvent(self, event): + self.mouse_x = None + self.update() + super().leaveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.selected = self._event_at(event.position().toPoint()) + self.update() + self.selection_changed.emit(self.selected) + + def wheelEvent(self, event): + if not event.modifiers() & Qt.KeyboardModifier.ControlModifier: + event.ignore() # rolagem normal do QScrollArea + return + factor = 1.1 if event.angleDelta().y() > 0 else 1 / 1.1 + self.setMinimumWidth(max(400, int(self.width() * factor))) + self.resize(self.minimumWidth(), self.height()) + event.accept() + + +class SchedulingGraphWindow(QWidget): + def __init__(self, mpsoc_config, router_address, app_filter=-1): + super().__init__() + self.graph = SchedulingGraph(mpsoc_config, router_address, app_filter) + name = f"scheduling_{router_address}" if app_filter == -1 else f"scheduling_app_{app_filter}" + export.install_export_actions(self.graph, name, png_widget=self.graph) + + if app_filter == -1: + self.setWindowTitle(f"Scheduling Graph {_router_label(mpsoc_config, router_address)}") + else: + self.setWindowTitle(f"Application {app_filter} Scheduling Graph") + self.setWindowIcon(QPixmap(os.path.join(ICON_DIR, "scheduling_icon.png"))) + + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setWidget(self.graph) + scroll.horizontalScrollBar().setSingleStep(200) + + # Pacotes do PE durante o trecho clicado (depuração hardware + software) + self.mpsoc_config = mpsoc_config + self._tracker = None + self._slice_messages = [] + self.slice_label = QLabel("Click a slice of the graph to see the packets the PE received and sent " + "during it (e.g. the packet that caused an interruption).") + self.slice_label.setWordWrap(True) + self.slice_table = _read_only_table(["Time (tick)", "Direction", "From", "To", "Service", "Flits", + "Latency (cycles)", "Tasks"]) + self.slice_table.setToolTip("Double click a packet to show its path on the main window mesh") + self.slice_table.cellDoubleClicked.connect(self._show_slice_message) + export.install_export_actions(self.slice_table, f"{name}_slice_packets", csv_view=self.slice_table) + packets_panel = QWidget() + packets_layout = QVBoxLayout(packets_panel) + packets_layout.addWidget(self.slice_label) + packets_layout.addWidget(self.slice_table) + self.graph.selection_changed.connect(self._show_slice_packets) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(scroll) + splitter.addWidget(packets_panel) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(splitter) + + screen = self.screen().availableGeometry() + graph_height = min(screen.height() - 240, self.graph.minimumHeight() + 30) + splitter.setSizes([graph_height, 220]) + self.resize(screen.width(), min(screen.height(), graph_height + 240)) + self.move(screen.topLeft()) + + def _all_messages(self): + # O trace inteiro é analisado uma vez, no primeiro clique + if self._tracker is None: + self._tracker = analysis.track_all(self.mpsoc_config, analysis.read_all_packets(self.mpsoc_config)) + return self._tracker.messages + + def _show_slice_packets(self, event): + config = self.mpsoc_config + if event is None: + self._slice_messages = [] + self.slice_table.setRowCount(0) + return + + self._slice_messages = analysis.messages_in_slice(self._all_messages(), event.pe, event.start, event.finish) + self.slice_label.setText( + f"{event.name} on PE {_router_label(config, event.pe)}, {event.start} → {event.finish} ticks: " + f"{len(self._slice_messages)} packet(s). Received = delivered to the PE up to " + f"{analysis.SLICE_LOOKBACK} cycles before the slice or during it; sent = injected during it.") + + table = self.slice_table + table.setRowCount(len(self._slice_messages)) + for row, (message, direction) in enumerate(self._slice_messages): + time = message.delivered if direction == "received" else message.injected + source = _router_label(config, message.source) + if message.from_peripheral: + source = f"Periph. @ {source}" + tasks = (f"{_slice_task(config, message.task_source)} → {_slice_task(config, message.task_target)}" + if message.task_source >= 0 or message.task_target >= 0 else "-") + values = (str(time), direction, source, _router_label(config, message.target), + config.get_string_service_name(message.service), str(message.flits), + str(message.latency) if message.delivered >= 0 else "not delivered", tasks) + for column, value in enumerate(values): + table.setItem(row, column, QTableWidgetItem(value)) + + def _show_slice_message(self, row, _column): + if 0 <= row < len(self._slice_messages): + message, _direction = self._slice_messages[row] + path_view.show_message(message, description=f"Packet {self.mpsoc_config.get_string_service_name(message.service)} " + f"from the scheduling graph") + + +# ========================================== +# TASK INFORMATION +# ========================================== +class TaskInfoWindow(QWidget): + """ + Estado de uma tarefa do PE e as mensagens que ela pediu (TaskInfoFrame.java). + Cada MESSAGE_REQUEST fica "pending" até chegar o MESSAGE_DELIVERY da + mesma tarefa remota, quando passa a "checked". + """ + + def __init__(self, mpsoc_config, router_window, task_id): + super().__init__() + self.mpsoc_config = mpsoc_config + self.router_window = router_window + self.task_id = task_id + name = _task_name(mpsoc_config, task_id) + self.setWindowTitle(f"Task {name} Information") + self.resize(560, 420) + + bold = QFont() + bold.setBold(True) + + def caption(text): + label = QLabel(text) + label.setFont(bold) + return label + + self.name_label = QLabel(name) + self.id_label = QLabel(str(task_id)) + self.status_label = QLabel() + self.time_label = QLabel() + + info = QGridLayout() + info.addWidget(caption("Task Name:"), 0, 0) + info.addWidget(self.name_label, 0, 1) + info.addWidget(caption("Task ID:"), 1, 0) + info.addWidget(self.id_label, 1, 1) + info.addWidget(caption("Task Status:"), 0, 2) + info.addWidget(self.status_label, 0, 3) + info.addWidget(caption("Executed Time:"), 1, 2) + info.addWidget(self.time_label, 1, 3) + info.setColumnStretch(1, 1) + info.setColumnStretch(3, 1) + + pipe_button = QPushButton("View PIPE") + request_button = QPushButton("Requested") + pipe_button.clicked.connect(lambda: self._show_comm_table("pipe", "Stored PIPE Messages")) + request_button.clicked.connect(lambda: self._show_comm_table("request", "Stored Message Requests")) + buttons = QHBoxLayout() + buttons.addWidget(pipe_button) + buttons.addWidget(request_button) + buttons.addStretch() + + self.message_table = _read_only_table(["Time: Requested / Delivered", "Remote task", "Delivered", "Num"]) + export.install_export_actions(self.message_table, f"task_{task_id}_messages", csv_view=self.message_table) + header = self.message_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + for column in (1, 2, 3): + header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents) + + layout = QVBoxLayout(self) + layout.addLayout(info) + layout.addLayout(buttons) + layout.addWidget(caption("Sent Message Requests:")) + layout.addWidget(self.message_table) + + self.comm_windows = [] + self.timer = QTimer(self) + self.timer.timeout.connect(self.refresh) + self.timer.start(TASK_INFO_REFRESH_MS) + self.refresh() + + def refresh(self): + try: + tasks = self.router_window.router_info.get_tasks_information() + except RuntimeError: + self.timer.stop() # janela do roteador já destruída + return + self._update_labels(tasks) + self._update_message_table(tasks) + + def _update_labels(self, tasks): + allocated, terminated = _task_lifetime(tasks, self.task_id) + if terminated is None: + self.status_label.setText("Running") + self.time_label.setText("-") + else: + self.status_label.setText("Terminated") + self.time_label.setText(str(terminated - allocated)) + + def _update_message_table(self, tasks): + pending = [] + rows = [] + for task in tasks: + if task.id != self.task_id: + continue + if task.service == "MESSAGE_REQUEST": + pending.append(task) + elif task.service == "MESSAGE_DELIVERY": + # A entrega responde o pedido mais antigo para a mesma tarefa remota + for index, request in enumerate(pending): + if request.remote_task_id == task.remote_task_id: + del pending[index] + rows.append((f"Requested: {request.time} Delivered: {task.time}", + task.remote_task_id, "checked")) + break + rows.extend((f"Requested: {request.time} Delivered: -", request.remote_task_id, "pending") + for request in pending) + + table = self.message_table + table.setRowCount(len(rows)) + counter = {} + for row, (times, remote, status) in enumerate(rows): + counter[remote] = counter.get(remote, 0) + 1 + values = (times, _task_name(self.mpsoc_config, remote), status, str(counter[remote])) + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + if column == 2: + _highlight(item, CHECKED_BACKGROUND if status == "checked" else PENDING_BACKGROUND) + table.setItem(row, column, item) + + def _comm_file(self, folder): + """debug//.txt; os arquivos usam o endereço hamiltoniano.""" + config = self.mpsoc_config + address = self.router_window.router_address + candidates = [address] + if config.router_addressing == MPSoCConfig.XY: + candidates.append(_pe_xy_address(config, address)) # nome usado pelo Java + for candidate in candidates: + path = os.path.join(config.debug_file_path, folder, f"{candidate}.txt") + if os.path.isfile(path): + return path + return None + + def _show_comm_table(self, folder, title): + path = self._comm_file(folder) + rows = read_comm_debug(path) if path else None + if not rows: + what = "PIPE" if folder == "pipe" else "request" + QMessageBox.information(self, title, f"No {what} information") + return + + window = QWidget() + window.setWindowTitle(title) + window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + window.resize(320, 300) + table = _read_only_table(["Prod Task", "Cons Task", "Time"]) + table.setRowCount(len(rows)) + for row, (producer, consumer, time) in enumerate(rows): + values = (_task_name(self.mpsoc_config, producer), _task_name(self.mpsoc_config, consumer), time) + for column, value in enumerate(values): + table.setItem(row, column, QTableWidgetItem(value)) + layout = QVBoxLayout(window) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(table) + + window.destroyed.connect(lambda *_, w=window: self.comm_windows.remove(w)) + self.comm_windows.append(window) + window.move(self.geometry().center() - window.rect().center()) + window.show() + + def closeEvent(self, event): + self.timer.stop() + for window in list(self.comm_windows): + window.close() + super().closeEvent(event) + + +def read_comm_debug(path): + """ + Lê debug/pipe/.txt ou debug/request/.txt (add|remprodconstempo) + e retorna as mensagens que continuam armazenadas: [(prod, cons, tempo), ...]. + """ + stored = [] + try: + with open(path, "r", encoding="utf-8") as comm_file: + for line in comm_file: + fields = line.strip().split("\t") + if len(fields) < 4: + continue + try: + producer, consumer = int(fields[1]), int(fields[2]) + except ValueError: + continue + if fields[0] == "add": + stored.append((producer, consumer, fields[3])) + else: + # Remove a primeira mensagem com o mesmo produtor e consumidor + for index, (prod, cons, _time) in enumerate(stored): + if (prod, cons) == (producer, consumer): + del stored[index] + break + except OSError: + return None + return stored diff --git a/router_matrix.py b/router_matrix.py index 750a698..f4a0b95 100644 --- a/router_matrix.py +++ b/router_matrix.py @@ -2,9 +2,10 @@ """ router_matrix.py -Monta a matriz gráfica de roteadores (grade NoC/MPSoC) dentro de um QWidget, -usando como base o "Ui_Form" gerado a partir de Roteador.ui (arquivo -Roteador.py) para desenhar cada roteador individual. +Desenha a matriz de roteadores (grade NoC/MPSoC) com QGraphicsView / +QGraphicsScene: corpo dos roteadores, setas dos enlaces, porta local e +periféricos são desenhados em vetor pelo próprio Qt (sem imagens), então a +malha pode ser ampliada/reduzida sem perder nitidez. Semântica dos campos de MPSoCConfig (igual ao formato de configuração do Memphis: mpsoc_dimension é o tamanho TOTAL da malha, cluster_dimension é o @@ -15,433 +16,780 @@ gerenciamento dentro da malha), usado só para desenhar a borda de separação entre clusters -Ou seja, o total de roteadores é sempre mpsoc_x * mpsoc_y — cluster_x e -cluster_y NÃO multiplicam esse total, apenas dividem a malha em blocos -visuais (com borda) de cluster_x por cluster_y roteadores cada. +A numeração de cada roteador segue a convenção do debugger original: o +roteador "0x0" fica no canto inferior esquerdo e o eixo Y cresce para cima. -A numeração de cada roteador segue a mesma convenção do print de -referência: o roteador "0x0" fica no canto inferior esquerdo e o eixo Y -cresce para cima (0x0, 0x1, 0x2, ... de baixo para cima). +Cada enlace entre dois roteadores vizinhos tem, por canal (HIGH/LOW), uma +seta em cada sentido. A mesma seta é, ao mesmo tempo, a saída de um roteador +e a entrada do vizinho (ex.: EAST_OUT_HIGH de 0x0 == WEST_IN_HIGH de 1x0), +por isso os dois roteadores guardam a mesma ArrowItem. Nas bordas da malha, +as setas ligam o roteador à caixa do periférico (desenhada fora do roteador). Durante a simulação, cada roteador pinta de vermelho as setas por onde o pacote atual passou (paint_arrow) e mostra a taxa de uso de cada enlace (update_throughput), igual ao Roteador.java do debugger original. + +Zoom: Ctrl + roda do mouse (ou gesto de pinça), Ctrl +/-/0, os botões no +canto da vista ou duplo clique no fundo (ajusta a malha à janela). Arrastar +o fundo move a vista. """ import math -import os -from PySide6.QtCore import QEvent, QObject, QRect, Qt, Signal -from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPixmap -from PySide6.QtWidgets import QFrame, QGridLayout, QWidget +from PySide6.QtCore import QEvent, QPointF, QRectF, QSize, Qt, QTimer, Signal +from PySide6.QtGui import (QBrush, QColor, QFont, QFontMetricsF, QImage, QKeySequence, QPainter, + QPainterPath, QPen, QPolygonF, QShortcut) +from PySide6.QtWidgets import (QFrame, QGraphicsItem, QGraphicsObject, QGraphicsRectItem, QGraphicsScene, + QGraphicsView, QHBoxLayout, QLabel, QToolButton) import theme -from Roteador import Ui_Form from util.MPSoCConfig import MPSoCConfig -IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images") +# ========================================== +# GEOMETRIA (unidades da cena = pixels com zoom 100%) +# ========================================== +BODY = 130 # lado do corpo do roteador +GAP = 64 # espaço entre dois roteadores (onde ficam as setas) +PITCH = BODY + GAP +ARROW_OFFSET = 8 # distância de cada seta do par ao centro do canal +ARROW_MARGIN = 4 # folga entre a ponta da seta e o corpo do roteador +SHAFT_WIDTH = 4.5 +HEAD_LENGTH = 11 +HEAD_WIDTH = 12 + +# Caixa do periférico: mais larga nas bordas norte/sul (setas verticais) e mais +# alta nas bordas leste/oeste, para cobrir os dois pares de setas +PERIPHERAL_NS = QSize(110, 38) +PERIPHERAL_EW = QSize(100, 90) + +ZOOM_MIN, ZOOM_MAX, ZOOM_STEP = 0.1, 8.0, 1.25 + +# Cor do corpo do roteador de acordo com o tipo de PE (as mesmas das imagens antigas) +_ROUTER_COLORS = { + MPSoCConfig.SLAVE: "#b7dde8", + MPSoCConfig.CLUSTER_MASTER: "#c4d6a0", + MPSoCConfig.GLOBAL_MASTER: "#f9c499", +} +_PE_TYPE_NAMES = { + MPSoCConfig.SLAVE: "Slave PE", + MPSoCConfig.CLUSTER_MASTER: "Cluster manager PE", + MPSoCConfig.GLOBAL_MASTER: "Global manager PE", +} +_BODY_BORDER = "#2d2e2f" +_HIGHLIGHT = "#ff8c00" + +_SIDE_NAMES = {"N": "north", "S": "south", "E": "east", "W": "west"} +_OPPOSITE = {"N": "S", "S": "N", "E": "W", "W": "E"} +# Direção "para fora" de cada lado, em coordenadas da cena (Y cresce para baixo) +_OUTWARD = {"N": (0, -1), "S": (0, 1), "E": (1, 0), "W": (-1, 0)} + +HIGH, LOW = MPSoCConfig.HIGH, MPSoCConfig.LOW + +# (lado, canal) -> (seta de entrada, seta de saída) nas constantes do MPSoCConfig +_SIDE_ARROWS = { + ("E", HIGH): (MPSoCConfig.EAST_IN_HIGH, MPSoCConfig.EAST_OUT_HIGH), + ("E", LOW): (MPSoCConfig.EAST_IN_LOW, MPSoCConfig.EAST_OUT_LOW), + ("W", HIGH): (MPSoCConfig.WEST_IN_HIGH, MPSoCConfig.WEST_OUT_HIGH), + ("W", LOW): (MPSoCConfig.WEST_IN_LOW, MPSoCConfig.WEST_OUT_LOW), + ("N", HIGH): (MPSoCConfig.NORTH_IN_HIGH, MPSoCConfig.NORTH_OUT_HIGH), + ("N", LOW): (MPSoCConfig.NORTH_IN_LOW, MPSoCConfig.NORTH_OUT_LOW), + ("S", HIGH): (MPSoCConfig.SOUTH_IN_HIGH, MPSoCConfig.SOUTH_OUT_HIGH), + ("S", LOW): (MPSoCConfig.SOUTH_IN_LOW, MPSoCConfig.SOUTH_OUT_LOW), +} -# Cache das imagens já carregadas (QPixmap só pode ser criado com a QApplication ativa) -_pixmap_cache: dict[str, QPixmap] = {} +# Porta física -> (lado, canal) do rótulo com a taxa de uso do enlace +_PORT_SIDES = { + MPSoCConfig.EAST0: ("E", HIGH), MPSoCConfig.EAST1: ("E", LOW), + MPSoCConfig.WEST0: ("W", HIGH), MPSoCConfig.WEST1: ("W", LOW), + MPSoCConfig.NORTH0: ("N", HIGH), MPSoCConfig.NORTH1: ("N", LOW), + MPSoCConfig.SOUTH0: ("S", HIGH), MPSoCConfig.SOUTH1: ("S", LOW), +} +_LOCAL_PORTS = (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1) -def _white_to_alpha(image: QImage) -> QImage: +def _channel_positions(mpsoc_config) -> dict[int, float]: """ - Remove o fundo branco das imagens das setas (os PNGs não têm canal - alfa), para que fiquem legíveis sobre o fundo escuro do modo escuro. - - A cor mais distante do branco é tomada como a cor da seta; os pixels - mais claros (fundo e serrilhado da borda) viram essa cor com opacidade - proporcional à distância do branco. + Posição (ao longo do lado do roteador) do centro de cada canal. Com um + canal físico só existem as portas ímpares (LOW, ver read_traffic_data), + que ficam no meio do lado. """ - image = image.convertToFormat(QImage.Format.Format_ARGB32) - width, height = image.width(), image.height() - - def distance(color: QColor) -> int: - return 255 - min(color.red(), color.green(), color.blue()) - - pixels = [[image.pixelColor(x, y) for x in range(width)] for y in range(height)] - base = max((color for row in pixels for color in row), key=distance) - base_distance = distance(base) or 1 - - for y, row in enumerate(pixels): - for x, color in enumerate(row): - alpha = distance(color) / base_distance - if alpha < 1: - faded = QColor(base) - faded.setAlphaF(alpha) - image.setPixelColor(x, y, faded) - - return image - - -def _pixmap(name: str) -> QPixmap: - if name not in _pixmap_cache: - image = QImage(os.path.join(IMAGES_DIR, name)) - # As imagens do corpo do roteador são opacas e não têm fundo branco - if not name.startswith("Router"): - image = _white_to_alpha(image) - _pixmap_cache[name] = QPixmap.fromImage(image) - return _pixmap_cache[name] - - -# Imagem do roteador de acordo com o tipo de PE -_ROUTER_IMAGES = { - MPSoCConfig.SLAVE: "Router.png", - MPSoCConfig.CLUSTER_MASTER: "Router_cluster_master.png", - MPSoCConfig.GLOBAL_MASTER: "Router_master.png", -} + if mpsoc_config is not None and mpsoc_config.channel_number == 1: + return {LOW: BODY * 0.5} + return {HIGH: BODY * 0.3, LOW: BODY * 0.7} -# Seta -> (widget do Roteador.ui, imagem quando pintada de vermelho) -_ARROWS = { - MPSoCConfig.EAST_IN_HIGH: ("in_east_HIGH", "red_left.png"), - MPSoCConfig.EAST_OUT_HIGH: ("out_east_HIGH", "red_undirected_h.png"), - MPSoCConfig.EAST_IN_LOW: ("in_east_LOW", "red_left.png"), - MPSoCConfig.EAST_OUT_LOW: ("out_east_LOW", "red_undirected_h.png"), - MPSoCConfig.NORTH_IN_HIGH: ("in_north_HIGH", "red_down.png"), - MPSoCConfig.NORTH_OUT_HIGH: ("out_north_HIGH", "red_undirected_v.png"), - MPSoCConfig.NORTH_IN_LOW: ("in_north_LOW", "red_down.png"), - MPSoCConfig.NORTH_OUT_LOW: ("out_north_LOW", "red_undirected_v.png"), - MPSoCConfig.WEST_IN_HIGH: ("in_weast_HIGH", "red_right.png"), - MPSoCConfig.WEST_OUT_HIGH: ("out_weast_HIGH", "red_undirected_h.png"), - MPSoCConfig.WEST_IN_LOW: ("in_weast_LOW", "red_right.png"), - MPSoCConfig.WEST_OUT_LOW: ("out_weast_LOW", "red_undirected_h.png"), - MPSoCConfig.SOUTH_IN_HIGH: ("in_south_HIGH", "red_up.png"), - MPSoCConfig.SOUTH_OUT_HIGH: ("out_south_HIGH", "red_undirected_v.png"), - MPSoCConfig.SOUTH_IN_LOW: ("in_south_LOW", "red_up.png"), - MPSoCConfig.SOUTH_OUT_LOW: ("out_south_LOW", "red_undirected_v.png"), - MPSoCConfig.LOCAL_IN: ("local_in", "red_local_in.png"), - MPSoCConfig.LOCAL_OUT: ("local_out", "red_local_out.png"), -} -# Setas (e imagens em estado normal) de cada lado do roteador; só são -# desenhadas quando existe um vizinho naquele lado -_WEST_ARROWS = {"out_weast_HIGH": "undirected_h.png", "in_weast_HIGH": "right.png", - "out_weast_LOW": "undirected_h.png", "in_weast_LOW": "right.png"} -_EAST_ARROWS = {"in_east_LOW": "left.png", "out_east_LOW": "undirected_h.png", - "in_east_HIGH": "left.png", "out_east_HIGH": "undirected_h.png"} -_SOUTH_ARROWS = {"in_south_LOW": "up.png", "out_south_LOW": "undirected_v.png", - "in_south_HIGH": "up.png", "out_south_HIGH": "undirected_v.png"} -_NORTH_ARROWS = {"out_north_HIGH": "undirected_v.png", "in_north_HIGH": "down.png", - "out_north_LOW": "undirected_v.png", "in_north_LOW": "down.png"} - -# Porta física -> rótulo com a taxa de uso do enlace -_PORT_LABELS = { - MPSoCConfig.EAST0: "east_HIGH_Label", - MPSoCConfig.EAST1: "east_LOW_Label", - MPSoCConfig.NORTH0: "north_HIGH_Label", - MPSoCConfig.NORTH1: "north_LOW_Label", - MPSoCConfig.WEST0: "weast_HIGH_Label", - MPSoCConfig.WEST1: "weast_LOW_Label", - MPSoCConfig.SOUTH0: "south_HIGH_Label", - MPSoCConfig.SOUTH1: "south_LOW_Label", - MPSoCConfig.LOCAL0: "local_Label", - MPSoCConfig.LOCAL1: "local_Label", -} +def _percent_font() -> QFont: + # "Andale Mono" nem sempre está instalada; qualquer monoespaçada serve + font = QFont("Monospace") + font.setStyleHint(QFont.StyleHint.Monospace) + font.setPixelSize(10) + return font -class _ImagePainter(QObject): - """ - Desenha uma imagem esticada sobre os QWidgets "vazios" do Roteador.ui - (setas e corpo do roteador), no lugar do UJPanelImagem do Java. - """ +def _format_load(value: float) -> str: + return f"{min(value, 99.99):05.2f}%" + + +class _MeshScene(QGraphicsScene): + """Cena da malha; guarda as cores do tema atual, lidas pelos itens ao pintar.""" def __init__(self, parent=None): super().__init__(parent) - self.images: dict[QWidget, str] = {} - - def set_image(self, widget: QWidget, name: str | None) -> None: - """Define a imagem do widget; None deixa o widget vazio.""" - if self.images.get(widget) == name: - return - if widget not in self.images: - widget.installEventFilter(self) - self.images[widget] = name - widget.update() - - def eventFilter(self, obj, event): - if event.type() == QEvent.Type.Paint and obj in self.images: - if self.images[obj] is None: - return True - painter = QPainter(obj) - painter.drawPixmap(obj.rect(), _pixmap(self.images[obj])) - painter.end() - return True - return super().eventFilter(obj, event) + self.colors = theme.matrix_colors(False) -def _matrix_stylesheet(dark: bool) -> str: - """ - Folha de estilo da grade inteira (aplicada no RouterMatrixWidget e - herdada pelos roteadores). Os rótulos dentro do corpo do roteador são - sempre pretos, pois o corpo tem cor clara nos dois temas; o % da porta - local fica fora do corpo e acompanha o tema. +# ========================================== +# ITENS DA CENA +# ========================================== +class ArrowItem(QGraphicsItem): """ - colors = theme.matrix_colors(dark) - return f""" - #routerMatrix {{ - background-color: {colors.canvas}; - }} - #routerMatrix QLabel {{ - background-color: transparent; - color: #000000; - }} - #routerMatrix QLabel#local_Label {{ - color: {colors.text}; - }} - #routerMatrix QFrame[clusterFrame="true"] {{ - background-color: transparent; - border: 1px solid {colors.cluster_border}; - }} + Seta de um enlace, de `start` até `end`. Fica cinza normalmente e vermelha + quando o pacote atual passou por ela. Setas `optional` (bordas da malha sem + periférico conhecido) só aparecem enquanto estão pintadas. """ - -class RouterWidget(QWidget, Ui_Form): + def __init__(self, start: QPointF, end: QPointF, optional: bool = False, tooltip: str = ""): + super().__init__() + self.painted = False + self.optional = optional + + delta = end - start + self._length = math.hypot(delta.x(), delta.y()) + self.setPos(start) + self.setRotation(math.degrees(math.atan2(delta.y(), delta.x()))) + self.setZValue(1) + if tooltip: + self.setToolTip(tooltip) + + length, shaft, head = self._length, SHAFT_WIDTH / 2, HEAD_WIDTH / 2 + neck = length - HEAD_LENGTH + self._polygon = QPolygonF([ + QPointF(0, -shaft), QPointF(neck, -shaft), QPointF(neck, -head), QPointF(length, 0), + QPointF(neck, head), QPointF(neck, shaft), QPointF(0, shaft), + ]) + self._update_visibility() + + def set_painted(self, painted: bool) -> None: + if painted != self.painted: + self.painted = painted + self._update_visibility() + self.update() + + def set_optional(self, optional: bool) -> None: + self.optional = optional + self._update_visibility() + + def _update_visibility(self) -> None: + self.setVisible(self.painted or not self.optional) + + def boundingRect(self) -> QRectF: + return QRectF(0, -HEAD_WIDTH / 2 - 1, self._length + 1, HEAD_WIDTH + 2) + + def paint(self, painter: QPainter, option, widget=None) -> None: + colors = self.scene().colors + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(colors.arrow_active if self.painted else colors.arrow)) + painter.drawPolygon(self._polygon) + + +class PeripheralItem(QGraphicsItem): + """Caixa com o nome do periférico ligado a uma porta de borda, fora do roteador.""" + + def __init__(self, rect: QRectF, name: str, router_label: str, side: str): + super().__init__() + self._rect = rect + self.name = name + self.setZValue(1) + self.setToolTip(f"Peripheral {name} at the {_SIDE_NAMES[side]} port of router {router_label}") + self._font = QFont() + self._font.setBold(True) + self._font.setPixelSize(12) + + def boundingRect(self) -> QRectF: + return self._rect.adjusted(-1, -1, 1, 1) + + def paint(self, painter: QPainter, option, widget=None) -> None: + colors = self.scene().colors + color = QColor(colors.peripheral_text) + painter.setPen(QPen(color, 1.5)) + painter.setBrush(QColor(colors.peripheral_fill)) + painter.drawRoundedRect(self._rect, 6, 6) + + painter.setFont(self._font) + text_rect = self._rect.adjusted(6, 4, -6, -4) + text = QFontMetricsF(self._font).elidedText(self.name, Qt.TextElideMode.ElideRight, text_rect.width()) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + + +class RouterItem(QGraphicsObject): """ - Um único roteador da malha. Reaproveita o layout gerado em Roteador.py - e apenas customiza o texto central (routerLabel) para exibir a posição - "XxY" do roteador dentro da malha, igual ao print de referência. + Um roteador da malha: corpo colorido pelo tipo de PE, nome no centro, + porcentagem de uso de cada enlace junto ao lado correspondente e, no canto + superior esquerdo (fora do corpo), as setas e a porcentagem da porta local. + As setas dos enlaces são itens próprios (ArrowItem), compartilhados com o + vizinho, e ficam em `self.arrows`. """ # Emitido com o endereço hamiltoniano quando o roteador é clicado clicked = Signal(int) - def __init__(self, pos_x: int, pos_y: int, parent=None, - mpsoc_x: int = 1, mpsoc_y: int = 1, mpsoc_config=None): - super().__init__(parent) - self.setupUi(self) - + def __init__(self, pos_x: int, pos_y: int, mpsoc_x: int = 1, mpsoc_y: int = 1, mpsoc_config=None): + super().__init__() self.pos_x = pos_x self.pos_y = pos_y self.mpsoc_x = mpsoc_x self.mpsoc_y = mpsoc_y - self.need_reset = True + self.need_reset = False + self.highlighted = False + self._hovered = False if mpsoc_config is not None: self.router_address = mpsoc_config.xy_to_ham_addr((pos_x << 8) | pos_y) self.pe_type = mpsoc_config.get_pe_type(self.router_address) - if mpsoc_config.channel_number == 1: - self._set_single_channel_geometry() else: self.router_address = pos_y * mpsoc_x + pos_x self.pe_type = MPSoCConfig.SLAVE + self.xy_label = f"{pos_x}x{pos_y}" if mpsoc_config is not None and mpsoc_config.router_addressing == MPSoCConfig.HAMILTONIAN: - self.routerLabel.setText(str(self.router_address)) + self.label = str(self.router_address) else: - self.routerLabel.setText(f"{pos_x}x{pos_y}") - - self.router.setCursor(Qt.CursorShape.PointingHandCursor) - self._painter = _ImagePainter(self) - - # "Andale Mono" nem sempre está instalada; usa qualquer fonte - # monoespaçada em tamanho fixo para o "00.00%" caber no rótulo - percent_font = QFont("Monospace") - percent_font.setStyleHint(QFont.StyleHint.Monospace) - percent_font.setPixelSize(10) - for label_name in set(_PORT_LABELS.values()): - getattr(self, label_name).setFont(percent_font) - - # O Roteador.ui fixa fundo branco no Form inteiro; as cores passam a - # vir da folha de estilo da matriz (_matrix_stylesheet), que segue o tema - self.setStyleSheet("") - - # Mesma posição do Java: acima do corpo do roteador, sem cruzar a borda - self.local_Label.setGeometry(QRect(21, 16, 40, 13)) - - self.reset_arrows() - - def _set_single_channel_geometry(self) -> None: - """Com apenas um canal físico, as setas LOW ocupam o centro de cada lado.""" - self.north_LOW_Label.setGeometry(QRect(70, 0, 40, 20)) - self.in_north_LOW.setGeometry(QRect(110, 0, 20, 30)) - self.out_north_LOW.setGeometry(QRect(77, 0, 6, 30)) - - self.east_LOW_Label.setGeometry(QRect(90, 30, 40, 20)) - self.in_east_LOW.setGeometry(QRect(160, 63, 33, 20)) - self.out_east_LOW.setGeometry(QRect(160, 107, 30, 6)) - - self.south_LOW_Label.setGeometry(QRect(40, 110, 40, 20)) - self.in_south_LOW.setGeometry(QRect(70, 160, 20, 30)) - self.out_south_LOW.setGeometry(QRect(117, 160, 6, 30)) - - self.weast_LOW_Label.setGeometry(QRect(1, 70, 40, 20)) - self.in_weast_LOW.setGeometry(QRect(0, 100, 30, 20)) - self.out_weast_LOW.setGeometry(QRect(0, 70, 30, 6)) + self.label = self.xy_label + + self._channels = _channel_positions(mpsoc_config) + + # Constante do MPSoCConfig (*_IN_* / *_OUT_* / LOCAL_*) -> ArrowItem + self.arrows: dict[int, ArrowItem] = {} + # Periféricos ligados às portas de borda: lado ("N", "S", "E", "W") -> nome + self.peripherals: dict[str, str] = {} + # Callback da matriz que desenha a caixa do periférico + self._peripheral_added = None + + self._loads = {key: _format_load(0) for key in _PORT_SIDES.values()} + self._local_load = _format_load(0) + + self._name_font = QFont("Arial") + self._name_font.setBold(True) + self._name_font.setPixelSize(19) + self._percent_font = _percent_font() + + self.setZValue(2) + self.setAcceptHoverEvents(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + pe_name = _PE_TYPE_NAMES.get(self.pe_type, "PE") + self.setToolTip(f"Router {self.label} ({self.xy_label}, address {self.router_address})\n" + f"{pe_name} - click for details") + + # ---------- desenho ---------- + def body_rect(self) -> QRectF: + return QRectF(0, 0, BODY, BODY) + + def boundingRect(self) -> QRectF: + # Inclui a porcentagem da porta local (canto superior esquerdo, fora do + # corpo) e a moldura de destaque + return QRectF(-GAP, -GAP, BODY + GAP + 6, BODY + GAP + 6) + + def shape(self) -> QPainterPath: + # Só o corpo recebe cliques e hover + path = QPainterPath() + path.addRect(self.body_rect()) + return path + + def _label_rect(self, side: str, position: float) -> QRectF: + width, height, pad = 44, 14, 4 + if side == "E": + return QRectF(BODY - width - pad, position - height / 2, width, height) + if side == "W": + return QRectF(pad, position - height / 2, width, height) + if side == "N": + return QRectF(position - width / 2, pad, width, height) + return QRectF(position - width / 2, BODY - height - pad, width, height) + + def paint(self, painter: QPainter, option, widget=None) -> None: + colors = self.scene().colors + body = self.body_rect() + + painter.setPen(QPen(QColor(_BODY_BORDER), 2.5 if self._hovered else 1.2)) + painter.setBrush(QColor(_ROUTER_COLORS.get(self.pe_type, _ROUTER_COLORS[MPSoCConfig.SLAVE]))) + painter.drawRect(body) + + # O corpo tem cor clara nos dois temas: textos internos sempre pretos + painter.setPen(QColor("#000000")) + painter.setFont(self._name_font) + painter.drawText(body, Qt.AlignmentFlag.AlignCenter, self.label) + + painter.setFont(self._percent_font) + for (side, channel), text in self._loads.items(): + position = self._channels.get(channel) + if position is None: + continue + align = {"E": Qt.AlignmentFlag.AlignRight, "W": Qt.AlignmentFlag.AlignLeft}.get( + side, Qt.AlignmentFlag.AlignHCenter) + painter.drawText(self._label_rect(side, position), align | Qt.AlignmentFlag.AlignVCenter, text) + + # Porta local: acima das setas diagonais, no canto fora do corpo + painter.setPen(QColor(colors.text)) + painter.drawText(QRectF(-GAP + 2, -GAP + 1, 46, 13), Qt.AlignmentFlag.AlignLeft, self._local_load) + + if self.highlighted: + painter.setPen(QPen(QColor(_HIGHLIGHT), 4)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(body.adjusted(-4, -4, 4, 4), 6, 6) + + # ---------- interação ---------- + def hoverEnterEvent(self, event): + self._hovered = True + self.update() + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): + self._hovered = False + self.update() + super().hoverLeaveEvent(event) + + def mousePressEvent(self, event): + # Aceitar o clique impede que a vista comece a arrastar a partir do roteador + if event.button() == Qt.MouseButton.LeftButton: + event.accept() + else: + super().mousePressEvent(event) def mouseReleaseEvent(self, event): - if self.router.geometry().contains(event.position().toPoint()): + if event.button() == Qt.MouseButton.LeftButton and self.body_rect().contains(event.pos()): self.clicked.emit(self.router_address) super().mouseReleaseEvent(event) + # ---------- estado da simulação ---------- def reset_arrows(self) -> None: """Volta todas as setas para o estado normal (sem pacote passando).""" self.need_reset = False - - self._painter.set_image(self.router, _ROUTER_IMAGES.get(self.pe_type, "Router.png")) - self._painter.set_image(self.local_in, "local_in.png") - self._painter.set_image(self.local_out, "local_out.png") - - # Setas de bordas sem vizinho ficam vazias; só aparecem (em vermelho) - # quando um pacote de periférico passa por elas - sides = ( - (_WEST_ARROWS, self.pos_x != 0), - (_EAST_ARROWS, self.pos_x != self.mpsoc_x - 1), - (_SOUTH_ARROWS, self.pos_y != 0), - (_NORTH_ARROWS, self.pos_y != self.mpsoc_y - 1), - ) - - for side, has_neighbor in sides: - for widget_name, image in side.items(): - self._painter.set_image(getattr(self, widget_name), image if has_neighbor else None) + for arrow in self.arrows.values(): + arrow.set_painted(False) + if self.highlighted: + self.highlighted = False + self.update() def paint_arrow(self, arrow: int) -> None: - """Pinta de vermelho a seta indicada (constantes *_IN_*/*_OUT_* do MPSoCConfig).""" - if arrow not in _ARROWS: + """Pinta de vermelho a seta indicada (constantes *_IN_*/*_OUT_*/LOCAL_* do MPSoCConfig).""" + item = self.arrows.get(arrow) + if item is not None: + self.need_reset = True + item.set_painted(True) + + def set_highlight(self, on: bool = True) -> None: + """Moldura laranja em volta do roteador (some no próximo reset das setas).""" + self.highlighted = on + if on: + self.need_reset = True + self.update() + + def set_peripheral(self, side: str, name: str) -> None: + """Mostra o periférico `name` ligado à porta de borda `side` ("N", "S", "E" ou "W").""" + if self.peripherals.get(side) == name: return - - widget_name, image = _ARROWS[arrow] - self.need_reset = True - self._painter.set_image(getattr(self, widget_name), image) + self.peripherals[side] = name + if self._peripheral_added is not None: + self._peripheral_added(self, side, name) def update_throughput(self, port: int, value: float) -> None: """Atualiza o percentual de uso do enlace da porta física informada.""" - label_name = _PORT_LABELS.get(port) - if label_name is not None: - self.set_link_load(getattr(self, label_name), value) - - def set_link_load(self, direction_label: QWidget, value: float) -> None: - """ - Atualiza o percentual mostrado em um dos rótulos de enlace - (north_HIGH_Label, south_LOW_Label, etc.). - """ - value = min(value, 99.99) - direction_label.setText(f"{value:05.2f}%") - - -class RouterMatrixWidget(QWidget): + text = _format_load(value) + if port in _LOCAL_PORTS: + changed = self._local_load != text + self._local_load = text + elif port in _PORT_SIDES: + key = _PORT_SIDES[port] + changed = self._loads[key] != text + self._loads[key] = text + else: + return + if changed: + self.update() + + def link_load(self, port: int) -> str: + """Texto mostrado para a taxa de uso da porta (usado nos testes).""" + if port in _LOCAL_PORTS: + return self._local_load + return self._loads[_PORT_SIDES[port]] + + +# ========================================== +# VISTA DA MALHA +# ========================================== +class _ZoomControls(QFrame): + """Botões de zoom no canto inferior direito da vista.""" + + def __init__(self, view: "RouterMatrixWidget"): + super().__init__(view) + self.setObjectName("zoomControls") + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setAutoFillBackground(True) + + layout = QHBoxLayout(self) + layout.setContentsMargins(3, 2, 3, 2) + layout.setSpacing(2) + + def button(text, tooltip, slot): + tool = QToolButton(self) + tool.setText(text) + tool.setToolTip(tooltip) + tool.setAutoRaise(True) + tool.clicked.connect(slot) + layout.addWidget(tool) + return tool + + button("−", "Zoom out (Ctrl+-)", view.zoom_out) + self.label = QLabel(self) + self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.label.setMinimumWidth(44) + self.label.setToolTip("Zoom level - Ctrl + mouse wheel to zoom") + layout.addWidget(self.label) + button("+", "Zoom in (Ctrl++)", view.zoom_in) + button("1:1", "Actual size (Ctrl+0)", view.reset_zoom) + button("Fit", "Fit the mesh to the window (double-click the background)", view.fit_to_view) + + def set_zoom(self, zoom: float) -> None: + self.label.setText(f"{round(zoom * 100)}%") + + +class RouterMatrixWidget(QGraphicsView): """ - Widget que organiza mpsoc_x * mpsoc_y roteadores em uma grade única, - desenhando uma borda a cada bloco de cluster_x por cluster_y roteadores - apenas para separação visual (sem alterar o total de roteadores). + Vista com os mpsoc_x * mpsoc_y roteadores, desenhando uma borda a cada + bloco de cluster_x por cluster_y roteadores apenas para separação visual + (sem alterar o total de roteadores). """ + zoom_changed = Signal(float) + def __init__(self, mpsoc_x: int, mpsoc_y: int, cluster_x: int, cluster_y: int, parent=None, mpsoc_config=None): - super().__init__(parent) + self._scene = _MeshScene() + super().__init__(self._scene, parent) + self._scene.setParent(self) self.mpsoc_config = mpsoc_config - - # Fundo e cores da grade seguem o tema (ver apply_theme) - self.setObjectName("routerMatrix") - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - # mpsoc_x/mpsoc_y é o tamanho TOTAL da malha. self.total_cols = max(1, mpsoc_x) self.total_rows = max(1, mpsoc_y) - # cluster_x/cluster_y é o tamanho de cada bloco visual; se não - # definido (0) ou maior que a própria malha, cai para "sem cluster" - # (um bloco único, sem bordas internas). + # cluster_x/cluster_y é o tamanho de cada bloco visual; se não definido + # (0) ou maior que a própria malha, cai para "sem cluster" self.cluster_x = max(1, min(cluster_x, self.total_cols)) if cluster_x else self.total_cols self.cluster_y = max(1, min(cluster_y, self.total_rows)) if cluster_y else self.total_rows - # Guarda referência de cada roteador por posição global (x, y) - # e pelo endereço hamiltoniano usado no traffic_router.txt - self.routers: dict[tuple[int, int], RouterWidget] = {} - self.routers_by_address: dict[int, RouterWidget] = {} + # Roteadores por posição global (x, y) e pelo endereço hamiltoniano + # usado no traffic_router.txt + self.routers: dict[tuple[int, int], RouterItem] = {} + self.routers_by_address: dict[int, RouterItem] = {} + self.peripheral_items: dict[tuple[int, int, str], PeripheralItem] = {} + self._cluster_items: list[QGraphicsRectItem] = [] + self._channels = _channel_positions(mpsoc_config) + + self._zoom = 1.0 + # Até o usuário mexer no zoom, a malha acompanha o tamanho da janela + self._auto_fit = True + + self.setObjectName("routerMatrix") + self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.TextAntialiasing) + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.SmartViewportUpdate) + self.setFrameShape(QFrame.Shape.NoFrame) + self.viewport().grabGesture(Qt.GestureType.PinchGesture) self._build_matrix() - def _build_matrix(self) -> None: - n_clusters_x = math.ceil(self.total_cols / self.cluster_x) - n_clusters_y = math.ceil(self.total_rows / self.cluster_y) - - draw_borders = self.cluster_x < self.total_cols or self.cluster_y < self.total_rows - - outer_layout = QGridLayout(self) - # Roteadores grudados uns nos outros: só deixamos um pequeno - # espaçamento entre blocos quando existe borda de cluster separando - # eles; sem clusters, a malha fica totalmente colada (spacing 0). - outer_layout.setSpacing(2 if draw_borders else 0) - outer_layout.setContentsMargins(0, 0, 0, 0) - - # Clusters em coordenadas de "cluster" (cx, cy), com cy=0 embaixo - for cluster_y in range(n_clusters_y): - for cluster_x in range(n_clusters_x): - cluster_widget = self._build_cluster( - cluster_x, cluster_y, draw_borders - ) - - # Linha 0 do grid = topo da janela, então invertemos o Y - # para que o cluster (0,0) fique embaixo, como no print. - grid_row = n_clusters_y - 1 - cluster_y - outer_layout.addWidget(cluster_widget, grid_row, cluster_x) - - self.setLayout(outer_layout) - - def _build_cluster(self, cluster_x: int, cluster_y: int, - draw_borders: bool) -> QFrame: - """ - Cria um bloco visual contendo até cluster_x * cluster_y roteadores - (o último bloco de cada linha/coluna pode ter menos roteadores caso - a dimensão total não seja múltipla do tamanho do cluster). - """ - cluster_frame = QFrame() - cluster_frame.setObjectName(f"cluster_{cluster_x}x{cluster_y}") - - if draw_borders: - cluster_frame.setFrameShape(QFrame.Shape.Box) - cluster_frame.setFrameShadow(QFrame.Shadow.Plain) - cluster_frame.setLineWidth(1) - # A cor da borda vem da folha de estilo da matriz (_matrix_stylesheet) - cluster_frame.setProperty("clusterFrame", True) - - cluster_layout = QGridLayout(cluster_frame) - cluster_layout.setSpacing(0) - # Roteadores dentro do bloco ficam colados; só reservamos uma borda - # (via margem) quando o bloco realmente representa um cluster. - margin = 1 if draw_borders else 0 - cluster_layout.setContentsMargins(margin, margin, margin, margin) - - # Início (inclusive) e fim (exclusivo) deste bloco na malha total - x_start = cluster_x * self.cluster_x - x_end = min(x_start + self.cluster_x, self.total_cols) - y_start = cluster_y * self.cluster_y - y_end = min(y_start + self.cluster_y, self.total_rows) - - block_rows = y_end - y_start - - for global_y in range(y_start, y_end): - for global_x in range(x_start, x_end): - router = RouterWidget(global_x, global_y, cluster_frame, - self.total_cols, self.total_rows, self.mpsoc_config) - self.routers[(global_x, global_y)] = router - self.routers_by_address[router.router_address] = router + self._controls = _ZoomControls(self) + self._controls.set_zoom(self._zoom) - local_x = global_x - x_start - # Linha 0 do grid = topo, então invertemos o Y local também - local_row = block_rows - 1 - (global_y - y_start) - cluster_layout.addWidget(router, local_row, local_x) + for keys, slot in ((QKeySequence.StandardKey.ZoomIn, self.zoom_in), + (QKeySequence.StandardKey.ZoomOut, self.zoom_out), + ("Ctrl+=", self.zoom_in), + ("Ctrl+0", self.reset_zoom)): + shortcut = QShortcut(QKeySequence(keys), self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(slot) - cluster_frame.setLayout(cluster_layout) - return cluster_frame + # ---------- construção ---------- + def _body_origin(self, x: int, y: int) -> QPointF: + # Linha 0 da cena = topo: invertemos o Y para o 0x0 ficar embaixo + return QPointF(x * PITCH, (self.total_rows - 1 - y) * PITCH) - def get_router(self, x: int, y: int) -> RouterWidget | None: - """Retorna o RouterWidget na posição global (x, y), se existir.""" + def _build_matrix(self) -> None: + for y in range(self.total_rows): + for x in range(self.total_cols): + router = RouterItem(x, y, self.total_cols, self.total_rows, self.mpsoc_config) + router.setPos(self._body_origin(x, y)) + router._peripheral_added = self._add_peripheral + self._scene.addItem(router) + self.routers[(x, y)] = router + self.routers_by_address[router.router_address] = router + self._add_local_arrows(router) + + for (x, y), router in self.routers.items(): + for side in ("E", "N"): + neighbor = self.get_router(x + 1, y) if side == "E" else self.get_router(x, y + 1) + if neighbor is not None: + self._add_link(router, side, neighbor) + for side, at_border in (("W", x == 0), ("E", x == self.total_cols - 1), + ("S", y == 0), ("N", y == self.total_rows - 1)): + if at_border: + self._add_link(router, side, None) + + if self.cluster_x < self.total_cols or self.cluster_y < self.total_rows: + self._add_cluster_borders() + + self._update_scene_rect() + + def _edge_point(self, router: RouterItem, side: str, position: float, distance: float) -> QPointF: + """Ponto a `distance` para fora do lado `side`, a `position` ao longo dele.""" + origin = router.pos() + dx, dy = _OUTWARD[side] + if side in ("E", "W"): + edge_x = origin.x() + (BODY if side == "E" else 0) + return QPointF(edge_x + dx * distance, origin.y() + position) + edge_y = origin.y() + (BODY if side == "S" else 0) + return QPointF(origin.x() + position, edge_y + dy * distance) + + def _add_link(self, router: RouterItem, side: str, neighbor: RouterItem | None) -> None: + """ + Setas do lado `side` do roteador, uma por sentido e por canal. Com + `neighbor` None é uma borda da malha: as setas ligam o roteador ao + periférico e ficam ocultas até existir um (ou um pacote passar). + """ + near, far = ARROW_MARGIN, GAP - ARROW_MARGIN + # Desloca o par perpendicularmente: a saída fica "à esquerda" de quem sai + out_shift = -ARROW_OFFSET if side in ("E", "S") else ARROW_OFFSET + other = neighbor.label if neighbor is not None else f"{_SIDE_NAMES[side]} peripheral" + + for channel, position in self._channels.items(): + channel_name = "HIGH" if channel == HIGH else "LOW" + out_pos, in_pos = position + out_shift, position - out_shift + + outgoing = ArrowItem(self._edge_point(router, side, out_pos, near), + self._edge_point(router, side, out_pos, far), + optional=neighbor is None, + tooltip=f"{router.label} → {other} ({channel_name})") + incoming = ArrowItem(self._edge_point(router, side, in_pos, far), + self._edge_point(router, side, in_pos, near), + optional=neighbor is None, + tooltip=f"{other} → {router.label} ({channel_name})") + self._scene.addItem(outgoing) + self._scene.addItem(incoming) + + in_arrow, out_arrow = _SIDE_ARROWS[(side, channel)] + router.arrows[out_arrow] = outgoing + router.arrows[in_arrow] = incoming + if neighbor is not None: + neighbor_in, neighbor_out = _SIDE_ARROWS[(_OPPOSITE[side], channel)] + neighbor.arrows[neighbor_in] = outgoing + neighbor.arrows[neighbor_out] = incoming + + def _add_local_arrows(self, router: RouterItem) -> None: + """Setas diagonais da porta local, no canto superior esquerdo do corpo.""" + corner = router.pos() + shift = QPointF(5, -5) # perpendicular à diagonal + near, far = QPointF(-ARROW_MARGIN, -ARROW_MARGIN), QPointF(-GAP * 0.6, -GAP * 0.6) + + local_in = ArrowItem(corner + far + shift, corner + near + shift, + tooltip=f"PE → router {router.label} (local port)") + local_out = ArrowItem(corner + near - shift, corner + far - shift, + tooltip=f"Router {router.label} → PE (local port)") + self._scene.addItem(local_in) + self._scene.addItem(local_out) + router.arrows[MPSoCConfig.LOCAL_IN] = local_in + router.arrows[MPSoCConfig.LOCAL_OUT] = local_out + + def _add_peripheral(self, router: RouterItem, side: str, name: str) -> None: + key = (router.pos_x, router.pos_y, side) + old = self.peripheral_items.pop(key, None) + if old is not None: + self._scene.removeItem(old) + + size = PERIPHERAL_NS if side in ("N", "S") else PERIPHERAL_EW + center = self._edge_point(router, side, BODY / 2, GAP + (size.height() if side in ("N", "S") + else size.width()) / 2) + rect = QRectF(center.x() - size.width() / 2, center.y() - size.height() / 2, + size.width(), size.height()) + item = PeripheralItem(rect, name, router.label, side) + self._scene.addItem(item) + self.peripheral_items[key] = item + + for channel in self._channels: + for arrow in _SIDE_ARROWS[(side, channel)]: + router.arrows[arrow].set_optional(False) + + self._update_scene_rect() + + def _add_cluster_borders(self) -> None: + half = GAP / 2 + for cy in range(math.ceil(self.total_rows / self.cluster_y)): + for cx in range(math.ceil(self.total_cols / self.cluster_x)): + x0, y0 = cx * self.cluster_x, cy * self.cluster_y + x1 = min(x0 + self.cluster_x, self.total_cols) - 1 + y1 = min(y0 + self.cluster_y, self.total_rows) - 1 + top_left = self._body_origin(x0, y1) + bottom_right = self._body_origin(x1, y0) + QPointF(BODY, BODY) + rect = QRectF(top_left, bottom_right).adjusted(-half + 2, -half + 2, half - 2, half - 2) + + item = QGraphicsRectItem(rect) + item.setZValue(0) + item.setBrush(Qt.BrushStyle.NoBrush) + self._scene.addItem(item) + self._cluster_items.append(item) + self._apply_cluster_pen() + + def _apply_cluster_pen(self) -> None: + pen = QPen(QColor(self._scene.colors.cluster_border), 1.5, Qt.PenStyle.DashLine) + for item in self._cluster_items: + item.setPen(pen) + + def _update_scene_rect(self) -> None: + # Todas as setas de borda contam (mesmo ocultas), para a cena não mudar + # de tamanho quando um pacote de periférico aparece + rect = QRectF() + for item in self._scene.items(): + rect = rect.united(item.sceneBoundingRect()) + self._scene.setSceneRect(rect.adjusted(-16, -16, 16, 16)) + + # ---------- API usada pela janela principal e pela simulação ---------- + def get_router(self, x: int, y: int) -> RouterItem | None: + """Retorna o roteador na posição global (x, y), se existir.""" return self.routers.get((x, y)) - def apply_theme(self, dark: bool) -> None: - """Aplica as cores do tema claro ou escuro na grade.""" - self.setStyleSheet(_matrix_stylesheet(dark)) - - def get_router_by_address(self, router_address: int) -> RouterWidget | None: - """Retorna o RouterWidget pelo endereço hamiltoniano, se existir.""" + def get_router_by_address(self, router_address: int) -> RouterItem | None: + """Retorna o roteador pelo endereço hamiltoniano, se existir.""" return self.routers_by_address.get(router_address) + def set_peripherals(self, peripherals) -> None: + """Periféricos (testcase_files.Peripheral) nas bordas da malha.""" + for peripheral in peripherals: + router = self.get_router(peripheral.x, peripheral.y) + if router is not None: + router.set_peripheral(peripheral.side, peripheral.name) + def reset_routers(self) -> None: """Apaga as setas pintadas de todos os roteadores.""" for router in self.routers.values(): if router.need_reset: - router.reset_arrows() \ No newline at end of file + router.reset_arrows() + + def apply_theme(self, dark: bool) -> None: + """Aplica as cores do tema claro ou escuro na malha.""" + self._scene.colors = theme.matrix_colors(dark) + self.setBackgroundBrush(QBrush(QColor(self._scene.colors.canvas))) + self._apply_cluster_pen() + self._scene.update() + + def ensure_router_visible(self, router: RouterItem) -> None: + self.ensureVisible(router.sceneBoundingRect(), 20, 20) + + def to_image(self, scale: float = 2.0) -> QImage: + """A malha inteira (independente do zoom e da rolagem) como imagem.""" + source = self._scene.sceneRect() + image = QImage(max(1, round(source.width() * scale)), max(1, round(source.height() * scale)), + QImage.Format.Format_ARGB32) + image.fill(QColor(self._scene.colors.canvas)) + painter = QPainter(image) + painter.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.TextAntialiasing) + self._scene.render(painter, QRectF(image.rect()), source) + painter.end() + return image + + # ---------- zoom ---------- + @property + def zoom(self) -> float: + return self._zoom + + def set_zoom(self, zoom: float, user: bool = True) -> None: + zoom = max(ZOOM_MIN, min(ZOOM_MAX, zoom)) + if user: + self._auto_fit = False + if math.isclose(zoom, self._zoom): + return + self.scale(zoom / self._zoom, zoom / self._zoom) + self._zoom = zoom + self._controls.set_zoom(zoom) + self.zoom_changed.emit(zoom) + + def zoom_in(self) -> None: + self.set_zoom(self._zoom * ZOOM_STEP) + + def zoom_out(self) -> None: + self.set_zoom(self._zoom / ZOOM_STEP) + + def reset_zoom(self) -> None: + self.set_zoom(1.0) + + def fit_to_view(self, user: bool = True) -> None: + """Ajusta o zoom para a malha inteira caber na vista.""" + rect = self._scene.sceneRect() + viewport = self.viewport().size() + if rect.isEmpty() or viewport.isEmpty(): + return + zoom = min(viewport.width() / rect.width(), viewport.height() / rect.height()) + anchor = self.transformationAnchor() + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + self.set_zoom(zoom, user) + self.setTransformationAnchor(anchor) + self.centerOn(rect.center()) + if user: + # "Fit" volta a acompanhar o tamanho da janela + self._auto_fit = True + + def _auto_zoom(self) -> None: + """Zoom inicial: 100%, ou menos se a malha não couber na janela.""" + if not self._auto_fit: + return + rect = self._scene.sceneRect() + viewport = self.viewport().size() + if rect.isEmpty() or viewport.isEmpty(): + return + fit = min(viewport.width() / rect.width(), viewport.height() / rect.height()) + if fit < 1: + self.fit_to_view(user=False) + else: + self.set_zoom(1.0, user=False) + self.centerOn(rect.center()) + + def wheelEvent(self, event): + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + steps = event.angleDelta().y() / 120 + if steps: + self.set_zoom(self._zoom * ZOOM_STEP ** steps) + event.accept() + return + super().wheelEvent(event) + + def viewportEvent(self, event): + if event.type() == QEvent.Type.Gesture: + pinch = event.gesture(Qt.GestureType.PinchGesture) + if pinch is not None: + self.set_zoom(self._zoom * pinch.scaleFactor()) + event.accept() + return True + elif event.type() == QEvent.Type.NativeGesture and \ + event.gestureType() == Qt.NativeGestureType.ZoomNativeGesture: + self.set_zoom(self._zoom * (1 + event.value())) + event.accept() + return True + return super().viewportEvent(event) + + def mouseDoubleClickEvent(self, event): + if self.itemAt(event.position().toPoint()) is None: + self.fit_to_view() + event.accept() + return + super().mouseDoubleClickEvent(event) + + def resizeEvent(self, event): + super().resizeEvent(event) + margin = 8 + viewport = self.viewport().geometry() + self._controls.adjustSize() + self._controls.move(viewport.right() - self._controls.width() - margin, + viewport.bottom() - self._controls.height() - margin) + self._controls.raise_() + # Adia para depois do layout terminar (o viewport já com o tamanho final) + QTimer.singleShot(0, self, self._auto_zoom) diff --git a/simulation.py b/simulation.py new file mode 100644 index 0000000..0426772 --- /dev/null +++ b/simulation.py @@ -0,0 +1,355 @@ +""" +Controle da simulação dos roteadores (play / próximo pacote / stop / voltar no tempo). + +Refatoração da parte de simulação do MainFrame.java e do CheckpointController.java +do GraphicalDebugger original. A thread com sleep() do Java foi trocada por um +QTimer, que roda no laço de eventos do Qt e pode atualizar a interface com segurança. +""" + +from PySide6.QtCore import QObject, QTimer, Signal + +from util.MPSoCConfig import MPSoCConfig + +# Velocidade (slider 0-100) acima da qual os roteadores não são redesenhados, +# para avançar o mais rápido possível +MAX_REPAINT_SPEED = 98 + +# Pacotes processados por passo do timer quando está sem redesenhar +FAST_BATCH_SIZE = 200 + +# No "voltar no tempo", só os últimos pacotes são desenhados +GO_REPAINT_LAST_PACKETS = 50 + +# Acompanhando um traffic_router.txt que ainda está sendo gravado: intervalo +# entre as tentativas de ler novas linhas quando o arquivo chega ao fim +FOLLOW_POLL_MS = 250 + +# Lado da malha de onde vem o pacote que entra por cada porta +_PORT_SIDES = { + MPSoCConfig.NORTH0: "N", MPSoCConfig.NORTH1: "N", MPSoCConfig.SOUTH0: "S", MPSoCConfig.SOUTH1: "S", + MPSoCConfig.EAST0: "E", MPSoCConfig.EAST1: "E", MPSoCConfig.WEST0: "W", MPSoCConfig.WEST1: "W", +} + + +class CheckpointController: + """ + Divide a simulação em janelas de tempo. Ao fim de cada janela a taxa de uso + dos enlaces é recalculada (checkpoint_reached). + """ + + def __init__(self, clock_period_ns, checkpoint_reached, window_size_ms=0.5): + self.clock_period_ns = clock_period_ns + self.checkpoint_reached = checkpoint_reached + self.window_size_ms = window_size_ms + self.reset() + + def reset(self): + self.window_init_ms = 0.0 + self.current_time_ms = 0.0 + + def set_time(self, time_in_ticks): + self.current_time_ms = ticks_to_ms(time_in_ticks, self.clock_period_ns) + + if self.current_time_ms - self.window_init_ms > self.window_size_ms: + self.window_init_ms = self.current_time_ms + self.checkpoint_reached() + + def get_window_size_in_ns(self): + return self.window_size_ms * 1_000_000 + + +def ticks_to_ms(ticks, clock_period_ns): + return ticks * clock_period_ns / 1_000_000 + + +# Porta de entrada -> (seta no roteador atual, seta no vizinho de onde o pacote veio) +_INPUT_ARROWS = { + MPSoCConfig.EAST0: (MPSoCConfig.EAST_IN_HIGH, MPSoCConfig.WEST_OUT_HIGH), + MPSoCConfig.EAST1: (MPSoCConfig.EAST_IN_LOW, MPSoCConfig.WEST_OUT_LOW), + MPSoCConfig.NORTH0: (MPSoCConfig.NORTH_IN_HIGH, MPSoCConfig.SOUTH_OUT_HIGH), + MPSoCConfig.NORTH1: (MPSoCConfig.NORTH_IN_LOW, MPSoCConfig.SOUTH_OUT_LOW), + MPSoCConfig.WEST0: (MPSoCConfig.WEST_IN_HIGH, MPSoCConfig.EAST_OUT_HIGH), + MPSoCConfig.WEST1: (MPSoCConfig.WEST_IN_LOW, MPSoCConfig.EAST_OUT_LOW), + MPSoCConfig.SOUTH0: (MPSoCConfig.SOUTH_IN_HIGH, MPSoCConfig.NORTH_OUT_HIGH), + MPSoCConfig.SOUTH1: (MPSoCConfig.SOUTH_IN_LOW, MPSoCConfig.NORTH_OUT_LOW), +} + + +class SimulationController(QObject): + """Reproduz os pacotes de traffic_router.txt sobre a matriz de roteadores.""" + + # Tempo (em ticks) do último pacote processado + time_changed = Signal(int) + # Último pacote processado (PacketInformation) + packet_changed = Signal(object) + # Play/stop mudou (True = rodando) + running_changed = Signal(bool) + # Fim do traffic_router.txt + finished = Signal() + # Pacote com serviço que não existe no services.cfg + unknown_service = Signal(object) + # Modo "follow": fim do arquivo alcançado, esperando novas linhas (True) ou voltaram a chegar (False) + waiting_for_data = Signal(bool) + + def __init__(self, mpsoc_config, mpsoc_information, router_matrix, parent=None): + super().__init__(parent) + self.mpsoc_config = mpsoc_config + self.mpsoc_information = mpsoc_information + self.router_matrix = router_matrix + + # Função (packet -> bool) usada pelo filtro de serviços/PEs; None = todos + self.packet_filter = None + self.speed = MAX_REPAINT_SPEED + self.running = False + + # Roteadores de destino dos pacotes que saíram de um PE e ainda não + # chegaram; enquanto houver algum, as setas pintadas não são apagadas + self.unfinished_packets = [] + + # "Print Router Total Link Usage": roteador (-1 = desligado) e o que imprimir + self.link_usage_router = -1 + self.print_link_usage = True + + self.checkpoint = CheckpointController(mpsoc_config.clock_period_in_ns, self.checkpoint_reached) + + self.timer = QTimer(self) + self.timer.timeout.connect(self._play_step) + + # Acompanhar um traffic_router.txt que ainda está sendo gravado + self.follow = False + self.waiting = False + + # ========================================== + # CONTROLES + # ========================================== + def set_speed(self, speed): + """Velocidade de 0 (parado) a 100 (máxima, sem redesenhar as setas).""" + self.speed = speed + if self.running: + self.timer.setInterval(self._interval()) + + def _interval(self): + if self.waiting: + return FOLLOW_POLL_MS + return max(0, 400 - self.speed * 4) + + def set_follow(self, follow): + """Em vez de parar no fim do arquivo, espera o simulador gravar mais pacotes.""" + self.follow = follow + self.mpsoc_information.read_traffic.follow = follow + if not follow: + self._set_waiting(False) + + def _set_waiting(self, waiting): + if waiting == self.waiting: + return + self.waiting = waiting + if self.running: + self.timer.setInterval(self._interval()) + self.waiting_for_data.emit(waiting) + + def play(self): + if self.running: + return + self.running = True + self.timer.start(self._interval()) + self.running_changed.emit(True) + + def stop(self): + if not self.running: + return + self.running = False + self.timer.stop() + self.running_changed.emit(False) + + def step(self): + """Processa um único pacote, sempre redesenhando.""" + return self.next_packet(repaint=True) + + def _play_step(self): + if self.speed == 0: + return + + if self.speed > MAX_REPAINT_SPEED: + # Modo rápido: vários pacotes por passo e sem desenhar as setas + self.unfinished_packets.clear() + for _ in range(FAST_BATCH_SIZE): + if self.next_packet(repaint=False) == -1 or not self.running: + return + else: + self.next_packet(repaint=True) + + def go_to_time(self, desired_time): + """ + Volta (ou avança) a simulação até o tempo informado, em ticks. + Só é possível ir até o último pacote já lido. Gera ValueError se o + tempo for inválido. + """ + packet_counter = self.mpsoc_information.get_packet_counter_by_time(desired_time) + if packet_counter == -1: + raise ValueError(f"Invalid time: {desired_time}") + + self.stop() + self.mpsoc_information.initialize_pe_information() + self.checkpoint.reset() + self.reset_graphical_path() + + time = 0 + while packet_counter > 0: + time = self.next_packet(packet_counter <= GO_REPAINT_LAST_PACKETS, desired_time) + if time == -1 or time > desired_time: + break + packet_counter -= 1 + + self.time_changed.emit(max(time, 0)) + + def reset_graphical_path(self): + self.unfinished_packets.clear() + self.router_matrix.reset_routers() + + # ========================================== + # PROCESSAMENTO DOS PACOTES + # ========================================== + def next_packet(self, repaint=True, limit_time=0): + """Processa o próximo pacote. Retorna o tempo dele ou -1 no fim do arquivo.""" + packet = self.mpsoc_information.get_next_packet(self.packet_filter, limit_time) + + if packet is None: + if self.follow: + # O simulador ainda pode gravar mais pacotes: continua rodando + self._set_waiting(True) + return -1 + self.router_matrix.reset_routers() + self.stop() + self.finished.emit() + return -1 + self._set_waiting(False) + + if packet.service not in self.mpsoc_config.services_hash: + self.stop() + self.unknown_service.emit(packet) + + self.checkpoint.set_time(packet.time) + + if repaint: + self.repaint_router(packet) + + self.packet_changed.emit(packet) + self.time_changed.emit(packet.time) + return packet.time + + def repaint_router(self, packet): + """Pinta o caminho do pacote: seta de entrada no roteador e de saída no vizinho.""" + if packet.input_port in (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1): + # Pacote novo saindo do PE: apaga o caminho anterior se nenhum + # outro pacote estiver em trânsito + if not self.unfinished_packets: + self.router_matrix.reset_routers() + + # Destinos fora da malha (ex.: periféricos) nunca "chegam", então não são aguardados + if 0 <= packet.target_router < self.mpsoc_config.get_pe_number(): + self.unfinished_packets.append(packet.target_router) + + self.paint_hop(packet.router_address, packet.input_port, packet.target_router) + + # Pacote chegou ao destino + if packet.router_address == packet.target_router and packet.router_address in self.unfinished_packets: + self.unfinished_packets.remove(packet.router_address) + + def paint_hop(self, address, port, target): + """Seta de entrada no roteador (e de saída no vizinho de onde o pacote veio).""" + router = self.router_matrix.get_router_by_address(address) + if router is None: + return + + if port in _INPUT_ARROWS: + local_arrow, neighbor_arrow = _INPUT_ARROWS[port] + neighbors = self.mpsoc_information.neighbors + + if port in (MPSoCConfig.NORTH0, MPSoCConfig.NORTH1): + neighbor_address = neighbors.get_vizinho_cima(address) + elif port in (MPSoCConfig.SOUTH0, MPSoCConfig.SOUTH1): + neighbor_address = neighbors.get_vizinho_baixo(address) + elif port in (MPSoCConfig.EAST0, MPSoCConfig.EAST1): + neighbor_address = neighbors.get_vizinho_direita(address) + else: + neighbor_address = neighbors.get_vizinho_esquerda(address) + + neighbor = self.router_matrix.get_router_by_address(neighbor_address) + if neighbor is not None: + neighbor.paint_arrow(neighbor_arrow) + elif neighbor_address == -1 and port in _PORT_SIDES: + # Veio de fora da malha: marca o periférico, se o YAML não o declarou + side = _PORT_SIDES[port] + if side not in router.peripherals: + router.set_peripheral(side, "Peripheral") + + elif port in (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1): + local_arrow = MPSoCConfig.LOCAL_IN + else: + return + + router.paint_arrow(local_arrow) + if address == target: + router.paint_arrow(MPSoCConfig.LOCAL_OUT) + + def show_path(self, hops, target, highlight=None): + """ + Mostra na malha o caminho de um pacote já analisado: `hops` são + (roteador, porta de entrada) na ordem; `highlight` recebe a moldura. + Para a simulação para o desenho não ser apagado pelo próximo pacote. + """ + self.stop() + self.reset_graphical_path() + for address, port in hops: + self.paint_hop(address, port, target) + if highlight is not None: + router = self.router_matrix.get_router_by_address(highlight) + if router is not None: + router.set_highlight(True) + + # ========================================== + # TAXA DE USO DOS ENLACES + # ========================================== + def checkpoint_reached(self): + """Atualiza o percentual de uso dos enlaces de todos os roteadores.""" + max_throughput_per_window = self.checkpoint.get_window_size_in_ns() / self.mpsoc_config.clock_period_in_ns + + for address in range(self.mpsoc_config.get_pe_number()): + router_info = self.mpsoc_information.get_router_information(address) + self.update_router_throughput(address, router_info, max_throughput_per_window) + + def update_router_throughput(self, address, router_info, max_throughput): + router = self.router_matrix.get_router_by_address(address) + if router is None or router_info is None: + return + + neighbors = self.mpsoc_information.neighbors + percent = 100.0 / max_throughput + + # Só as portas que possuem vizinho, mais as portas locais + ports = [] + if neighbors.get_x_coordinate(address) != 0: + ports += [MPSoCConfig.WEST0, MPSoCConfig.WEST1] + if neighbors.get_x_coordinate(address) != self.mpsoc_config.mpsoc_x - 1: + ports += [MPSoCConfig.EAST0, MPSoCConfig.EAST1] + if neighbors.get_y_coordinate(address) != 0: + ports += [MPSoCConfig.SOUTH0, MPSoCConfig.SOUTH1] + if neighbors.get_y_coordinate(address) != self.mpsoc_config.mpsoc_y - 1: + ports += [MPSoCConfig.NORTH0, MPSoCConfig.NORTH1] + ports += [MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1] + + total_link_usage = 0.0 + for port in ports: + usage = router_info.get_port_bandwidth_throughput_in_cycles(port) * percent + router.update_throughput(port, usage) + if port not in (MPSoCConfig.LOCAL0, MPSoCConfig.LOCAL1): + total_link_usage += usage + + if address == self.link_usage_router: + # Mesma saída do Java: um valor por checkpoint, com vírgula decimal + value = total_link_usage if self.print_link_usage else self.checkpoint.current_time_ms + print(f"{value:.6g}".replace(".", ","), flush=True) + + router_info.reset_bandwidth_throughput() + router_info.reset_throughput() diff --git a/slave_matrix.py b/slave_matrix.py index be63f6e..2680159 100644 --- a/slave_matrix.py +++ b/slave_matrix.py @@ -18,17 +18,16 @@ """ from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget # Cores fixas, independentes do tema (claro/escuro) do MainWindow: sem # isso, o toggle Dark/Light recoloriria essas células via # QApplication.setPalette(). +# O fundo (cor do mapa de calor) é pintado em paintEvent, pois muda a +# cada atualização da simulação. _SLAVE_STYLESHEET = """ -QWidget#slaveWidget { - background-color: #0000cc; - border: 1px solid #ffffff; -} QLabel { background-color: transparent; color: #ffffff; @@ -56,8 +55,8 @@ def __init__(self, pos_x: int, pos_y: int, parent=None): # Isola a célula do tema global do app (ver comentário de # _SLAVE_STYLESHEET acima). - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setStyleSheet(_SLAVE_STYLESHEET) + self._background = QColor("#0000cc") # Cresce para preencher toda a área do scrollArea, como no print # (diferente do RouterWidget, que tem tamanho fixo 170x170). @@ -89,6 +88,26 @@ def __init__(self, pos_x: int, pos_y: int, parent=None): self.setLayout(layout) + def _set_background(self, color: QColor) -> None: + if color != self._background: + self._background = QColor(color) + self.update() + + def paintEvent(self, event): + # Fundo com a cor do mapa de calor e borda preta de 1 px + painter = QPainter(self) + painter.fillRect(self.rect(), self._background) + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawRect(self.rect().adjusted(0, 0, -1, -1)) + painter.end() + + def set_values(self, title: str, value: str, unit: str, background: QColor) -> None: + """Atualiza a célula com os dados do mapa de calor do Communication Overview.""" + self.title_label.setText(title) + self.percent_label.setText(value) + self.flits_label.setText(unit) + self._set_background(background) + def set_traffic(self, percent: float, flits: int | None = None) -> None: """ Atualiza o percentual mostrado (e, opcionalmente, a contagem de diff --git a/statistics_windows.py b/statistics_windows.py new file mode 100644 index 0000000..278dab9 --- /dev/null +++ b/statistics_windows.py @@ -0,0 +1,828 @@ +""" +Janelas "Traffic Statistics" e "Application Timeline" (menu Tools). + +Não existem no GraphicalDebugger em Java: implementam métricas usadas no +artigo do Memphis (Ruaro et al., DAES 2019) — vazão em Mbps, taxa de +injeção e latência (Seção 6.2/6.3) e a admissão de aplicações (Seção 3.4). +A lógica está em analysis.py. +""" + +import os + +from PySide6.QtCore import QAbstractTableModel, QModelIndex, QRect, QSortFilterProxyModel, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPen +from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QScrollArea, QSpinBox, QSplitter, QTableView, QTableWidget, QTableWidgetItem, + QTabWidget, QToolTip, QVBoxLayout, QWidget) + +import analysis +import export +import path_view +from overview_windows import _LiveOverview, _router_label +from util.MPSoCConfig import MPSoCConfig + + +def _ticks_to_us(mpsoc_config, ticks): + return ticks * mpsoc_config.clock_period_in_ns / 1000.0 + + +class _NumericItem(QTableWidgetItem): + """Célula que ordena pelo valor numérico (UserRole), não pelo texto.""" + + def __init__(self, text, value): + super().__init__(text) + self.setData(Qt.ItemDataRole.UserRole, value) + self.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + def __lt__(self, other): + return self.data(Qt.ItemDataRole.UserRole) < other.data(Qt.ItemDataRole.UserRole) + + +def _row_id(table, row): + item = table.item(row, 0) + return None if item is None else item.data(ROW_ID_ROLE) + + +def _sortable_table(headers): + table = QTableWidget(0, len(headers)) + table.setHorizontalHeaderLabels(headers) + table.verticalHeader().setVisible(False) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + table.horizontalHeader().setStretchLastSection(True) + # O QTableWidget começa com o indicador em ordem decrescente + table.horizontalHeader().setSortIndicator(0, Qt.SortOrder.AscendingOrder) + return table + + +# Guarda, na primeira coluna, o índice da linha na lista original (a ordenação muda as linhas) +ROW_ID_ROLE = Qt.ItemDataRole.UserRole + 1 + + +def _fill_table(table, rows, row_ids=None): + """ + rows: listas de (texto, valor numérico ou None). row_ids (padrão: o + índice da linha em `rows`) fica no ROW_ID_ROLE da primeira coluna. + """ + table.setSortingEnabled(False) + table.setRowCount(len(rows)) + for row, cells in enumerate(rows): + for column, (text, value) in enumerate(cells): + item = QTableWidgetItem(text) if value is None else _NumericItem(text, value) + if column == 0: + item.setData(ROW_ID_ROLE, row if row_ids is None else row_ids[row]) + table.setItem(row, column, item) + # Reordena pela coluna escolhida pelo usuário + table.setSortingEnabled(True) + + +# ========================================== +# TRAFFIC STATISTICS +# ========================================== +class _MessageModel(QAbstractTableModel): + HEADERS = ("Injected (tick)", "Source", "Target", "Service", "Flits", "Hops", "Latency (cycles)", "Application") + + def __init__(self, mpsoc_config): + super().__init__() + self.mpsoc_config = mpsoc_config + self.messages = [] + + def set_messages(self, messages): + self.beginResetModel() + self.messages = messages + self.endResetModel() + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.messages) + + def columnCount(self, parent=QModelIndex()): + return len(self.HEADERS) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self.HEADERS[section] + return None + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + message = self.messages[index.row()] + column = index.column() + config = self.mpsoc_config + + if role == Qt.ItemDataRole.UserRole: # valor usado para ordenar + return (message.injected, message.source, message.target, message.service, message.flits, + message.hops, message.latency, min(message.apps, default=-1))[column] + if role == Qt.ItemDataRole.TextAlignmentRole and column in (0, 4, 5, 6): + return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + if role != Qt.ItemDataRole.DisplayRole: + return None + + if column == 0: + return str(message.injected) + if column == 1: + label = _router_label(config, message.source) + return f"Periph. @ {label}" if message.from_peripheral else label + if column == 2: + return _router_label(config, message.target) + if column == 3: + return config.get_string_service_name(message.service) + if column == 4: + return str(message.flits) + if column == 5: + return str(message.hops) if message.delivered >= 0 else "-" + if column == 6: + return str(message.latency) if message.delivered >= 0 else "not delivered" + return ", ".join(analysis.app_label(config, app) for app in sorted(message.apps)) or "kernel" + + +class _MessageFilter(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.app = None + self.setSortRole(Qt.ItemDataRole.UserRole) + + def set_app(self, app): + if hasattr(self, "beginFilterChange"): # Qt 6.10+ + self.beginFilterChange() + self.app = app + self.endFilterChange() + else: + self.app = app + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + message = self.sourceModel().messages[row] + if self.app is not None and self.app not in message.apps: + return False + return True + + +class TrafficStatisticsWindow(_LiveOverview): + """ + Vazão por enlace (Mbps e % da capacidade), métricas por aplicação, a + latência de cada mensagem, a validação do roteamento XY e os pacotes + parados (possível deadlock), até o tempo atual da simulação. + """ + + # Sem avançar por este número de ciclos, o pacote é considerado parado + DEFAULT_BLOCKED_THRESHOLD = 10000 + + def __init__(self, mpsoc_config, mpsoc_information, simulation=None): + self.tracker = analysis.TrafficTracker(mpsoc_config) + super().__init__(mpsoc_config, mpsoc_information, simulation) + self.setWindowTitle("Traffic Statistics") + self.resize(820, 560) + + self.summary = QLabel() + self.summary.setWordWrap(True) + self.summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + self.links_table = _sortable_table(["Router", "Input port", "Volume (flits)", + "Throughput (Mbps)", "Link usage (%)"]) + self.links_table.setToolTip("Average since the beginning of the simulation, per router input port.\n" + "Follows the Service/PE/Application filter.") + + self.apps_table = _sortable_table(["Application", "Messages", "Flits", "Throughput (Mbps)", + "Injection rate (%)", "Avg latency", "Median latency", + "Max latency", "Avg hops"]) + self.apps_table.setToolTip("Throughput = flits of the delivered messages of the application over the\n" + "interval between its first injection and its last delivery.\n" + "Injection rate = throughput / link capacity. Latencies in clock cycles.") + + self.message_model = _MessageModel(mpsoc_config) + self.message_filter = _MessageFilter() + self.message_filter.setSourceModel(self.message_model) + self.messages_view = QTableView() + self.messages_view.setModel(self.message_filter) + self.messages_view.setSortingEnabled(True) + self.messages_view.sortByColumn(0, Qt.SortOrder.AscendingOrder) + self.messages_view.verticalHeader().setVisible(False) + self.messages_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.messages_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.messages_view.horizontalHeader().setStretchLastSection(True) + self.messages_view.doubleClicked.connect(self._show_message_path) + self.messages_view.setToolTip("Double click a packet to show its path on the main window mesh") + self.message_app_combo = QComboBox() + self.message_app_combo.currentIndexChanged.connect( + lambda _index: self.message_filter.set_app(self.message_app_combo.currentData())) + messages_tab = QWidget() + messages_layout = QVBoxLayout(messages_tab) + app_row = QHBoxLayout() + app_row.addWidget(QLabel("Application:")) + app_row.addWidget(self.message_app_combo, 1) + messages_layout.addLayout(app_row) + messages_layout.addWidget(self.messages_view) + latency_note = QLabel("Latency = from the header entering the NoC (local port or peripheral) until the " + "last flit leaves the target router (network only, without the kernel time). " + "traffic_router.txt has no packet ID, so each packet is followed hop by hop by " + "service, size, neighbor router and time.") + latency_note.setWordWrap(True) + messages_layout.addWidget(latency_note) + + routing_tab = self._build_routing_tab() + blocked_tab = self._build_blocked_tab() + + self.tabs = QTabWidget() + self.tabs.addTab(self.links_table, "Links") + self.tabs.addTab(self.apps_table, "Applications") + self.tabs.addTab(messages_tab, "Messages") + self.tabs.addTab(routing_tab, "Routing") + self.tabs.addTab(blocked_tab, "Blocked") + self.tabs.currentChanged.connect(lambda _index: self.refresh()) + + # Tabela exportada por aba (mesma ordem das abas) + self.tab_tables = [(self.links_table, "links"), (self.apps_table, "applications"), + (self.messages_view, "messages"), (self.violations_table, "routing_violations"), + (self.blocked_table, "blocked_packets")] + for table, name in self.tab_tables: + export.install_export_actions(table, name, csv_view=table) + + csv_button = QPushButton("Export CSV…") + csv_button.setToolTip("Save the table of the current tab (as filtered and sorted)") + csv_button.clicked.connect(self._export_current_table) + png_button = QPushButton("Save PNG…") + png_button.clicked.connect(lambda: export.export_png(self, self, "traffic_statistics")) + buttons = QHBoxLayout() + buttons.addStretch() + buttons.addWidget(csv_button) + buttons.addWidget(png_button) + + layout = QVBoxLayout(self) + layout.addWidget(self.summary) + layout.addWidget(self.tabs) + layout.addLayout(buttons) + + self._fill_app_combo() + self.refresh() + + def _build_routing_tab(self): + tab = QWidget() + self.routing_summary = QLabel() + self.routing_summary.setWordWrap(True) + self.violations_table = _sortable_table(["Time (tick)", "Router", "Input port", "Target", "Service", + "Moving", "Tasks"]) + self.violations_table.setToolTip("Hop records whose input port is not compatible with XY routing\n" + "(X first, then Y) towards the target in the header.\n" + "Double click to show the packet path on the main window mesh.") + self.violations_table.cellDoubleClicked.connect(self._show_violation_path) + self._violation_rows = [] + layout = QVBoxLayout(tab) + layout.addWidget(self.routing_summary) + layout.addWidget(self.violations_table) + return tab + + def _build_blocked_tab(self): + tab = QWidget() + self.blocked_threshold = QSpinBox() + self.blocked_threshold.setRange(1, 1_000_000_000) + self.blocked_threshold.setSingleStep(1000) + self.blocked_threshold.setValue(self.DEFAULT_BLOCKED_THRESHOLD) + self.blocked_threshold.setSuffix(" cycles") + self.blocked_threshold.valueChanged.connect(lambda _value: self.refresh()) + threshold_row = QHBoxLayout() + threshold_row.addWidget(QLabel("Packets in flight without progress for at least")) + threshold_row.addWidget(self.blocked_threshold) + threshold_row.addStretch() + + self.deadlock_label = QLabel() + self.deadlock_label.setWordWrap(True) + self.blocked_table = _sortable_table(["Injected (tick)", "Last seen (tick)", "Stopped for (cycles)", + "Source", "Target", "Service", "Last router", "Hops seen", + "Next XY hop", "Status"]) + self.blocked_table.setToolTip("Double click to show where the packet stopped on the main window mesh") + self.blocked_table.cellDoubleClicked.connect(self._show_blocked_path) + self._blocked_rows = [] + note = QLabel("A packet with no record after its injection may also be a gap in traffic_router.txt. " + "A circular wait (each packet waiting for the link held by the next one) indicates a " + "deadlock, which XY routing should never produce.") + note.setWordWrap(True) + layout = QVBoxLayout(tab) + layout.addLayout(threshold_row) + layout.addWidget(self.deadlock_label) + layout.addWidget(self.blocked_table) + layout.addWidget(note) + return tab + + def _show_message_path(self, index): + message = self.message_model.messages[self.message_filter.mapToSource(index).row()] + service = self.mpsoc_config.get_string_service_name(message.service) + path_view.show_message(message, description=f"Path of {service} injected at {message.injected}") + + def _show_violation_path(self, row, _column): + index = _row_id(self.violations_table, row) + if index is None or index >= len(self._violation_rows): + return + violation = self._violation_rows[index] + description = f"XY violation at {_router_label(self.mpsoc_config, violation.router)}, tick {violation.time}" + if violation.message is not None: + path_view.show_message(violation.message, highlight=violation.router, description=description) + else: + path_view.show_record(violation.router, violation.port, violation.target, description) + + def _show_blocked_path(self, row, _column): + index = _row_id(self.blocked_table, row) + if index is not None and index < len(self._blocked_rows): + message = self._blocked_rows[index] + path_view.show_message(message, description=f"Packet stopped at " + f"{_router_label(self.mpsoc_config, message.last_router)}") + + def _export_current_table(self): + table, name = self.tab_tables[self.tabs.currentIndex()] + export.export_csv(self, table, name) + + def _fill_app_combo(self): + self.message_app_combo.blockSignals(True) + self.message_app_combo.clear() + self.message_app_combo.addItem("All", None) + for app in sorted(analysis.known_app_ids(self.mpsoc_config)): + self.message_app_combo.addItem(analysis.app_label(self.mpsoc_config, app), app) + self.message_app_combo.blockSignals(False) + self.message_filter.set_app(None) + + def rebuild(self): + self.tracker = analysis.TrafficTracker(self.mpsoc_config) + self.message_model.mpsoc_config = self.mpsoc_config + self._fill_app_combo() + self.refresh() + + # ------------------------------------------ + def _update_tracker(self): + """Analisa os pacotes já processados pela simulação (recomeça ao voltar no tempo).""" + reader = self.mpsoc_information.read_traffic + consumed = reader.packet_read_control + if consumed < self.tracker.consumed: + self.tracker.reset() + for packet in reader.all_packets[self.tracker.consumed:consumed]: + self.tracker.consume(packet) + return reader.all_packets[consumed - 1].time if consumed else 0 + + def refresh(self): + self._dirty = False + if not hasattr(self, "tabs"): + return # chamado pela base antes da interface existir + + config = self.mpsoc_config + now = self._update_tracker() + capacity = analysis.link_capacity_mbps(config) + delivered = self.tracker.delivered() + + frequency = 1000.0 / config.clock_period_in_ns if config.clock_period_in_ns else 0 + self.summary.setText( + f"Simulated time: {now} ticks ({_ticks_to_us(config, now) / 1000:.5f} ms)   " + f"Link capacity: {capacity:.0f} Mbps ({config.flit_size}-bit flits @ {frequency:.0f} MHz)
" + f"Messages: {len(self.tracker.messages)} injected, {len(delivered)} delivered, " + f"{self.tracker.pending_count()} in flight, {self.tracker.lost} without recorded delivery") + + current = self.tabs.currentIndex() + if current == 0: + self._update_links(now, capacity) + elif current == 1: + self._update_apps(delivered, capacity) + elif current == 2: + self.message_model.set_messages(list(self.tracker.messages)) + elif current == 3: + self._update_routing() + else: + self._update_blocked(now) + + def _update_routing(self): + config = self.mpsoc_config + tracker = self.tracker + non_xy, anomalies = analysis.routing_summary(tracker) + checked = sum(tracker.hop_records.values()) + excluded = sum(total for _count, total in non_xy.values()) + + if anomalies: + status = (f"⚠ {len(anomalies)} hop record(s) outside XY routing " + f"in {checked - excluded} checked records.") + else: + status = f"✓ No XY routing violation in {checked - excluded} checked hop records." + if non_xy: + services = ", ".join(f"{config.get_string_service_name(service)} ({count}/{total})" + for service, (count, total) in sorted(non_xy.items())) + status += (f"
Not checked — services that do not follow XY at all, most likely broadcast " + f"(e.g. the Memphis-V management network on the LOW channel): {services}.") + self.routing_summary.setText(status) + + rows = [] + self._violation_rows = anomalies + for violation in anomalies: + # Lado de onde o pacote veio -> sentido em que ele andava + side = analysis._PORT_SIDE.get(violation.port, (0, 0)) + direction = {(-1, 0): "east", (1, 0): "west", (0, -1): "north", (0, 1): "south"}.get(side, "-") + tasks = f"{violation.task_source} → {violation.task_target}" if violation.task_source >= 0 else "-" + rows.append([(str(violation.time), violation.time), + (_router_label(config, violation.router), None), + (MPSoCConfig.get_port_string(violation.port), None), + (_router_label(config, violation.target), None), + (config.get_string_service_name(violation.service), None), + (direction, None), (tasks, None)]) + _fill_table(self.violations_table, rows) + + def _update_blocked(self, now): + config = self.mpsoc_config + blocked = analysis.blocked_messages(self.tracker, now, self.blocked_threshold.value()) + cycles = analysis.find_wait_cycles(config, blocked) + in_cycle = {id(message) for cycle in cycles for message in cycle} + + if cycles: + descriptions = [" → ".join(f"{_router_label(config, m.last_router)} " + f"({config.get_string_service_name(m.service)})" for m in cycle) + for cycle in cycles] + self.deadlock_label.setText("⚠ Circular wait found (possible deadlock):" + "
" + "
".join(descriptions)) + elif blocked: + self.deadlock_label.setText(f"{len(blocked)} packet(s) stopped. " + "No circular wait among them.") + else: + self.deadlock_label.setText("✓ No packet stopped for this long.") + + rows = [] + self._blocked_rows = blocked + for message in blocked: + following = analysis.next_hop(config, message.last_router, message.target) + expected = analysis.hops(config, message.source, message.target) + if id(message) in in_cycle: + status = "in circular wait" + elif message.hops_seen == 0: + status = "no record after injection" + else: + status = "stopped mid-path" + stopped = now - message.last_time + rows.append([(str(message.injected), message.injected), + (str(message.last_time), message.last_time), + (str(stopped), stopped), + (_router_label(config, message.source), None), + (_router_label(config, message.target), None), + (config.get_string_service_name(message.service), None), + (_router_label(config, message.last_router), None), + (f"{message.hops_seen}/{expected}", message.hops_seen), + (_router_label(config, following) if following is not None else "local", None), + (status, None)]) + _fill_table(self.blocked_table, rows) + + def _update_links(self, now, capacity): + config = self.mpsoc_config + rows = [] + for address in range(config.get_pe_number()): + router = self.mpsoc_information.get_router_information(address) + for port in range(MPSoCConfig.NPORT): + flits = router.get_port_total_volume_in_flits(port) + if not flits: + continue + throughput = analysis.mbps(config, flits, now) + usage = throughput * 100.0 / capacity if capacity else 0.0 + rows.append([(_router_label(config, address), None), + (MPSoCConfig.get_port_string(port), None), + (str(flits), flits), + (f"{throughput:.2f}", throughput), + (f"{usage:.3f}", usage)]) + _fill_table(self.links_table, rows) + + def _update_apps(self, delivered, capacity): + config = self.mpsoc_config + rows = [] + for app, stats in sorted(analysis.traffic_by_app(delivered).items()): + throughput = analysis.mbps(config, stats.flits, stats.active_cycles) + rate = throughput * 100.0 / capacity if capacity else 0.0 + rows.append([(analysis.app_label(config, app), None), + (str(stats.messages), stats.messages), + (str(stats.flits), stats.flits), + (f"{throughput:.2f}", throughput), + (f"{rate:.3f}", rate), + (f"{stats.average_latency:.1f}", stats.average_latency), + (f"{stats.median_latency:g}", stats.median_latency), + (str(stats.latency_max), stats.latency_max), + (f"{stats.average_hops:.2f}", stats.average_hops)]) + _fill_table(self.apps_table, rows) + + +# ========================================== +# APPLICATION TIMELINE +# ========================================== +_PHASES = ( + # (nome, cor, início, fim) + ("Mapping", QColor(230, 159, 0), lambda t: t.received, lambda t: t.mapped), + ("Task allocation / release", QColor(170, 90, 200), lambda t: t.mapped if t.mapped >= 0 else t.received, + lambda t: t.start), + ("Execution", QColor(0, 114, 178), lambda t: t.start, lambda t: t.end), +) +_ALLOCATION_COLOR = QColor(0, 158, 115) +_TERMINATION_COLOR = QColor(213, 94, 0) + + +class TimelineChart(QWidget): + """Uma linha por aplicação com as fases da admissão e da execução.""" + + ROW_HEIGHT = 34 + TOP = 34 + LEFT = 170 + RIGHT = 20 + + def __init__(self, mpsoc_config, parent=None): + super().__init__(parent) + self.mpsoc_config = mpsoc_config + self.timelines = [] + self.range = (0, 1) + self.current_time = -1 + self.setMouseTracking(True) + + def set_timelines(self, timelines, time_range): + self.timelines = timelines + self.set_range(*time_range) + self.setMinimumHeight(self.TOP + len(timelines) * self.ROW_HEIGHT + 60) + + def set_range(self, start, end): + self.range = (start, max(end, start + 1)) + self.update() + + def set_current_time(self, ticks): + self.current_time = ticks + self.update() + + def _x(self, ticks): + start, end = self.range + width = max(1, self.width() - self.LEFT - self.RIGHT) + return self.LEFT + (ticks - start) * width / (end - start) + + def _row_rect(self, row): + return QRect(self.LEFT, self.TOP + row * self.ROW_HEIGHT + 8, self.width() - self.LEFT - self.RIGHT, + self.ROW_HEIGHT - 16) + + def _phase_rects(self, row, timeline): + rect = self._row_rect(row) + for name, color, begin, end in _PHASES: + start, finish = begin(timeline), end(timeline) + if start < 0 or finish < 0 or finish < start: + continue + x1, x2 = self._x(start), self._x(finish) + yield name, color, start, finish, QRect(int(x1), rect.top(), max(2, int(x2 - x1)), rect.height()) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + palette = self.palette() + painter.fillRect(self.rect(), palette.base().color()) + text = palette.text().color() + grid = QColor(text) + grid.setAlpha(60) + + if not self.timelines: + painter.setPen(text) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No application found in the trace") + return + + bottom = self.TOP + len(self.timelines) * self.ROW_HEIGHT + + # Régua de tempo + start, end = self.range + painter.setPen(text) + step = self._ruler_step(end - start) + tick = (start // step + 1) * step + while tick < end: + x = int(self._x(tick)) + painter.setPen(grid) + painter.drawLine(x, self.TOP, x, bottom) + painter.setPen(text) + painter.drawText(QRect(x - 60, bottom + 4, 120, 16), Qt.AlignmentFlag.AlignHCenter, + self._format_ticks(tick)) + tick += step + painter.drawText(QRect(self.LEFT, bottom + 22, self.width() - self.LEFT - self.RIGHT, 16), + Qt.AlignmentFlag.AlignHCenter, "time") + + # Legenda + x = self.LEFT + for name, color, _begin, _end in _PHASES: + painter.fillRect(QRect(x, 10, 12, 12), color) + painter.drawText(x + 16, 21, name) + x += 26 + painter.fontMetrics().horizontalAdvance(name) + painter.setPen(QPen(_ALLOCATION_COLOR, 2)) + painter.drawLine(x, 10, x, 22) + painter.setPen(text) + painter.drawText(x + 6, 21, "task code arrived") + x += 20 + painter.fontMetrics().horizontalAdvance("task code arrived") + painter.setPen(QPen(_TERMINATION_COLOR, 2)) + painter.drawLine(x, 10, x, 22) + painter.setPen(text) + painter.drawText(x + 6, 21, "task terminated") + + for row, timeline in enumerate(self.timelines): + rect = self._row_rect(row) + painter.setPen(text) + label = analysis.app_label(self.mpsoc_config, timeline.app) + painter.drawText(QRect(6, rect.top(), self.LEFT - 12, rect.height()), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, label) + painter.setPen(grid) + painter.drawLine(self.LEFT, rect.bottom() + 8, self.width() - self.RIGHT, rect.bottom() + 8) + + painter.setClipRect(QRect(self.LEFT, 0, self.width() - self.LEFT - self.RIGHT, self.height())) + for _name, color, _start, _finish, phase_rect in self._phase_rects(row, timeline): + painter.fillRect(phase_rect, color) + for time, _pe in timeline.task_allocated.values(): + x = int(self._x(time)) + painter.setPen(QPen(_ALLOCATION_COLOR, 2)) + painter.drawLine(x, rect.top() - 5, x, rect.top() + 4) + for time in timeline.task_terminated.values(): + x = int(self._x(time)) + painter.setPen(QPen(_TERMINATION_COLOR, 2)) + painter.drawLine(x, rect.bottom() - 4, x, rect.bottom() + 5) + painter.setClipping(False) + + if self.current_time >= start and self.current_time <= end: + x = int(self._x(self.current_time)) + painter.setPen(QPen(QColor(Qt.GlobalColor.red), 1, Qt.PenStyle.DashLine)) + painter.drawLine(x, self.TOP, x, bottom) + + def _format_ticks(self, ticks): + us = _ticks_to_us(self.mpsoc_config, ticks) + return f"{us / 1000:g} ms" if us >= 1000 else f"{us:g} µs" + + def _ruler_step(self, span): + """Passo 1-2-5 em ticks que dá ~8 marcas na régua.""" + target = max(1, span / 8) + power = 1 + while power * 10 <= target: + power *= 10 + for factor in (1, 2, 5, 10): + if power * factor >= target: + return power * factor + return power * 10 + + def mouseMoveEvent(self, event): + point = event.position().toPoint() + for row, timeline in enumerate(self.timelines): + for name, _color, start, finish, rect in self._phase_rects(row, timeline): + if rect.adjusted(-2, 0, 2, 0).contains(point): + cycles = finish - start + QToolTip.showText(event.globalPosition().toPoint(), + f"{analysis.app_label(self.mpsoc_config, timeline.app)}\n{name}\n" + f"{start} → {finish} ticks\n{cycles} cycles " + f"({_ticks_to_us(self.mpsoc_config, cycles):.2f} µs)", self) + return + QToolTip.hideText() + + def wheelEvent(self, event): + """Ctrl + roda: zoom em torno do mouse.""" + if not event.modifiers() & Qt.KeyboardModifier.ControlModifier: + event.ignore() + return + start, end = self.range + width = max(1, self.width() - self.LEFT - self.RIGHT) + anchor = start + (event.position().x() - self.LEFT) * (end - start) / width + factor = 0.8 if event.angleDelta().y() > 0 else 1.25 + new_start = anchor - (anchor - start) * factor + new_end = anchor + (end - anchor) * factor + self.set_range(max(0, int(new_start)), int(new_end)) + event.accept() + + +class ApplicationTimelineWindow(QWidget): + """ + Linha do tempo da admissão de cada aplicação (protocolo da Seção 3.4 do + artigo): pedido recebido pelo mapper, mapeamento, chegada do código de + cada tarefa, liberação (TASK_RELEASE) e execução até o término. + + Analisa o traffic_router.txt inteiro (não só até o tempo atual) e o log + do mapper_task (log/logx.txt do cenário). + """ + + def __init__(self, mpsoc_config, simulation=None, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.mpsoc_config = mpsoc_config + self.simulation = None + self.timelines = [] + self.setWindowTitle("Application Timeline") + self.resize(1000, 620) + + self.chart = TimelineChart(mpsoc_config) + self.zoom_combo = QComboBox() + self.zoom_combo.addItems(["Whole run", "Admission only"]) + self.zoom_combo.currentIndexChanged.connect(self._apply_zoom) + reload_button = QPushButton("Reload") + reload_button.clicked.connect(self.load) + csv_button = QPushButton("Export CSV…") + csv_button.clicked.connect(lambda: export.export_csv(self, self.table, "application_timeline")) + png_button = QPushButton("Save PNG…") + png_button.setToolTip("Save the chart as it is shown (with the current zoom)") + png_button.clicked.connect(lambda: export.export_png(self, self.chart, "application_timeline")) + self.source_label = QLabel() + self.source_label.setWordWrap(True) + + controls = QHBoxLayout() + controls.addWidget(QLabel("Zoom:")) + controls.addWidget(self.zoom_combo) + controls.addWidget(QLabel("(Ctrl + mouse wheel zooms around the cursor)")) + controls.addStretch() + controls.addWidget(csv_button) + controls.addWidget(png_button) + controls.addWidget(reload_button) + + chart_scroll = QScrollArea() + chart_scroll.setWidgetResizable(True) + chart_scroll.setWidget(self.chart) + + self.table = _sortable_table(["Application", "Tasks", "Request (tick)", "Mapped (tick)", + "Released (tick)", "Terminated (tick)", "Mapping (cycles)", + "Admission (cycles)", "Admission (µs)", "Execution (ms)"]) + self.table.setToolTip("Admission = from the request received by the mapper until TASK_RELEASE.\n" + "Double click a row to zoom the chart on that application's admission.") + self.table.cellDoubleClicked.connect(self._zoom_on_row) + export.install_export_actions(self.table, "application_timeline", csv_view=self.table) + export.install_export_actions(self.chart, "application_timeline", png_widget=self.chart) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(chart_scroll) + splitter.addWidget(self.table) + splitter.setSizes([360, 220]) + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.source_label) + layout.addWidget(splitter) + + self.set_simulation(simulation) + self.load() + + def set_simulation(self, simulation): + if self.simulation is not None: + try: + self.simulation.time_changed.disconnect(self.chart.set_current_time) + except (RuntimeError, TypeError): + pass + self.simulation = simulation + if simulation is not None: + simulation.time_changed.connect(self.chart.set_current_time) + + def set_information(self, mpsoc_config, simulation=None): + self.mpsoc_config = mpsoc_config + self.chart.mpsoc_config = mpsoc_config + self.set_simulation(simulation) + self.load() + + def closeEvent(self, event): + self.set_simulation(None) + super().closeEvent(event) + + def load(self): + """Relê o traffic_router.txt inteiro com um leitor próprio (não mexe na simulação).""" + config = self.mpsoc_config + packets = analysis.read_all_packets(config) + + self.timelines, log_path = analysis.build_timelines(config, packets) + self.trace_end = max((packet.time + packet.bandwidth_cycles for packet in packets), default=1) + if log_path: + self.source_label.setText(f"Request, mapping, release and termination from the mapper log: " + f"{os.path.relpath(log_path, os.path.dirname(config.debug_file_path))}. " + f"Task code arrival and task termination from traffic_router.txt.") + else: + self.source_label.setText("Mapper log not found (log/ folder of the scenario): showing only task " + "code arrival and termination from traffic_router.txt.") + + self.chart.set_timelines(self.timelines, (0, self.trace_end)) + self._apply_zoom() + self._fill_table() + + def _apply_zoom(self, *_): + if self.zoom_combo.currentIndex() == 0 or not self.timelines: + self.chart.set_range(0, self.trace_end) + return + firsts = [t.first_event for t in self.timelines if t.first_event >= 0] + starts = [t.start for t in self.timelines if t.start >= 0] + if not firsts or not starts: + self.chart.set_range(0, self.trace_end) + return + start, end = min(firsts), max(starts) + margin = max(1, (end - start) // 20) + self.chart.set_range(max(0, start - margin), end + margin) + + def _zoom_on_row(self, row, _column): + item = self.table.item(row, 0) + if item is None: + return + timeline = next((t for t in self.timelines if t.app == item.data(ROW_ID_ROLE)), None) + if timeline is None or timeline.first_event < 0 or timeline.start < 0: + return + start, end = timeline.first_event, timeline.start + margin = max(1, (end - start) // 10) + self.chart.set_range(max(0, start - margin), end + margin) + + def _fill_table(self): + config = self.mpsoc_config + + def tick(value): + return (str(value), value) if value >= 0 else ("-", -1) + + rows = [] + for timeline in self.timelines: + admission = timeline.admission_cycles + execution = timeline.execution_cycles + label = analysis.app_label(config, timeline.app) + rows.append([(label, None), + (str(timeline.task_count), timeline.task_count), + tick(timeline.received), tick(timeline.mapped), tick(timeline.start), + tick(timeline.end), tick(timeline.mapping_cycles), tick(admission), + (f"{_ticks_to_us(config, admission):.2f}", admission) if admission >= 0 else ("-", -1), + (f"{_ticks_to_us(config, execution) / 1000:.3f}", execution) + if execution >= 0 else ("-", -1)]) + # O ID da aplicação fica na primeira coluna, para o duplo clique + _fill_table(self.table, rows, [timeline.app for timeline in self.timelines]) diff --git a/testcase_files.py b/testcase_files.py new file mode 100644 index 0000000..f23c7fb --- /dev/null +++ b/testcase_files.py @@ -0,0 +1,100 @@ +""" +Leitura do YAML do testcase do Memphis-V (/.yaml). + +Só a seção de periféricos é usada: + + hw: + Peripherals: + - name: APP_INJ + pe: 2,2 + port: N + +O arquivo é simples o bastante para ser lido linha a linha, sem depender do +PyYAML. +""" + +import os +from dataclasses import dataclass + +SIDES = {"N": "north", "S": "south", "E": "east", "W": "west"} + + +@dataclass(frozen=True) +class Peripheral: + name: str + x: int + y: int + side: str # "N", "S", "E" ou "W" + + +def find_testcase_yaml(mpsoc_config): + """ + /.yaml. O diretório de debug fica em + //debug, então o testcase é dois níveis acima. + """ + scenario = mpsoc_config.get_testcase_path() + if scenario is None: + return None + testcase = os.path.dirname(os.path.normpath(scenario)) + path = os.path.join(testcase, os.path.basename(testcase) + ".yaml") + return path if os.path.isfile(path) else None + + +def _value(line): + return line.split(":", 1)[1].strip().strip("\"'") + + +def parse_peripherals(text): + """Periféricos declarados em hw → Peripherals (linhas inválidas são ignoradas).""" + peripherals = [] + in_section = False + section_indent = 0 + current = {} + + def flush(): + try: + x, y = (int(value) for value in current["pe"].replace("[", "").replace("]", "").split(",")) + side = current["port"].upper()[:1] + if side in SIDES: + peripherals.append(Peripheral(current.get("name", "Peripheral"), x, y, side)) + except (KeyError, ValueError): + pass + + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + indent = len(line) - len(line.lstrip()) + stripped = line.strip() + + if stripped.lower().startswith("peripherals:"): + in_section, section_indent, current = True, indent, {} + continue + if not in_section: + continue + if indent <= section_indent: # fim da seção + break + + if stripped.startswith("- "): + if current: + flush() + current = {} + stripped = stripped[2:].strip() + if ":" in stripped: + current[stripped.split(":", 1)[0].strip().lower()] = _value(stripped) + + if in_section and current: + flush() + return peripherals + + +def read_peripherals(mpsoc_config): + """(periféricos, caminho do YAML) — lista vazia e None se o YAML não existir.""" + path = find_testcase_yaml(mpsoc_config) + if path is None: + return [], None + try: + with open(path, "r", encoding="utf-8") as yaml_file: + return parse_peripherals(yaml_file.read()), path + except OSError: + return [], None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..46626ab --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +""" +Configuração comum dos testes. + +- Qt roda sem janela (QT_QPA_PLATFORM=offscreen). +- QSettings e a pasta de projetos vão para um diretório temporário, para + não mexer no tema, nos projetos e nas pastas salvos pelo usuário. +- O cenário de exemplo é extraído do "DEBUG EXAMPLE.zip" versionado no repositório. +""" + +import os +import tempfile +import zipfile +from pathlib import Path + +_SANDBOX = tempfile.mkdtemp(prefix="memphis-tests-") +os.environ["QT_QPA_PLATFORM"] = "offscreen" +os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config") +os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data") + +import pytest # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLE_ZIP = ROOT / "DEBUG EXAMPLE.zip" +SCENARIO_IN_ZIP = "DEBUG EXAMPLE/LOGS/example_testcase/example_scenario/" +TESTCASE_YAML_IN_ZIP = "DEBUG EXAMPLE/LOGS/example_testcase/example_testcase.yaml" + + +@pytest.fixture(scope="session") +def scenario_dir(tmp_path_factory): + """Pasta do cenário de exemplo (contém debug/ e log/).""" + if not EXAMPLE_ZIP.exists(): + pytest.skip("DEBUG EXAMPLE.zip not found") + target = tmp_path_factory.mktemp("example") + with zipfile.ZipFile(EXAMPLE_ZIP) as archive: + members = [name for name in archive.namelist() + if name.startswith(SCENARIO_IN_ZIP) or name == TESTCASE_YAML_IN_ZIP] + archive.extractall(target, members) + return target / SCENARIO_IN_ZIP + + +@pytest.fixture(scope="session") +def debug_dir(scenario_dir): + return str(scenario_dir / "debug") + + +@pytest.fixture(scope="session") +def config(debug_dir): + from util.MPSoCConfig import MPSoCConfig + return MPSoCConfig(debug_dir) + + +@pytest.fixture(scope="session") +def packets(config): + """Todos os pacotes do traffic_router.txt do exemplo.""" + from information.read_traffic_data import ReadTrafficData + from information.router_neighbors import RouterNeighbors + reader = ReadTrafficData(config, RouterNeighbors.from_config(config)) + try: + result = [] + while (packet := reader.get_next_packet()) is not None: + result.append(packet) + return result + finally: + reader.close() + + +@pytest.fixture(scope="session") +def qapp(): + from PySide6.QtWidgets import QApplication + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def dialogs(monkeypatch): + """ + Substitui as caixas de diálogo modais (que travariam o teste) e registra + as mensagens mostradas. `dialogs.answers` / `dialogs.texts` são as + respostas das próximas perguntas / entradas de texto. + """ + from PySide6.QtWidgets import QInputDialog, QMessageBox + + class Recorder: + def __init__(self): + self.shown = [] + self.answers = [] + self.texts = [] + self.items = [] + + recorder = Recorder() + + def show(kind): + return staticmethod(lambda *args, **kwargs: recorder.shown.append((kind, args[1], args[2]))) + + for kind in ("information", "warning", "critical"): + monkeypatch.setattr(QMessageBox, kind, show(kind)) + monkeypatch.setattr(QMessageBox, "question", staticmethod( + lambda *args, **kwargs: recorder.answers.pop(0) if recorder.answers else QMessageBox.StandardButton.Yes)) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *args, **kwargs: recorder.texts.pop(0))) + monkeypatch.setattr(QInputDialog, "getItem", staticmethod(lambda *args, **kwargs: recorder.items.pop(0))) + return recorder diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..89d94be --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,287 @@ +"""analysis.py: aplicações, roteamento XY, latência, pacotes parados, deadlock e linha do tempo.""" + +import pytest + +import analysis +from information.packet_information import PacketInformation +from util.MPSoCConfig import MPSoCConfig + +REQUEST = 41 +BROADCAST = 0 + + +@pytest.fixture +def mesh(tmp_path): + """Plataforma sintética 3x3 XY, flits de 32 bits a 100 MHz.""" + (tmp_path / "platform.cfg").write_text( + "router_addressing XY\nchannel_number 2\nmpsoc_x 3\nmpsoc_y 3\nflit_size 32\nclock_period_ns 10\n" + "BEGIN_task_name_relation\ntaskA 256\ntaskB 257\nEND_task_name_relation\n" + "BEGIN_app_name_relation\nmanagement\t0\nsynthetic\t1\nEND_app_name_relation\n") + (tmp_path / "services.cfg").write_text("NEW_APP 0\nMESSAGE_REQUEST 41\nTASK_ALLOCATION 42\n" + "$TASK_ALLOCATION_SERVICE 42\n") + return MPSoCConfig(str(tmp_path)) + + +def at(config, x, y): + return config.xy_to_ham_addr((x << 8) | y) + + +def packet(config, time, router, port, target, service=REQUEST, size=2, bandwidth=6, source=256, target_task=257): + return PacketInformation(at(config, *router), time, service, size, bandwidth, port, + at(config, *target), source, target_task) + + +def path_packets(config, start=100): + """Pacote de 0x0 para 2x1 pelo XY: leste, leste, norte.""" + return [packet(config, start, (0, 0), MPSoCConfig.LOCAL0, (2, 1)), + packet(config, start + 5, (1, 0), MPSoCConfig.WEST0, (2, 1)), + packet(config, start + 10, (2, 0), MPSoCConfig.WEST0, (2, 1)), + packet(config, start + 15, (2, 1), MPSoCConfig.SOUTH0, (2, 1))] + + +# ------------------------------------------------------------------ geometria +def test_next_hop_goes_x_first(mesh): + assert analysis.next_hop(mesh, at(mesh, 0, 0), at(mesh, 2, 1)) == at(mesh, 1, 0) + assert analysis.next_hop(mesh, at(mesh, 2, 0), at(mesh, 2, 1)) == at(mesh, 2, 1) + assert analysis.next_hop(mesh, at(mesh, 2, 1), at(mesh, 2, 1)) is None + + +def test_neighbor_and_border(mesh): + assert analysis.neighbor(mesh, at(mesh, 1, 0), MPSoCConfig.WEST0) == at(mesh, 0, 0) + assert analysis.neighbor(mesh, at(mesh, 0, 0), MPSoCConfig.SOUTH0) is None + assert analysis.is_peripheral_input(mesh, at(mesh, 0, 0), MPSoCConfig.SOUTH0) + assert not analysis.is_peripheral_input(mesh, at(mesh, 1, 1), MPSoCConfig.SOUTH0) + + +@pytest.mark.parametrize("router, port, target, expected", [ + ((1, 0), MPSoCConfig.WEST0, (2, 2), True), # andando para leste, destino à frente em X + ((1, 0), MPSoCConfig.WEST0, (0, 2), False), # andando para leste, destino atrás + ((2, 1), MPSoCConfig.SOUTH0, (2, 2), True), # andando para norte na coluna certa + ((1, 1), MPSoCConfig.SOUTH0, (2, 2), False), # andando em Y antes de terminar o X +]) +def test_xy_consistent(mesh, router, port, target, expected): + assert analysis.xy_consistent(mesh, at(mesh, *router), port, at(mesh, *target)) is expected + + +def test_link_capacity_and_mbps(mesh): + assert analysis.link_capacity_mbps(mesh) == 3200.0 + assert analysis.mbps(mesh, 100, 100) == 3200.0 # 1 flit por ciclo = capacidade total + assert analysis.mbps(mesh, 10, 0) == 0.0 + + +# ---------------------------------------------------------------- aplicações +def test_packet_apps_ignore_kernel_and_unknown(mesh): + known = analysis.known_app_ids(mesh) + assert known == {0, 1} + assert analysis.packet_apps(packet(mesh, 0, (0, 0), 8, (1, 0), source=256, target_task=257), known) == {1} + assert analysis.packet_apps(packet(mesh, 0, (0, 0), 8, (1, 0), source=0xFFFF, target_task=0), known) == {0} + assert analysis.packet_apps(packet(mesh, 0, (0, 0), 8, (1, 0), source=-1, target_task=5000), known) == set() + assert analysis.app_label(mesh, 1) == "1 - synthetic" + + +# --------------------------------------------------------- latência e caminho +def test_task_fields_may_change_between_hops(mesh): + tracker = analysis.TrafficTracker(mesh) + records = path_packets(mesh) + records[3].task_source, records[3].task_target = 0xFFFF, 0 # como no trace do Memphis-V + for record in records: + tracker.consume(record) + [message] = tracker.delivered() + assert message.hops_seen == 3 + assert message.apps == {1} # vale a tarefa da injeção + + +def test_ignores_records_outside_the_mesh(mesh): + tracker = analysis.TrafficTracker(mesh) + record = packet(mesh, 10, (1, 1), MPSoCConfig.LOCAL0, (0, 0), size=0, bandwidth=0) + record.target_router = 512 + tracker.consume(record) + assert tracker.messages == [] + + +def test_tracks_packet_hop_by_hop(mesh): + tracker = analysis.TrafficTracker(mesh) + for record in path_packets(mesh): + tracker.consume(record) + + [message] = tracker.delivered() + assert message.latency == 15 + 6 # chegada do header + ciclos até o último flit sair + assert message.hops == 3 + assert message.hops_seen == 3 + assert message.apps == {1} + assert tracker.pending_count() == 0 + assert analysis.routing_summary(tracker) == ({}, []) + + +def test_path_is_recorded(mesh): + tracker = analysis.TrafficTracker(mesh) + for record in path_packets(mesh): + tracker.consume(record) + [message] = tracker.delivered() + assert [(router, port) for router, port, _time in message.path] == [ + (at(mesh, 0, 0), MPSoCConfig.LOCAL0), (at(mesh, 1, 0), MPSoCConfig.WEST0), + (at(mesh, 2, 0), MPSoCConfig.WEST0), (at(mesh, 2, 1), MPSoCConfig.SOUTH0)] + + +def test_violation_points_to_its_packet(mesh): + tracker = analysis.TrafficTracker(mesh) + # Sai de 1x0 para 2x2 mas sobe antes de terminar o X: 1x0 -> 1x1 (errado) -> 2x1 -> 2x2 + records = [packet(mesh, 10, (1, 0), MPSoCConfig.LOCAL0, (2, 2)), + packet(mesh, 12, (1, 1), MPSoCConfig.SOUTH0, (2, 2)), + packet(mesh, 14, (2, 1), MPSoCConfig.WEST0, (2, 2)), + packet(mesh, 16, (2, 2), MPSoCConfig.SOUTH0, (2, 2))] + for record in records: + tracker.consume(record) + _non_xy, [violation] = analysis.routing_summary(tracker) + assert violation.router == at(mesh, 1, 1) + assert violation.message is tracker.messages[0] + assert tracker.messages[0].hops_seen == 3 + + +def test_messages_in_slice(mesh): + tracker = analysis.TrafficTracker(mesh) + for record in path_packets(mesh, start=100): # entregue em 2x1 no tick 121 + tracker.consume(record) + target, source = at(mesh, 2, 1), at(mesh, 0, 0) + [(message, direction)] = analysis.messages_in_slice(tracker.messages, target, 150, 300) + assert direction == "received" # 29 ciclos antes do trecho + assert analysis.messages_in_slice(tracker.messages, target, 300, 400) == [] + assert analysis.messages_in_slice(tracker.messages, source, 90, 110)[0][1] == "sent" + + +def test_same_router_delivery(mesh): + tracker = analysis.TrafficTracker(mesh) + tracker.consume(packet(mesh, 50, (1, 1), MPSoCConfig.LOCAL0, (1, 1), bandwidth=4)) + [message] = tracker.delivered() + assert (message.latency, message.hops) == (4, 0) + + +def test_missing_intermediate_records_still_match(mesh): + tracker = analysis.TrafficTracker(mesh) + records = path_packets(mesh) + for record in (records[0], records[3]): # só injeção e chegada + tracker.consume(record) + [message] = tracker.delivered() + assert message.latency == 21 + assert message.hops_seen == 0 + + +def test_traffic_by_app(mesh): + tracker = analysis.TrafficTracker(mesh) + for start in (100, 1000): + for record in path_packets(mesh, start): + tracker.consume(record) + stats = analysis.traffic_by_app(tracker.delivered())[1] + assert stats.messages == 2 + assert stats.flits == 6 + assert stats.active_cycles == 1021 - 100 + assert stats.median_latency == 21 + + +# ------------------------------------------------------------------ roteamento +def test_detects_xy_violation(mesh): + tracker = analysis.TrafficTracker(mesh) + for record in path_packets(mesh): + tracker.consume(record) + # Andando para o norte em 1x1, mas o destino está em outra coluna + tracker.consume(packet(mesh, 200, (1, 1), MPSoCConfig.SOUTH0, (2, 2))) + non_xy, anomalies = analysis.routing_summary(tracker) + assert non_xy == {} + assert [(violation.time, violation.router) for violation in anomalies] == [(200, at(mesh, 1, 1))] + + +def test_broadcast_service_is_not_reported_as_violation(mesh): + tracker = analysis.TrafficTracker(mesh) + # Inundação a partir de 0x0: aparece em roteadores fora do caminho XY até 0x1 + for time, router, port in ((10, (1, 0), MPSoCConfig.WEST1), (12, (2, 0), MPSoCConfig.WEST1), + (14, (1, 1), MPSoCConfig.SOUTH1), (16, (2, 1), MPSoCConfig.SOUTH1)): + tracker.consume(packet(mesh, time, router, port, (0, 1), service=BROADCAST, source=-1, target_task=-1)) + non_xy, anomalies = analysis.routing_summary(tracker) + assert BROADCAST in non_xy + assert anomalies == [] + + +# -------------------------------------------------------- parados e deadlock +def test_blocked_packets(mesh): + tracker = analysis.TrafficTracker(mesh) + records = path_packets(mesh) + tracker.consume(records[0]) + tracker.consume(records[1]) # parou em 1x0 + [blocked] = analysis.blocked_messages(tracker, now=20_000, threshold=10_000) + assert blocked.last_router == at(mesh, 1, 0) + assert blocked.hops_seen == 1 + assert analysis.blocked_messages(tracker, now=200, threshold=10_000) == [] + + +def test_circular_wait_is_found(mesh): + corners = [(0, 0), (1, 0), (1, 1), (0, 1)] # cada pacote quer o enlace que o seguinte ocupa + messages = [] + for index, corner in enumerate(corners): + previous = corners[index - 1] + following = corners[(index + 1) % 4] + messages.append(analysis.Message(at(mesh, *previous), at(mesh, *following), REQUEST, 3, 0, + last_router=at(mesh, *corner), last_time=0, + prev_router=at(mesh, *previous), hops_seen=1)) + cycles = analysis.find_wait_cycles(mesh, messages) + assert len(cycles) == 1 + assert len(cycles[0]) == 4 + + +def test_no_cycle_for_independent_packets(mesh): + tracker = analysis.TrafficTracker(mesh) + tracker.consume(path_packets(mesh)[0]) + blocked = analysis.blocked_messages(tracker, now=50_000, threshold=1) + assert analysis.find_wait_cycles(mesh, blocked) == [] + + +# ------------------------------------------------------------ cenário exemplo +def test_example_routing_is_xy(config, packets): + tracker = analysis.TrafficTracker(config) + for record in packets: + tracker.consume(record) + non_xy, anomalies = analysis.routing_summary(tracker) + assert anomalies == [] + names = {config.get_string_service_name(service) for service in non_xy} + assert names == {"NEW_APP", "APP_ALLOCATION_REQUEST", "APP_MAPPING_COMPLETE", "ABORT_TASK"} + + +def test_example_latency(config, packets): + tracker = analysis.TrafficTracker(config) + for record in packets: + tracker.consume(record) + delivered = tracker.delivered() + assert len(tracker.messages) == 1388 # 1398 injeções menos 10 registros sem destino na malha + assert len(delivered) == 1388 + assert tracker.lost == 0 + assert analysis.median([message.latency for message in delivered]) < 50 + # O maior é o código da tarefa mais longa (TASK_ALLOCATION de 2907 flits de payload) + assert max(message.latency for message in delivered) == 13483 + # Todos os pacotes com hops foram seguidos roteador a roteador + multi_hop = [message for message in delivered if message.hops > 0] + assert all(message.hops_seen == message.hops for message in multi_hop) + assert analysis.blocked_messages(tracker, packets[-1].time, 10_000) == [] + + +def test_example_timeline(config, packets): + timelines, log_path = analysis.build_timelines(config, packets) + assert log_path.endswith("log0x0.txt") + by_app = {timeline.app: timeline for timeline in timelines} + assert sorted(by_app) == [0, 1, 2, 3] + + synthetic = by_app[1] + assert synthetic.name == "synthetic_default" + assert synthetic.task_count == 4 + assert (synthetic.received, synthetic.mapped, synthetic.started, synthetic.terminated) == \ + (132778, 164297, 240840, 6037445) + assert synthetic.admission_cycles == 240840 - 132778 + assert sorted(synthetic.task_allocated) == [256, 257, 258, 259] + assert sorted(synthetic.task_terminated) == [256, 257, 258, 259] + + +def test_timeline_without_log(mesh): + records = [PacketInformation(at(mesh, 1, 0), 10, 42, 100, 50, MPSoCConfig.SOUTH0, at(mesh, 1, 0), 256, -1)] + timelines, log_path = analysis.build_timelines(mesh, records) + assert log_path is None + [timeline] = timelines + assert timeline.task_allocated == {256: (60, at(mesh, 1, 0))} + assert timeline.admission_cycles == 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..50991f6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,45 @@ +"""Leitura do platform.cfg / services.cfg e conversões de endereço.""" + +import pytest + +from util.MPSoCConfig import MPSoCConfig + + +def test_platform(config): + assert config.router_addressing == MPSoCConfig.XY + assert (config.mpsoc_x, config.mpsoc_y) == (3, 3) + assert config.flit_size == 32 + assert config.clock_period_in_ns == 10 + assert config.get_pe_number() == 9 + + +def test_task_and_app_names(config): + assert config.task_name_hash[0] == "mapper_task" + assert config.task_name_hash[256] == "taskA" + assert dict(config.app_name_hash) == {0: "management", 1: "synthetic_default", + 2: "prod_cons_custom", 3: "prod_cons_custom"} + + +def test_services(config): + assert config.get_service_value("MESSAGE_REQUEST") == 41 + assert config.get_string_service_name(43) == "MESSAGE_DELIVERY" + assert config.get_service_value("DOES_NOT_EXIST") == -1 + assert 42 in config.task_allocation_services + assert 6 in config.task_terminated_services + + +@pytest.mark.parametrize("address", range(9)) +def test_address_round_trip(config, address): + assert config.xy_to_ham_addr(config.ham_to_xy_addr(address)) == address + label = config.ham_address_to_xy_label(address) + assert config.xy_label_to_ham_address(label) == address + + +def test_hamiltonian_snake(config): + # Linhas ímpares são percorridas da direita para a esquerda + assert config.ham_address_to_xy_label(3) == "2x1" + assert config.ham_address_to_xy_label(5) == "0x1" + + +def test_testcase_path(config, scenario_dir): + assert config.get_testcase_path() == str(scenario_dir).rstrip("/") diff --git a/tests/test_deloream.py b/tests/test_deloream.py new file mode 100644 index 0000000..1369393 --- /dev/null +++ b/tests/test_deloream.py @@ -0,0 +1,50 @@ +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest + +from deloream import DeloreamWindow, read_all_messages, read_task_messages + + +def _item(window, text): + return window.tree.findItems(text, Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchRecursive)[0] + + +def _click(window, item): + rect = window.tree.visualItemRect(item) + QTest.mouseClick(window.tree.viewport(), Qt.MouseButton.LeftButton, pos=rect.center()) + + +def test_read_messages(scenario_dir): + messages = read_all_messages(scenario_dir / "log") + assert sum(len(task_messages) for task_messages in messages.values()) == 409 + assert read_task_messages(scenario_dir / "log", 3, 0) == messages[(3, 0)] + + +def test_single_click_opens_tab(qapp, scenario_dir): + window = DeloreamWindow(str(scenario_dir)) + window.show() + assert window.tabs.count() == 0 + + cons = _item(window, "cons[768]") + _click(window, cons) + assert window.tabs.count() == 1 + tab = window.tabs.currentWidget() + assert tab.kind == "task" and tab.item_id == 768 + assert tab.model.rowCount() == len(window.messages[(3, 0)]) > 0 + + # Clicar de novo volta para a mesma aba + _click(window, cons) + assert window.tabs.count() == 1 + + # Aplicação: mensagens de todas as tarefas, com a coluna da tarefa + _click(window, _item(window, "prod_cons_custom[3]")) + assert window.tabs.count() == 2 + app_tab = window.tabs.currentWidget() + assert app_tab.model.rowCount() == len(window.messages[(3, 0)]) + len(window.messages.get((3, 1), [])) + assert not app_tab.table.isColumnHidden(2) + + app_tab.search.setText("Inicio") + assert 0 < app_tab.proxy.rowCount() < app_tab.model.rowCount() + + window.close_all_tabs() + assert window.tabs.count() == 0 + window.close() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..d6b7bda --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,31 @@ +"""Exportação CSV/PNG.""" + +import csv + +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QLabel, QTableWidget, QTableWidgetItem + +import export + + +def test_write_csv(qapp, tmp_path): + table = QTableWidget(2, 2) + table.setHorizontalHeaderLabels(["Router", "Flits"]) + for row, values in enumerate((("0x0", "10"), ("1x0", "20, with comma"))): + for column, value in enumerate(values): + table.setItem(row, column, QTableWidgetItem(value)) + + path = tmp_path / "table.csv" + assert export.write_csv(table.model(), str(path)) == 2 + with open(path, newline="", encoding="utf-8") as csv_file: + assert list(csv.reader(csv_file)) == [["Router", "Flits"], ["0x0", "10"], ["1x0", "20, with comma"]] + + +def test_save_png(qapp, tmp_path): + label = QLabel("Memphis") + label.resize(120, 40) + path = tmp_path / "label.png" + export.save_png(label, str(path)) + image = QImage(str(path)) + assert not image.isNull() + assert image.width() >= 120 diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..36b672e --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,219 @@ +"""Testes rápidos das janelas (sem tela, com os diálogos substituídos).""" + +import os + +import pytest +from PySide6.QtWidgets import QDialog + +import projects + + +@pytest.fixture +def window(qapp, dialogs, debug_dir): + import main + main_window = main.MinhaJanela() + assert main_window.load_debug(debug_dir) + yield main_window + main_window.close_router_info_windows() + main_window.stop_simulation() + main_window.close() + main_window.deleteLater() + + +def run_to_end(window): + while window.simulation.next_packet(repaint=False) != -1: + pass + + +def test_load_and_simulate(window): + assert window.windowTitle().endswith("example_scenario") + window.simulation.step() + assert window.simulation_ticks > 0 + run_to_end(window) + assert window.simulation_ticks == 6251176 + + +def test_invalid_directory(qapp, dialogs, tmp_path): + import main + main_window = main.MinhaJanela() + assert not main_window.load_debug(str(tmp_path)) + assert dialogs.shown and dialogs.shown[0][0] == "critical" + + +def test_router_info_window(window): + run_to_end(window) + window.open_router_info(0) + router_window = window.router_info_windows[0] + router_window.refresh() + assert router_window.traffic_table.item(10, 1).text() == "6864 flits" + router_window.log_tab.update_log() + assert "Initializing PE 0" in router_window.log_tab.text.toPlainText() + router_window._open_task_info(0, 0) + assert router_window.task_windows[0].status_label.text() == "Running" + + +def test_application_filter(window): + import analysis + filter_window = window.filter_window + tab = filter_window.app_tab + tab.combo.setCurrentIndex(tab.combo.findData(2)) + tab.add_selected() + tab._apply() + seen = set() + while (packet := window.mpsoc_information.get_next_packet(window.simulation.packet_filter)) is not None: + seen |= analysis.packet_apps(packet, filter_window.known_apps) + assert seen == {2} + + +def test_traffic_statistics_tabs(window): + run_to_end(window) + window.open_traffic_statistics() + statistics = window.traffic_statistics_window + for index in range(statistics.tabs.count()): + statistics.tabs.setCurrentIndex(index) + statistics.refresh() + assert statistics.links_table.rowCount() > 0 + assert statistics.apps_table.rowCount() == 4 + assert statistics.violations_table.rowCount() == 0 + assert "No XY routing violation" in statistics.routing_summary.text() + assert "No packet stopped" in statistics.deadlock_label.text() + assert statistics.blocked_table.rowCount() == 0 + + +def test_statistics_restart_after_going_back(window): + run_to_end(window) + window.open_traffic_statistics() + statistics = window.traffic_statistics_window + statistics.refresh() + total = statistics.tracker.consumed + window.simulation.go_to_time(300000) + statistics.refresh() + assert 0 < statistics.tracker.consumed < total + + +def test_application_timeline(window): + window.open_application_timeline() + timeline = window.timeline_window + assert timeline.table.rowCount() == 4 + timeline.zoom_combo.setCurrentIndex(1) + start, end = timeline.chart.range + assert end < 1_000_000 + + +def test_exports(window, tmp_path, monkeypatch): + import export + from PySide6.QtWidgets import QFileDialog + run_to_end(window) + window.open_traffic_statistics() + statistics = window.traffic_statistics_window + statistics.tabs.setCurrentIndex(1) + statistics.refresh() + + csv_path = tmp_path / "apps.csv" + monkeypatch.setattr(QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(csv_path), ""))) + statistics._export_current_table() + assert csv_path.read_text().startswith("Application,Messages") + + png_path = tmp_path / "mesh" # extensão é acrescentada + monkeypatch.setattr(QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(png_path), ""))) + window.save_mesh_png() + assert os.path.getsize(str(png_path) + ".png") > 0 + + +def test_platform_setup(window): + from platform_setup import PlatformSetupDialog + dialog = PlatformSetupDialog(window.mpconfig, window.simulation.checkpoint, window) + dialog.clock_edit.setText("20") + dialog.window_width_edit.setText("250") + dialog.accept() + assert dialog.result() == QDialog.DialogCode.Accepted + assert window.mpconfig.clock_period_in_ns == 20 + assert window.simulation.checkpoint.window_size_ms == 0.25 + + +def test_save_and_open_project(window, dialogs, debug_dir): + dialogs.texts += [("has space", True), ("example", True)] + window.save_project() + assert ("critical", "Error", "Name contains space") in dialogs.shown + assert projects.read_project(projects.project_path("example")) == os.path.abspath(debug_dir) + + window._choose_project = lambda *args: projects.project_path("example") + window.open_project() + assert window.windowTitle().endswith(": example") + + window.delete_project() + assert not os.path.exists(projects.project_path("example")) + + +def test_peripherals_on_mesh(window): + matrix = window.router_matrix_widget + assert matrix.get_router(0, 0).peripherals == {"S": "MA_INJ"} + assert matrix.get_router(2, 2).peripherals == {"N": "APP_INJ"} + assert matrix.get_router(1, 1).peripherals == {} + + +def test_show_path_from_statistics(window): + run_to_end(window) + window.open_traffic_statistics() + statistics = window.traffic_statistics_window + statistics.tabs.setCurrentIndex(2) + statistics.refresh() + # Primeira mensagem com mais de um hop + model = statistics.message_model + row = next(row for row, message in enumerate(model.messages) if message.hops >= 2) + message = model.messages[row] + statistics._show_message_path(statistics.message_filter.mapFromSource(model.index(row, 0))) + + matrix = window.router_matrix_widget + painted = [address for address, router in matrix.routers_by_address.items() if router.need_reset] + assert {router for router, _port, _time in message.path} <= set(painted) + assert matrix.get_router_by_address(message.target).highlighted + assert not window.simulation.running + + window.reset_graphical_path() + assert not any(router.need_reset for router in matrix.routers.values()) + + +def test_scheduling_slice_packets(window): + from router_info_window import SchedulingGraphWindow + graph_window = SchedulingGraphWindow(window.mpconfig, 0) # PE 0x0 (mapper) + graph = graph_window.graph + interruption = next(event for event in graph.events if event.name == "Interruption") + graph.selection_changed.emit(interruption) + assert graph_window.slice_table.rowCount() > 0 + assert graph_window.slice_table.item(0, 1).text() in ("received", "sent") + graph_window._show_slice_message(0, 0) + assert any(router.need_reset for router in window.router_matrix_widget.routers.values()) + graph_window.close() + + +def test_follow_live_trace(qapp, dialogs, debug_dir, tmp_path): + import shutil + + import main + debug = tmp_path / "scenario" / "debug" + shutil.copytree(debug_dir, debug) + traffic = debug / "traffic_router.txt" + lines = traffic.read_text().splitlines(keepends=True) + traffic.write_text("".join(lines[:100])) + + main_window = main.MinhaJanela() + assert main_window.load_debug(str(debug)) + main_window.actionFollow_Live_Trace.setChecked(True) + simulation = main_window.simulation + simulation.play() + while simulation.next_packet(repaint=False) != -1: + pass + assert simulation.running and simulation.waiting # fim do arquivo: continua esperando + assert len(main_window.mpsoc_information.read_traffic.all_packets) == 100 + + with open(traffic, "a") as traffic_file: + traffic_file.write("".join(lines[100:150])) + while simulation.next_packet(repaint=False) != -1: + pass + assert len(main_window.mpsoc_information.read_traffic.all_packets) == 150 + + main_window.actionFollow_Live_Trace.setChecked(False) + assert simulation.next_packet(repaint=False) == -1 + assert not simulation.running # sem follow, o fim do arquivo para a simulação + main_window.close() diff --git a/tests/test_information.py b/tests/test_information.py new file mode 100644 index 0000000..aa23008 --- /dev/null +++ b/tests/test_information.py @@ -0,0 +1,90 @@ +"""Leitura do traffic_router.txt e estado dos roteadores.""" + +from information import MPSoCInformation +from util.MPSoCConfig import MPSoCConfig + + +def test_all_packets_read(packets): + assert len(packets) == 3101 + # O arquivo não está estritamente em ordem de tempo: a linha de um pacote + # longo pode ser gravada depois de registros mais novos + out_of_order = sum(a.time > b.time for a, b in zip(packets, packets[1:])) + assert 0 < out_of_order < len(packets) * 0.05 + + +def run_all(config, packet_filter=None): + information = MPSoCInformation(config) + try: + while information.get_next_packet(packet_filter) is not None: + pass + return information + finally: + information.close() + + +def test_router_volumes(config): + information = run_all(config) + assert information.get_router_information(0).get_router_total_volume_in_flits() == 6864 + assert information.get_total_noc_volume() > 0 + + +def test_terminated_tasks_have_real_ids(config): + # No Memphis-V a tarefa terminada vem no último campo (task_source = -1) + information = run_all(config) + terminated = sorted(task.id for address in range(config.get_pe_number()) + for task in information.get_router_information(address).get_tasks_information() + if task.service == "TERMINATED") + assert terminated == [1, 2, 256, 257, 258, 259, 512, 513, 768, 769] + + +def test_packet_filter_skips_packets(config): + information = run_all(config, lambda packet: packet.router_address == 0) + assert information.get_router_information(4).get_router_total_volume_in_flits() == 0 + assert information.get_router_information(0).get_router_total_volume_in_flits() == 6864 + + +def test_go_back_in_time(config): + information = MPSoCInformation(config) + try: + for _ in range(100): + information.get_next_packet() + time = information.read_traffic.all_packets[49].time + counter = information.get_packet_counter_by_time(time) + assert counter >= 50 + assert information.get_packet_counter_by_time(10 ** 12) == -1 + finally: + information.close() + + +def test_port_names(): + assert MPSoCConfig.get_port_string(MPSoCConfig.LOCAL1) == "LOCAL 1" + assert MPSoCConfig.get_port_string(99) is None + + +def test_follow_reads_lines_as_they_are_written(tmp_path, debug_dir): + import shutil + + from information.read_traffic_data import ReadTrafficData + from information.router_neighbors import RouterNeighbors + from util.MPSoCConfig import MPSoCConfig + + debug = tmp_path / "debug" + shutil.copytree(debug_dir, debug) + lines = (debug / "traffic_router.txt").read_text().splitlines(keepends=True) + traffic = debug / "traffic_router.txt" + traffic.write_text("".join(lines[:2]) + lines[2][:5]) # terceira linha pela metade + + config = MPSoCConfig(str(debug)) + reader = ReadTrafficData(config, RouterNeighbors.from_config(config)) + reader.follow = True + try: + assert reader.get_next_packet() is not None + assert reader.get_next_packet() is not None + assert reader.get_next_packet() is None # linha incompleta: espera + with open(traffic, "a") as traffic_file: + traffic_file.write(lines[2][5:] + lines[3]) + assert reader.get_next_packet().time == int(lines[2].split("\t")[0]) + assert reader.get_next_packet().time == int(lines[3].split("\t")[0]) + assert reader.get_next_packet() is None + finally: + reader.close() diff --git a/tests/test_testcase_files.py b/tests/test_testcase_files.py new file mode 100644 index 0000000..3d8a44c --- /dev/null +++ b/tests/test_testcase_files.py @@ -0,0 +1,44 @@ +"""Periféricos do YAML do testcase.""" + +from testcase_files import Peripheral, find_testcase_yaml, parse_peripherals, read_peripherals + +YAML = """ +hw: # Hardware properties + mpsoc_dimension: [3,3] + Peripherals: # Attached peripherals + - name: MA_INJ # comentário + pe: 0,0 + port: S + - name: APP_INJ + pe: [2,2] + port: North + - name: BROKEN # sem porta: ignorado + pe: 1,1 + parameters: + - TRAFFIC_DEBUG: yes +""" + + +def test_parse_peripherals(): + assert parse_peripherals(YAML) == [Peripheral("MA_INJ", 0, 0, "S"), Peripheral("APP_INJ", 2, 2, "N")] + + +def test_no_peripherals_section(): + assert parse_peripherals("hw:\n mpsoc_dimension: [3,3]\n") == [] + + +def test_example_testcase(config): + peripherals, path = read_peripherals(config) + assert path.endswith("example_testcase.yaml") + assert peripherals == [Peripheral("MA_INJ", 0, 0, "S"), Peripheral("APP_INJ", 2, 2, "N")] + + +def test_missing_yaml(tmp_path): + from util.MPSoCConfig import MPSoCConfig + debug = tmp_path / "scenario" / "debug" + debug.mkdir(parents=True) + (debug / "platform.cfg").write_text("mpsoc_x 2\nmpsoc_y 2\n") + (debug / "services.cfg").write_text("") + config = MPSoCConfig(str(debug)) + assert find_testcase_yaml(config) is None + assert read_peripherals(config) == ([], None) diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..5e25da4 --- /dev/null +++ b/theme.py @@ -0,0 +1,99 @@ +""" +Temas claro/escuro da aplicação. + +Concentra as paletas do Qt e as cores da grade de roteadores, para que a +janela principal, as janelas secundárias e a matriz mudem juntas. A escolha +do usuário fica salva com QSettings e é restaurada na próxima execução. +""" + +from dataclasses import dataclass + +from PySide6.QtCore import QSettings +from PySide6.QtGui import QColor, QPalette, Qt +from PySide6.QtWidgets import QApplication + +_SETTINGS_ORG = "GAPH" +_SETTINGS_APP = "MemphisGraphicalDebugger" +_SETTINGS_KEY = "theme/dark_mode" + + +@dataclass(frozen=True) +class MatrixColors: + """Cores da área onde a grade de roteadores é desenhada.""" + + canvas: str # fundo atrás dos roteadores + text: str # textos fora do corpo do roteador (ex.: % da porta local) + cluster_border: str # borda entre clusters + arrow: str # enlace sem pacote passando + arrow_active: str # enlace por onde o pacote atual passou + peripheral_fill: str + peripheral_text: str # também usado na borda da caixa do periférico + + +LIGHT_MATRIX = MatrixColors(canvas="#ffffff", text="#000000", cluster_border="#808080", + arrow="#8c8c8c", arrow_active="#e00000", + peripheral_fill="#fff1e0", peripheral_text="#b34700") +DARK_MATRIX = MatrixColors(canvas="#262626", text="#e6e6e6", cluster_border="#6e6e6e", + arrow="#7a7a7a", arrow_active="#ff4040", + peripheral_fill="#3d2a1a", peripheral_text="#ffb070") + + +def matrix_colors(dark): + return DARK_MATRIX if dark else LIGHT_MATRIX + + +def _build_palette(dark): + palette = QPalette() + + if dark: + window, base, alternate = QColor(53, 53, 53), QColor(35, 35, 35), QColor(45, 45, 45) + text, disabled_text = QColor(Qt.GlobalColor.white), QColor(127, 127, 127) + tooltip_base, highlighted_text = QColor(25, 25, 25), QColor(Qt.GlobalColor.black) + button, placeholder = QColor(53, 53, 53), QColor(150, 150, 150) + else: + window, base, alternate = QColor(240, 240, 240), QColor(Qt.GlobalColor.white), QColor(225, 225, 225) + text, disabled_text = QColor(Qt.GlobalColor.black), QColor(160, 160, 160) + tooltip_base, highlighted_text = QColor(Qt.GlobalColor.white), QColor(Qt.GlobalColor.white) + button, placeholder = QColor(240, 240, 240), QColor(120, 120, 120) + + role = QPalette.ColorRole + palette.setColor(role.Window, window) + palette.setColor(role.WindowText, text) + palette.setColor(role.Base, base) + palette.setColor(role.AlternateBase, alternate) + palette.setColor(role.ToolTipBase, tooltip_base) + palette.setColor(role.ToolTipText, text) + palette.setColor(role.Text, text) + palette.setColor(role.PlaceholderText, placeholder) + palette.setColor(role.Button, button) + palette.setColor(role.ButtonText, text) + palette.setColor(role.BrightText, QColor(Qt.GlobalColor.red)) + palette.setColor(role.Link, QColor(42, 130, 218)) + palette.setColor(role.Highlight, QColor(42, 130, 218)) + palette.setColor(role.HighlightedText, highlighted_text) + + # Sem isso, botões e campos desabilitados ficam com o texto quase + # invisível no modo escuro (o Fusion usa a cor ativa escurecida) + disabled = QPalette.ColorGroup.Disabled + for disabled_role in (role.WindowText, role.Text, role.ButtonText): + palette.setColor(disabled, disabled_role, disabled_text) + palette.setColor(disabled, role.Base, window) + palette.setColor(disabled, role.Button, window) + + return palette + + +def apply_theme(dark, app=None): + """Aplica a paleta clara ou escura na aplicação inteira.""" + app = app or QApplication.instance() + app.setStyle("Fusion") + app.setPalette(_build_palette(dark)) + + +def load_dark_mode(): + """Retorna o último tema escolhido (claro por padrão).""" + return QSettings(_SETTINGS_ORG, _SETTINGS_APP).value(_SETTINGS_KEY, False, type=bool) + + +def save_dark_mode(dark): + QSettings(_SETTINGS_ORG, _SETTINGS_APP).setValue(_SETTINGS_KEY, dark) diff --git a/util/MPSoCConfig.py b/util/MPSoCConfig.py index 406b1b7..7bc3021 100644 --- a/util/MPSoCConfig.py +++ b/util/MPSoCConfig.py @@ -1,3 +1,5 @@ +import os + from sortedcontainers import SortedDict @@ -72,6 +74,7 @@ def __init__(self, debug_file_path): self.services_hash = SortedDict() self.task_name_hash = SortedDict() + self.app_name_hash = SortedDict() self.service_reference = [] self.task_allocation_services = [] self.task_terminated_services = [] @@ -114,6 +117,8 @@ def __init__(self, debug_file_path): case "BEGIN_task_name_relation": # Passamos o OBJETO do arquivo, não a string do caminho self.initialize_task_naming(platform_file) + case "BEGIN_app_name_relation": + self.initialize_app_naming(platform_file) case _: pass except FileNotFoundError: @@ -148,6 +153,19 @@ def initialize_task_naming(self, platform_file): continue + def initialize_app_naming(self, platform_file): + """Lê a relação nomeID das aplicações (até END_app_name_relation).""" + self.app_name_hash = SortedDict() + for line in platform_file: + fields = line.split() + if fields == ["END_app_name_relation"]: + break + if len(fields) >= 2: + try: + self.app_name_hash[int(fields[1])] = fields[0] + except ValueError: + continue + def initialize_services(self, services_file_path): """Lê os serviços (nome e código decimal) do arquivo services.cfg.""" self.services_hash = SortedDict() @@ -277,10 +295,11 @@ def get_pe_number(self): return self.mpsoc_x * self.mpsoc_y def get_testcase_path(self): - index = self.debug_file_path.find("debug") - if index < 1: + """Pasta do cenário: a que contém o diretório de debug (e o log/).""" + debug_dir = os.path.normpath(self.debug_file_path) + if os.path.basename(debug_dir) != "debug": return None - return self.debug_file_path[:index - 1] + return os.path.dirname(debug_dir) def get_frequency_in_hz(self): return 1_000_000_000 // self.clock_period_in_ns diff --git a/uv.lock b/uv.lock index 092019e..3236fb4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,42 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "project-memphis-py" version = "0.1.0" @@ -11,12 +47,29 @@ dependencies = [ { name = "sortedcontainers" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "pyside6", specifier = ">=6.7" }, { name = "sortedcontainers", specifier = ">=2.4.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyside6" version = "6.11.2" @@ -65,6 +118,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "shiboken6" version = "6.11.2"