From 55333a86523c683fe6e6c9df982bcd891a512cac Mon Sep 17 00:00:00 2001 From: AdamBalski Date: Mon, 19 Jan 2026 00:47:47 +0100 Subject: [PATCH] add charts --- config.yaml | 6 +- src/charts.py | 183 +++++++++++++++++++++++++++++++++++++++++++++ src/config.py | 4 +- src/environment.py | 34 +++++++++ src/run.py | 61 +++++++++++++++ 5 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 src/charts.py diff --git a/config.yaml b/config.yaml index c09f391..89668c6 100644 --- a/config.yaml +++ b/config.yaml @@ -1,11 +1,13 @@ window: width: 1200 - height: 800 + height: 1000 panel: width: 700 height: 700 x: 100 y: 50 +chart: + height: 200 grid: cell_size: 25 mating: @@ -14,4 +16,4 @@ mating: max_range: 5 mutation_chance: 0.05 mutation_multiply_border: 0.2 - mutation_addding_border: 0.05 \ No newline at end of file + mutation_addding_border: 0.05 diff --git a/src/charts.py b/src/charts.py new file mode 100644 index 0000000..2eb00eb --- /dev/null +++ b/src/charts.py @@ -0,0 +1,183 @@ +"""Simple chart registry and rendering utilities.""" + +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass, field +from typing import Callable, Deque, List, Tuple + +import pygame + +ChartCallback = Callable[[], float] + + +@dataclass +class Chart: + name: str + value_name: str + callback: ChartCallback + history: Deque[Tuple[float, float]] = field(default_factory=lambda: deque(maxlen=300)) + + def sample(self, timestamp: float) -> None: + try: + value = float(self.callback()) + except Exception: + # On callback failure, skip this sample but keep the chart alive. + return + self.history.append((timestamp, value)) + + def _value_range(self) -> Tuple[float, float]: + values = [v for _, v in self.history] + if not values: + return (0.0, 1.0) + v_min = min(values) + v_max = max(values) + if abs(v_max - v_min) < 1e-9: + padding = max(1.0, abs(v_max) * 0.1 + 1.0) + return (v_min - padding, v_max + padding) + return (v_min, v_max) + + def render(self, surface: pygame.Surface, rect: pygame.Rect, font: pygame.font.Font) -> None: + pygame.draw.rect(surface, (25, 25, 25), rect) + pygame.draw.rect(surface, (80, 80, 80), rect, 1) + + title = font.render(self.name, True, (220, 220, 220)) + surface.blit(title, (rect.x + 8, rect.y + 4)) + + label = font.render(self.value_name, True, (180, 180, 180)) + surface.blit(label, (rect.x + 8, rect.y + 20)) + + if len(self.history) < 2: + return + + times = [t for t, _ in self.history] + start = times[0] + duration = max(times[-1] - start, 1.0) + v_min, v_max = self._value_range() + scale = max(v_max - v_min, 1e-6) + + padding_top = 28 + padding_bottom = 12 + padding_side = 8 + usable_width = max(1, rect.width - padding_side * 2) + usable_height = max(1, rect.height - padding_top - padding_bottom) + + points: List[Tuple[int, int]] = [] + for timestamp, value in self.history: + x = rect.x + padding_side + int(((timestamp - start) / duration) * usable_width) + normalized = (value - v_min) / scale + y = rect.y + rect.height - padding_bottom - int(normalized * usable_height) + points.append((x, y)) + + if len(points) >= 2: + pygame.draw.lines(surface, (120, 200, 255), False, points, 2) + + latest_value = self.history[-1][1] + latest_text = font.render(f"{latest_value:.1f}", True, (255, 255, 255)) + surface.blit(latest_text, (rect.right - latest_text.get_width() - 8, rect.y + 4)) + + +class ChartManager: + def __init__(self, sample_interval: float = 1.0): + self.sample_interval = sample_interval + self._accumulator = 0.0 + self._charts: List[Chart] = [] + self._font: pygame.font.Font | None = None + self._scroll_x = 0.0 + self._scroll_speed = 40.0 + self._view_columns = 2.5 + self._last_sample_time: float | None = None + + def register_chart(self, name: str, value_name: str, callback: ChartCallback) -> Chart: + chart = Chart(name=name, value_name=value_name, callback=callback) + self._charts.append(chart) + return chart + + def update(self, dt: float) -> None: + self._accumulator += dt + while self._accumulator >= self.sample_interval: + self._accumulator -= self.sample_interval + self._poll() + + def _poll(self) -> None: + timestamp = time.time() + self._last_sample_time = timestamp + for chart in self._charts: + chart.sample(timestamp) + + def render(self, surface: pygame.Surface, rect: pygame.Rect) -> None: + if not self._charts: + pygame.draw.rect(surface, (30, 30, 30), rect) + pygame.draw.rect(surface, (80, 80, 80), rect, 1) + return + + chart_height = rect.height + chart_width = rect.width / max(self._view_columns, 0.1) + chart_w = max(1, int(chart_width)) + total_width = chart_width * len(self._charts) + + max_scroll = max(0.0, total_width - rect.width) + self._scroll_x = max(0.0, min(self._scroll_x, max_scroll)) + + font = self._font or pygame.font.Font(None, 20) + self._font = font + + pygame.draw.rect(surface, (20, 20, 20), rect) + pygame.draw.rect(surface, (50, 50, 50), rect, 1) + + clip = surface.get_clip() + surface.set_clip(rect) + + origin_x = rect.x - self._scroll_x + for i, chart in enumerate(self._charts): + chart_rect = pygame.Rect( + int(origin_x + i * chart_width), + rect.y, + chart_w, + chart_height, + ) + if chart_rect.right < rect.x or chart_rect.x > rect.right: + continue + chart.render(surface, chart_rect, font) + + surface.set_clip(clip) + + def scroll(self, dy: float) -> None: + self._scroll_x = max(0.0, self._scroll_x + dy * self._scroll_speed) + + +_MANAGER = ChartManager() + + +def register_chart(name: str, value_name: str, callback: ChartCallback) -> Chart: + """Registers a chart to be sampled and rendered.""" + return _MANAGER.register_chart(name, value_name, callback) + + +def update(dt: float) -> None: + """Advances the polling timer.""" + _MANAGER.update(dt) + + +def render(surface: pygame.Surface, rect: pygame.Rect) -> None: + """Renders all registered charts inside the given rect.""" + _MANAGER.render(surface, rect) + + +def scroll(dy: float) -> None: + """Scrolls the chart viewport horizontally (dy is mouse wheel delta).""" + _MANAGER.scroll(dy) + + +def first_sample_age() -> float: + """Returns seconds since the oldest datapoint across charts was sampled.""" + oldest = None + for chart in _MANAGER._charts: + if chart.history: + timestamp = chart.history[0][0] + if oldest is None or timestamp < oldest: + oldest = timestamp + if oldest is None: + return 0.0 + return max(0.0, time.time() - oldest) diff --git a/src/config.py b/src/config.py index 7ff0f49..707d173 100644 --- a/src/config.py +++ b/src/config.py @@ -17,9 +17,11 @@ GRID_WIDTH = PANEL_WIDTH // CELL_SIZE GRID_HEIGHT = PANEL_HEIGHT // CELL_SIZE +CHART_AREA_HEIGHT = cfg["chart"]["height"] + MIN_AGE_PERCENT = cfg["mating"]["min_age_percent"] MIN_ENERGY_LEVEL = cfg["mating"]["min_energy_level"] MAX_RANGE = cfg["mating"]["max_range"] MUTATION_CHANCE = cfg["mating"]["mutation_chance"] MUTATION_MULTIPLY_BORDER = cfg["mating"]["mutation_multiply_border"] -MUTATION_ADDING_BORDER = cfg["mating"]["mutation_addding_border"] \ No newline at end of file +MUTATION_ADDING_BORDER = cfg["mating"]["mutation_addding_border"] diff --git a/src/environment.py b/src/environment.py index ded8aab..fd68299 100644 --- a/src/environment.py +++ b/src/environment.py @@ -285,6 +285,40 @@ def get_agents(self): def create_agent(self, agent : Agent): self.agents.append(agent) + def agent_count(self) -> int: + with self.data_lock: + return sum(1 for agent in self.agents if agent.is_alive()) + + def average_agent_health(self) -> float: + with self.data_lock: + living = [agent for agent in self.agents if agent.is_alive()] + if not living: + return 0.0 + return sum(agent.hp for agent in living) / len(living) + + def average_agent_energy(self) -> float: + with self.data_lock: + living = [agent for agent in self.agents if agent.is_alive()] + if not living: + return 0.0 + return sum(agent.energy for agent in living) / len(living) + + def food_sources_with_stock(self) -> int: + with self.data_lock: + return sum( + 1 + for source in self.food_sources + if not source.is_destroyed and getattr(source, "food_left", 0) >= 1 + ) + + def total_food_stock(self) -> float: + with self.data_lock: + return sum( + max(0.0, float(getattr(source, "food_left", 0))) + for source in self.food_sources + if not source.is_destroyed + ) + def _get_agent_area(self, agent: Agent) -> Area: """Maps agent pixel position to the underlying terrain cell.""" grid_x = min(self.grid_width - 1, max(0, int(agent.x // self.cell_size))) diff --git a/src/run.py b/src/run.py index 8ad05f1..e483b9d 100644 --- a/src/run.py +++ b/src/run.py @@ -7,6 +7,7 @@ from area import Area from food import FoodSource from ui import UI +import charts pygame.init() pygame.display.set_caption("Simulation") @@ -14,6 +15,24 @@ clock = pygame.time.Clock() manager = pygame_gui.UIManager((config.WINDOW_WIDTH, config.WINDOW_HEIGHT)) +hint_font = pygame.font.Font(None, 22) + + +def _format_first_sample_age() -> str: + age = charts.first_sample_age() + if age <= 0: + return "" + if age < 60: + return f"Oldest sample is {int(age)}s old" + minutes = int(age // 60) + seconds = int(age % 60) + return f"Oldest sample is {minutes}m {seconds}s old" + + +def _chart_hint_text() -> str: + base = "Scroll over the chart area to pan charts" + age_text = _format_first_sample_age() + return f"{base} — {age_text}" if age_text else base env_ui = UI( manager=manager, x=config.PANEL_WIDTH + 2*config.PANEL_X, @@ -29,6 +48,40 @@ pixel_height=config.PANEL_HEIGHT ) +chart_rect = pygame.Rect( + config.PANEL_X, + config.PANEL_Y + config.PANEL_HEIGHT + 20, + config.PANEL_WIDTH, + config.CHART_AREA_HEIGHT +) +chart_hint_pos = (chart_rect.x, chart_rect.bottom + 4) + +charts.register_chart( + name="Agents over time", + value_name="Agents", + callback=env.agent_count, +) +charts.register_chart( + name="Average health", + value_name="HP", + callback=env.average_agent_health, +) +charts.register_chart( + name="Average energy", + value_name="Energy", + callback=env.average_agent_energy, +) +charts.register_chart( + name="Food sources with stock", + value_name="Sources", + callback=env.food_sources_with_stock, +) +charts.register_chart( + name="Stored food across sources", + value_name="Food", + callback=env.total_food_stock, +) + running = True while running: dt = clock.tick(60) / 1000.0 @@ -52,10 +105,14 @@ env.change_area_at(grid_x, grid_y, env_ui.current_brush) elif isinstance(env_ui.current_brush, type(FoodSource)): env.add_manual_food_source(grid_x, grid_y, env_ui.current_brush) + elif event.type == pygame.MOUSEWHEEL: + if chart_rect.collidepoint(mouse_pos): + charts.scroll(-event.y) manager.process_events(event) env_ui.process_events(event, env) manager.update(dt) + charts.update(dt) screen.fill((20, 20, 20)) # Clear screen @@ -66,6 +123,10 @@ cell_size=config.CELL_SIZE ) + charts.render(screen, chart_rect) + hint_surface = hint_font.render(_chart_hint_text(), True, (220, 220, 220)) + screen.blit(hint_surface, chart_hint_pos) + manager.draw_ui(screen) pygame.display.flip()