2026-rff_mp/filippovavm/docs/laba2/мп2_1.ipynb
2026-05-24 18:27:35 +03:00

632 lines
28 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"cells": [
{
"cell_type": "markdown",
"id": "332cd3ba-eb85-47e3-85cc-736843c10214",
"metadata": {},
"source": [
"# Полная реализация поиска выхода из лабиринта (ООП + паттерны)"
]
},
{
"cell_type": "markdown",
"id": "3d027c2d-7827-4b2f-8c52-0632b97fb462",
"metadata": {},
"source": [
"## Этап 1. Модель лабиринта (без паттернов)\n",
"\n",
"**Описание:** \n",
"Создаются два класса: `Cell` (клетка) и `Maze` (лабиринт). \n",
"`Cell` хранит координаты `(x, y)`, флаг `is_wall`, флаги `is_start`, `is_exit`, метод `is_passable()`. \n",
"`Maze` содержит двумерный массив клеток, размеры, ссылки на старт и выход. \n",
"Метод `get_neighbors(cell)` возвращает список проходимых соседей (вверх, вниз, влево, вправо). \n",
"\n",
"Этот этап — основа для всех последующих алгоритмов."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "c35ca325-3402-4c1b-91d4-32f734d6d599",
"metadata": {},
"outputs": [],
"source": [
"import time\n",
"import csv\n",
"import heapq\n",
"from collections import deque\n",
"from abc import ABC, abstractmethod\n",
"import matplotlib.pyplot as plt\n",
"import pandas as pd\n",
"from dataclasses import dataclass\n",
"\n",
"class Cell:\n",
" def __init__(self, x, y, is_wall=False):\n",
" self.x = x\n",
" self.y = y\n",
" self.is_wall = is_wall\n",
" self.is_start = False\n",
" self.is_exit = False\n",
"\n",
" def is_passable(self):\n",
" return not self.is_wall\n",
"\n",
"\n",
"class Maze:\n",
" def __init__(self, width, height):\n",
" self.width = width\n",
" self.height = height\n",
" self.cells = [[Cell(x, y) for x in range(width)] for y in range(height)]\n",
" self.start = None\n",
" self.exit = None\n",
"\n",
" def get_cell(self, x, y):\n",
" if 0 <= x < self.width and 0 <= y < self.height:\n",
" return self.cells[y][x]\n",
" return None\n",
"\n",
" def get_neighbors(self, cell):\n",
" neighbors = []\n",
" for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:\n",
" nx, ny = cell.x + dx, cell.y + dy\n",
" nb = self.get_cell(nx, ny)\n",
" if nb and nb.is_passable():\n",
" neighbors.append(nb)\n",
" return neighbors"
]
},
{
"cell_type": "markdown",
"id": "9e10908b-e541-46e4-ad15-99555c9c5de3",
"metadata": {},
"source": [
"## Этап 2. Загрузка лабиринта из файла паттерн Builder\n",
"\n",
"**Описание:** \n",
"Паттерн **Builder** отделяет конструирование сложного объекта (лабиринта) от его представления. \n",
"Интерфейс `MazeBuilder` объявляет метод `build_from_file(filename)`. \n",
"`TextFileMazeBuilder` реализует загрузку из текстового файла, где:\n",
"- `#` стена\n",
"- пробел (или любой другой символ, кроме `#`, `S`, `E`) проход\n",
"- `S` старт\n",
"- `E` выход\n",
"\n",
"Процесс: чтение строк, определение размеров, создание клеток, установка флагов. \n",
"Builder скрывает детали парсинга и валидации."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "eab1c38a-aef1-4de7-96b3-24df9b6becb7",
"metadata": {},
"outputs": [],
"source": [
"class MazeBuilder(ABC):\n",
" @abstractmethod\n",
" def build_from_file(self, filename):\n",
" pass\n",
"\n",
"\n",
"class TextFileMazeBuilder(MazeBuilder):\n",
" def build_from_file(self, filename):\n",
" with open(filename, 'r', encoding='utf-8') as f:\n",
" lines = [line.rstrip('\\n') for line in f.readlines()]\n",
" height = len(lines)\n",
" width = max(len(line) for line in lines)\n",
" maze = Maze(width, height)\n",
"\n",
" for y, line in enumerate(lines):\n",
" for x, ch in enumerate(line):\n",
" cell = maze.get_cell(x, y)\n",
" if ch == '#':\n",
" cell.is_wall = True\n",
" elif ch == 'S':\n",
" cell.is_start = True\n",
" maze.start = cell\n",
" elif ch == 'E':\n",
" cell.is_exit = True\n",
" maze.exit = cell\n",
" else:\n",
" cell.is_wall = False\n",
" return maze"
]
},
{
"cell_type": "markdown",
"id": "791f75f0-ea40-496d-ad38-c827e444e6ae",
"metadata": {},
"source": [
"## Этап 3. Стратегии поиска пути паттерн Strategy\n",
"\n",
"**Описание:** \n",
"Паттерн **Strategy** определяет семейство алгоритмов, инкапсулирует каждый и делает их взаимозаменяемыми. \n",
"Интерфейс `PathFindingStrategy` объявляет метод `find_path(maze, start, exit)`, возвращающий `(path, visited_count)`. \n",
"\n",
"Реализованы три стратегии:\n",
"\n",
"1. **BFS (поиск в ширину)** \n",
" - Использует очередь `deque`. \n",
" - Гарантирует нахождение кратчайшего пути по числу шагов. \n",
" - Сложность O(V+E). \n",
" - Подходит для небольших и средних лабиринтов, где важна оптимальность.\n",
"\n",
"2. **DFS (поиск в глубину)** \n",
" - Использует стек (список). \n",
" - Не гарантирует кратчайший путь, но может быть быстрее на определённых конфигурациях. \n",
" - Сложность O(V+E). \n",
" - Полезен, когда нужно быстро найти любой путь.\n",
"\n",
"3. **A\\*** (A-star) \n",
" - Использует приоритетную очередь (heapq) и эвристику. \n",
" - Эвристика манхэттенское расстояние: \n",
" $[\n",
" h(n) = |x_n - x_{exit}| + |y_n - y_{exit}|\n",
" $] \n",
" - Оценка стоимости пути: \\( f(n) = g(n) + h(n) \\), где \\( g(n) \\) реальная стоимость от старта. \n",
" - Гарантирует оптимальность при допустимой эвристике. \n",
" - На практике быстрее BFS за счёт целенаправленного поиска."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "fe37e65c-7f33-458f-9ead-37838b60316a",
"metadata": {},
"outputs": [],
"source": [
"class PathFindingStrategy(ABC):\n",
" @abstractmethod\n",
" def find_path(self, maze, start, exit):\n",
" pass\n",
"\n",
"\n",
"class BFSStrategy(PathFindingStrategy):\n",
" def find_path(self, maze, start, exit):\n",
" visited = set()\n",
" if start == exit:\n",
" return [start], 1\n",
" queue = deque([start])\n",
" visited.add(start)\n",
" parent = {start: None}\n",
" while queue:\n",
" current = queue.popleft()\n",
" for nb in maze.get_neighbors(current):\n",
" if nb not in visited:\n",
" visited.add(nb)\n",
" parent[nb] = current\n",
" if nb == exit:\n",
" path = []\n",
" node = nb\n",
" while node is not None:\n",
" path.append(node)\n",
" node = parent[node]\n",
" path.reverse()\n",
" return path, len(visited)\n",
" queue.append(nb)\n",
" return [], len(visited)\n",
"\n",
"\n",
"class DFSStrategy(PathFindingStrategy):\n",
" def find_path(self, maze, start, exit):\n",
" visited = set()\n",
" stack = [(start, [start])]\n",
" while stack:\n",
" current, path = stack.pop()\n",
" if current == exit:\n",
" return path, len(visited)\n",
" visited.add(current)\n",
" for nb in maze.get_neighbors(current):\n",
" if nb not in visited:\n",
" stack.append((nb, path + [nb]))\n",
" return [], len(visited)\n",
"\n",
"\n",
"class AStarStrategy(PathFindingStrategy):\n",
" def heuristic(self, cell, exit):\n",
" return abs(cell.x - exit.x) + abs(cell.y - exit.y)\n",
"\n",
" def find_path(self, maze, start, exit):\n",
" open_set = []\n",
" counter = 0\n",
" heapq.heappush(open_set, (0, counter, start))\n",
" counter += 1\n",
" came_from = {}\n",
" g_score = {start: 0}\n",
" f_score = {start: self.heuristic(start, exit)}\n",
" visited = set()\n",
" while open_set:\n",
" _, _, current = heapq.heappop(open_set)\n",
" visited.add(current)\n",
" if current == exit:\n",
" path = []\n",
" node = current\n",
" while node in came_from:\n",
" path.append(node)\n",
" node = came_from[node]\n",
" path.append(start)\n",
" path.reverse()\n",
" return path, len(visited)\n",
" for nb in maze.get_neighbors(current):\n",
" tentative_g = g_score[current] + 1\n",
" if tentative_g < g_score.get(nb, float('inf')):\n",
" came_from[nb] = current\n",
" g_score[nb] = tentative_g\n",
" f = tentative_g + self.heuristic(nb, exit)\n",
" heapq.heappush(open_set, (f, counter, nb))\n",
" counter += 1\n",
" return [], len(visited)"
]
},
{
"cell_type": "markdown",
"id": "6eeb7cd4-dade-40a0-b607-c08198b602ea",
"metadata": {},
"source": [
"## Этап 4. Класс-оркестратор MazeSolver и статистика\n",
"\n",
"**Описание:** \n",
"`MazeSolver` принимает лабиринт и стратегию. \n",
"Метод `solve()` замеряет время выполнения (`time.perf_counter()`), вызывает стратегию и возвращает статистику `SearchStats`: \n",
"- `time_ms` время в миллисекундах \n",
"- `visited_cells` количество посещённых клеток \n",
"- `path_length` длина пути \n",
"- `algorithm` имя алгоритма\n",
"\n",
"Класс также поддерживает паттерн **Observer** (будет добавлен в следующем этапе)."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "5177c17f-6dd2-42d7-92b0-87471b5c22c1",
"metadata": {},
"outputs": [],
"source": [
"@dataclass\n",
"class SearchStats:\n",
" time_ms: float\n",
" visited_cells: int\n",
" path_length: int\n",
" algorithm: str\n",
"\n",
"\n",
"class MazeSolver:\n",
" def __init__(self, maze, strategy, observers=None):\n",
" self.maze = maze\n",
" self.strategy = strategy\n",
" self.observers = observers if observers else []\n",
"\n",
" def attach(self, observer):\n",
" self.observers.append(observer)\n",
"\n",
" def detach(self, observer):\n",
" self.observers.remove(observer)\n",
"\n",
" def notify(self, event_type, data=None):\n",
" for obs in self.observers:\n",
" obs.update(event_type, data)\n",
"\n",
" def set_strategy(self, strategy):\n",
" self.strategy = strategy\n",
"\n",
" def solve(self):\n",
" if self.maze.start is None or self.maze.exit is None:\n",
" raise ValueError(\"Лабиринт не имеет старта или выхода\")\n",
" self.notify(\"search_start\")\n",
" start_time = time.perf_counter()\n",
" path, visited = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit)\n",
" end_time = time.perf_counter()\n",
" if path:\n",
" self.notify(\"path_found\", len(path))\n",
" else:\n",
" self.notify(\"no_path\")\n",
" stats = SearchStats(\n",
" time_ms=(end_time - start_time) * 1000,\n",
" visited_cells=visited,\n",
" path_length=len(path),\n",
" algorithm=self.strategy.__class__.__name__\n",
" )\n",
" return path, stats"
]
},
{
"cell_type": "markdown",
"id": "87114e56-ac0f-4227-b081-b7f84ebecaa3",
"metadata": {},
"source": [
"## Этап 5. Визуализация и пошаговое управление паттерны Observer и Command\n",
"\n",
"**Описание:** \n",
"- **Observer** (`ConsoleLogger`) подписывается на события `MazeSolver` и выводит сообщения о начале поиска, нахождении пути или его отсутствии. \n",
"- **Command** интерфейс с методами `execute()` и `undo()`. \n",
" `MoveCommand` реализует перемещение игрока на одну клетку и сохраняет предыдущую позицию для отмены. \n",
" `Player` хранит текущую клетку. \n",
"- Демонстрация: после нахождения пути для `tiny.txt` алгоритм BFS с наблюдателем выводит логи, затем выполняется последовательное перемещение по найденному пути с возможностью отмены последнего шага (undo)."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "171e638f-f3ce-4278-902f-37375c33a94b",
"metadata": {},
"outputs": [],
"source": [
"class Observer(ABC):\n",
" @abstractmethod\n",
" def update(self, event_type, data=None):\n",
" pass\n",
"\n",
"\n",
"class ConsoleLogger(Observer):\n",
" def update(self, event_type, data=None):\n",
" if event_type == \"search_start\":\n",
" print(f\"[LOG] Поиск пути начат\")\n",
" elif event_type == \"path_found\":\n",
" print(f\"[LOG] Путь найден! Длина: {data}\")\n",
" elif event_type == \"no_path\":\n",
" print(\"[LOG] Путь не найден\")\n",
" elif event_type == \"step\":\n",
" print(f\"[LOG] Шаг: {data}\")\n",
"\n",
"\n",
"class Command(ABC):\n",
" @abstractmethod\n",
" def execute(self):\n",
" pass\n",
"\n",
" @abstractmethod\n",
" def undo(self):\n",
" pass\n",
"\n",
"\n",
"class MoveCommand(Command):\n",
" def __init__(self, player, direction, maze):\n",
" self.player = player\n",
" self.direction = direction\n",
" self.maze = maze\n",
" self.prev_pos = None\n",
"\n",
" def execute(self):\n",
" self.prev_pos = self.player.current_cell\n",
" dx, dy = self.direction\n",
" nx, ny = self.player.current_cell.x + dx, self.player.current_cell.y + dy\n",
" new_cell = self.maze.get_cell(nx, ny)\n",
" if new_cell and new_cell.is_passable():\n",
" self.player.current_cell = new_cell\n",
" return True\n",
" return False\n",
"\n",
" def undo(self):\n",
" if self.prev_pos:\n",
" self.player.current_cell = self.prev_pos\n",
" return True\n",
" return False\n",
"\n",
"\n",
"class Player:\n",
" def __init__(self, start_cell):\n",
" self.current_cell = start_cell\n",
"\n",
"\n",
"def interactive_move_demo(maze, path):\n",
" if not path:\n",
" print(\"Путь не найден, демонстрация движения невозможна.\")\n",
" return\n",
" player = Player(maze.start)\n",
" command_history = []\n",
" print(\"\\n=== Интерактивное движение по найденному пути ===\")\n",
" print(\"Текущая позиция: старт\")\n",
" for step, cell in enumerate(path):\n",
" if cell == maze.start:\n",
" continue\n",
" prev = path[step-1]\n",
" dx = cell.x - prev.x\n",
" dy = cell.y - prev.y\n",
" cmd = MoveCommand(player, (dx, dy), maze)\n",
" cmd.execute()\n",
" command_history.append(cmd)\n",
" print(f\"Шаг {step}: перемещение на ({dx},{dy}), позиция ({player.current_cell.x},{player.current_cell.y})\")\n",
" if cell == maze.exit:\n",
" print(\"Достигнут выход!\")\n",
" break\n",
" if command_history:\n",
" print(\"\\n=== Демонстрация отмены последнего шага (undo) ===\")\n",
" cmd = command_history[-1]\n",
" cmd.undo()\n",
" print(f\"Отменён последний шаг, позиция: ({player.current_cell.x},{player.current_cell.y})\")"
]
},
{
"cell_type": "markdown",
"id": "1d67bc06-60f8-4b1d-9018-0b6cb8a8df74",
"metadata": {},
"source": [
"## Этап 6. Экспериментальная часть\n",
"\n",
"**Описание:** \n",
"Подготавливаются 5 лабиринтов разной сложности (файлы `tiny.txt`, `medium.txt`, `large.txt`, `empty.txt`, `no_exit.txt`). \n",
"Для каждого лабиринта и каждой стратегии выполняется 5 запусков `solve()`, усредняются: \n",
"- время выполнения (мс) \n",
"- количество посещённых клеток \n",
"- длина найденного пути \n",
"\n",
"Результаты сохраняются в `all_results.csv`. \n",
"Строятся столбчатые диаграммы для каждого лабиринта и общий график сравнения алгоритмов. \n",
"\n",
"Код также демонстрирует паттерны Observer (логирование) и Command (движение) на лабиринте `tiny.txt`."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "996a2948-28ca-4f04-8571-b8ce694fe2a4",
"metadata": {},
"outputs": [
{
"ename": "NameError",
"evalue": "name 'BFSStrategy' is not defined",
"output_type": "error",
"traceback": [
"\u001b[1;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[1;31mNameError\u001b[0m Traceback (most recent call last)",
"Cell \u001b[1;32mIn[1], line 32\u001b[0m\n\u001b[0;32m 24\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;18m__name__\u001b[39m \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m__main__\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n\u001b[0;32m 25\u001b[0m maze_files \u001b[38;5;241m=\u001b[39m [\n\u001b[0;32m 26\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mtiny.txt\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[0;32m 27\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mmedium.txt\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m (...)\u001b[0m\n\u001b[0;32m 30\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mno_exit.txt\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m 31\u001b[0m ]\n\u001b[1;32m---> 32\u001b[0m strategies \u001b[38;5;241m=\u001b[39m [BFSStrategy(), DFSStrategy(), AStarStrategy()]\n\u001b[0;32m 33\u001b[0m all_results \u001b[38;5;241m=\u001b[39m []\n\u001b[0;32m 34\u001b[0m logger \u001b[38;5;241m=\u001b[39m ConsoleLogger()\n",
"\u001b[1;31mNameError\u001b[0m: name 'BFSStrategy' is not defined"
]
}
],
"source": [
"def test_single_maze(filename, strategies, repeats=5):\n",
" builder = TextFileMazeBuilder()\n",
" maze = builder.build_from_file(filename)\n",
" results = []\n",
" for strategy in strategies:\n",
" solver = MazeSolver(maze, strategy)\n",
" times = []\n",
" visits = []\n",
" lengths = []\n",
" for _ in range(repeats):\n",
" _, stats = solver.solve()\n",
" times.append(stats.time_ms)\n",
" visits.append(stats.visited_cells)\n",
" lengths.append(stats.path_length)\n",
" results.append({\n",
" 'algorithm': strategy.__class__.__name__,\n",
" 'avg_time_ms': sum(times) / repeats,\n",
" 'avg_visited': sum(visits) / repeats,\n",
" 'avg_path_len': sum(lengths) / repeats\n",
" })\n",
" return results\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" maze_files = [\n",
" \"tiny.txt\",\n",
" \"medium.txt\",\n",
" \"large.txt\",\n",
" \"empty.txt\",\n",
" \"no_exit.txt\"\n",
" ]\n",
" strategies = [BFSStrategy(), DFSStrategy(), AStarStrategy()]\n",
" all_results = []\n",
" logger = ConsoleLogger()\n",
"\n",
" for maze_file in maze_files:\n",
" print(f\"Загрузка лабиринта из {maze_file}...\")\n",
" try:\n",
" builder = TextFileMazeBuilder()\n",
" maze = builder.build_from_file(maze_file)\n",
" # Демонстрация Observer и Command для tiny.txt\n",
" if maze_file == \"tiny.txt\":\n",
" solver_with_observer = MazeSolver(maze, strategies[0], observers=[logger])\n",
" path, _ = solver_with_observer.solve()\n",
" interactive_move_demo(maze, path)\n",
" results = test_single_maze(maze_file, strategies)\n",
" for r in results:\n",
" r['maze'] = maze_file\n",
" all_results.append(r)\n",
" print(f\"Результаты для {maze_file}:\")\n",
" for r in results:\n",
" print(f\" {r['algorithm']}: время = {r['avg_time_ms']:.3f} мс, \"\n",
" f\"посещено = {r['avg_visited']:.1f}, длина пути = {r['avg_path_len']:.1f}\")\n",
" except Exception as e:\n",
" print(f\"Ошибка при обработке {maze_file}: {e}\")\n",
"\n",
" if all_results:\n",
" with open('all_results.csv', 'w', newline='', encoding='utf-8') as f:\n",
" writer = csv.DictWriter(f, fieldnames=['maze', 'algorithm', 'avg_time_ms', 'avg_visited', 'avg_path_len'])\n",
" writer.writeheader()\n",
" writer.writerows(all_results)\n",
"\n",
" df = pd.DataFrame(all_results)\n",
" for maze in df['maze'].unique():\n",
" subset = df[df['maze'] == maze]\n",
" plt.figure()\n",
" plt.bar(subset['algorithm'], subset['avg_time_ms'])\n",
" plt.title(f'Сравнение алгоритмов на лабиринте {maze}')\n",
" plt.ylabel('Среднее время (мс)')\n",
" plt.savefig(f'plot_{maze}.png')\n",
" plt.close()\n",
"\n",
" plt.figure(figsize=(10, 6))\n",
" for alg in df['algorithm'].unique():\n",
" subset = df[df['algorithm'] == alg]\n",
" plt.plot(subset['maze'], subset['avg_time_ms'], marker='o', label=alg)\n",
" plt.xlabel('Лабиринт')\n",
" plt.ylabel('Среднее время (мс)')\n",
" plt.title('Сравнение эффективности алгоритмов на разных лабиринтах')\n",
" plt.legend()\n",
" plt.grid(True)\n",
" plt.savefig('summary_comparison.png')\n",
" plt.show()\n",
" else:\n",
" print(\"Нет данных для построения графиков. Проверьте файлы лабиринтов.\")\n",
"\n",
" print(\"\\nЭксперимент завершён. Результаты сохранены в all_results.csv и графиках.\")"
]
},
{
"cell_type": "markdown",
"id": "937ab5f6-e884-46f6-8d45-b152ca61e7b7",
"metadata": {},
"source": [
"## Заключение\n",
"\n",
"В работе реализованы:\n",
"- Классы `Cell` и `Maze` для моделирования лабиринта.\n",
"- Паттерн **Builder** для загрузки лабиринтов из текстовых файлов.\n",
"- Паттерн **Strategy** для трёх алгоритмов поиска: BFS, DFS, A*.\n",
"- Паттерны **Observer** (логирование) и **Command** (управление с отменой) для визуализации и интерактивности.\n",
"- Экспериментальная часть с замером времени, посещённых клеток, длины пути, сохранением результатов в CSV и построением графиков.\n",
"\n",
"Код полностью соответствует заданию и готов к использованию."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1ddcb647-eb50-40aa-bc9a-cb76f8a14f23",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "a560e7bf-6b18-4018-9912-ea8da341e8a7",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "25793d80-f546-4270-ae7c-86c4898d6c32",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}