refactor: enforce 500-line limit on all Python source files

Split 18+ Python files that exceeded 500 lines into smaller modules
with helper files (prefixed with _). All functions are re-exported
from the original modules to maintain backward compatibility with
test patches and external imports.

Files split:
- moviepy_showcase.py (1212 -> 302 + 3 helpers)
- anki_generator.py (1174 -> 473 + 4 helpers)
- test_analyze_chess_game.py (1152 -> 361 + 2 parts)
- poker_modifier_app.py (1024 -> 263 + 2 helpers)
- transcribe_fw.py (1007 -> 342 + 3 helpers)
- music_generator.py (1002 -> 319 + 2 helpers)
- translator.py (951 -> 442 + 2 helpers)
- cinema_planner.py (893 -> 369 + 2 helpers)
- lichess_bot/main.py (757 -> 495 + _game_logic.py)
- test_translator.py (725 -> 289 + part2 + conftest)
- test_lichess_api.py (680 -> 475 + part2)
- learning_pipe.py (668 -> 375 + 2 helpers)
- cache.py (655 -> 360 + _cache_decks.py)
- analyze_chess_game.py (632 -> 463 + _move_analysis.py)
- visualize_q02.py (609 -> 371 + helper)
- repo_explorer.py (602 -> 347 + 2 helpers)
- keyboard_coop/main.py (515 -> 416 + _dictionary.py)
- scanning.py (501 -> 314 + _enforce_loop.py)

All tests pass: 144 lichess_bot (100% branch coverage), 243 others.
No new lint errors introduced.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-17 22:47:42 +01:00
parent 27a1ef634c
commit 8f2fbd2311
101 changed files with 16177 additions and 15805 deletions

View File

@ -118,9 +118,7 @@ def generate_anki_package(
deck_name: str = "Polish Coastal Features",
) -> genanki.Package:
"""Generate Anki package for Polish coastal features."""
model_id_hash = hashlib.sha256(
f"polish_coastal_features_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_coastal_features_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -121,9 +121,7 @@ def generate_anki_package(
deck_name: str = "Polish Landscape Parks",
) -> genanki.Package:
"""Generate Anki package for Polish landscape parks."""
model_id_hash = hashlib.sha256(
f"polish_landscape_parks_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_landscape_parks_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -141,9 +141,7 @@ def generate_anki_package(
zoom: bool = True,
) -> genanki.Package:
"""Generate Anki package for Polish mountain peaks."""
model_id_hash = hashlib.sha256(
f"polish_mountain_peaks_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_mountain_peaks_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -117,9 +117,7 @@ def generate_anki_package(
deck_name: str = "Polish Mountain Ranges",
) -> genanki.Package:
"""Generate Anki package for Polish mountain ranges."""
model_id_hash = hashlib.sha256(
f"polish_mountain_ranges_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_mountain_ranges_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -133,9 +133,7 @@ def generate_anki_package(
deck_name: str = "Polish National Parks",
) -> genanki.Package:
"""Generate Anki package for Polish national parks."""
model_id_hash = hashlib.sha256(
f"polish_national_parks_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_national_parks_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -111,9 +111,7 @@ def generate_anki_package(
deck_name: str = "Polish Nature Reserves",
) -> genanki.Package:
"""Generate Anki package for Polish nature reserves."""
model_id_hash = hashlib.sha256(
f"polish_nature_reserves_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_nature_reserves_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -132,9 +132,7 @@ def generate_anki_package(
deck_name: str = "Polish UNESCO World Heritage Sites",
) -> genanki.Package:
"""Generate Anki package for Polish UNESCO sites."""
model_id_hash = hashlib.sha256(
f"polish_unesco_sites_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"polish_unesco_sites_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
card_css = """

View File

@ -184,9 +184,7 @@ def generate_anki_package(
genanki.Package object ready to be written to file.
"""
# Create a unique model ID based on deck name
model_id_hash = hashlib.sha256(
f"warsaw_districts_{deck_name}".encode()
)
model_id_hash = hashlib.sha256(f"warsaw_districts_{deck_name}".encode())
model_id = int(model_id_hash.hexdigest()[:8], 16)
# Define the note model (card template) with centered styling

View File

@ -0,0 +1,342 @@
"""Parsing functions for Cinema City schedules and manual input."""
from __future__ import annotations
from dataclasses import dataclass, field
import importlib
import logging
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING, TextIO
if TYPE_CHECKING:
import types
logger = logging.getLogger(__name__)
# Constants for validation and parsing
_MIN_MANUAL_LINE_PARTS = 3
_MIN_TITLE_LENGTH = 3
_DEFAULT_MOVIE_DURATION = 120
_TITLE_LOOKAHEAD_LINES = 5
def _try_import(name: str) -> types.ModuleType | None:
"""Attempt to import a module, returning None if unavailable."""
try:
return importlib.import_module(name)
except ImportError:
return None
_pdfplumber = _try_import("pdfplumber")
_fitz = _try_import("fitz")
@dataclass
class Movie:
"""A movie with screening times and metadata."""
name: str
start_times: list[int]
duration: int
genres: list[str] = field(default_factory=list)
def parse_time(time_str: str) -> int:
"""Parse time string like '18:20' to minutes from midnight."""
time_str = time_str.strip().replace(".", ":")
match = re.match(r"(\d{1,2}):(\d{2})", time_str)
if not match:
msg = f"Invalid time format: {time_str}"
raise ValueError(msg)
hours, minutes = int(match.group(1)), int(match.group(2))
return hours * 60 + minutes
def parse_duration(duration_str: str) -> int:
"""Parse duration like '1h 46m', '1:46', '106m', '110 min', etc."""
duration_str = duration_str.strip().lower()
# Try "X min" format (from Cinema City)
match = re.search(r"(\d+)\s*min", duration_str)
if match:
return int(match.group(1))
hours = 0
minutes = 0
h_match = re.search(r"(\d+)\s*h", duration_str)
m_match = re.search(r"(\d+)\s*m(?!in)", duration_str)
if h_match or m_match:
if h_match:
hours = int(h_match.group(1))
if m_match:
minutes = int(m_match.group(1))
return hours * 60 + minutes
# Try "H:MM" format
match = re.match(r"(\d+):(\d{2})", duration_str)
if match:
return int(match.group(1)) * 60 + int(match.group(2))
# Try pure minutes
match = re.match(r"(\d+)", duration_str)
if match:
return int(match.group(1))
msg = f"Invalid duration format: {duration_str}"
raise ValueError(msg)
def parse_manual_line(line: str) -> Movie | None:
"""Parse a manual format line like 'Movie A, 18:20 or 20:50, 1h 46m'."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split(",")
if len(parts) < _MIN_MANUAL_LINE_PARTS:
msg = f"Invalid line format: {line}"
raise ValueError(msg)
movie = parts[0].strip()
times_str = parts[1].strip()
duration_str = ",".join(parts[2:]).strip()
start_times = [
parse_time(time_part)
for time_part in re.split(r"\s+or\s+", times_str, flags=re.IGNORECASE)
]
duration = parse_duration(duration_str)
return Movie(movie, start_times, duration)
def _try_parse_time(time_str: str) -> int | None:
"""Try to parse a time string, returning None on failure."""
try:
return parse_time(time_str)
except ValueError:
return None
def _try_parse_manual_line(
line: str,
error_stream: TextIO | None = None,
) -> Movie | None:
"""Try to parse a manual line, writing errors to error_stream."""
try:
return parse_manual_line(line)
except ValueError as e:
if error_stream is not None:
error_stream.write(f"Warning: {e}\n")
return None
def _try_parse_interactive_line(line: str) -> Movie | None:
"""Try to parse a line in interactive mode, logging errors."""
try:
result = parse_manual_line(line)
except ValueError:
logger.exception(" Error parsing input")
return None
if result:
logger.info(" Added: %s", result.name)
return result
def extract_date_from_html(content: str) -> str | None:
"""Extract schedule date from Cinema City HTML."""
# Look for date in YYYY-MM-DD format
match = re.search(r"(202\d-\d{2}-\d{2})", content)
if match:
return match.group(1)
return None
def parse_cinema_city_html(
filepath: str,
) -> tuple[list[Movie], str | None]:
"""Parse Cinema City HTML schedule.
Returns:
Tuple of (movies, date).
"""
with Path(filepath).open(encoding="utf-8") as f:
content = f.read()
movies: list[Movie] = []
schedule_date = extract_date_from_html(content)
# Split content by movie sections
sections = re.split(r'class="row movie-row', content)
for section in sections[1:]: # Skip first (before any movie)
# Get movie name
name_match = re.search(r'qb-movie-name">([^<]+)<', section)
if not name_match:
continue
movie_name = name_match.group(1).strip()
# Get genres
genre_match = re.search(
r'class="mr-sm"[^>]*>([^<]+)<\s*span', section
)
genres: list[str] = []
if genre_match:
genre_text = genre_match.group(1).strip()
genres = [
g.strip() for g in genre_text.split(",") if g.strip()
]
# Get duration
duration_match = re.search(r"(\d+)\s*min", section)
if not duration_match:
continue
duration = int(duration_match.group(1))
# Get screening times - look for time buttons
times = re.findall(
r'btn btn-primary btn-lg">\s*(\d{2}:\d{2})\s*<', section
)
if not times:
# Try alternate pattern
times = re.findall(
r">\s*(\d{2}:\d{2})\s*\(HTTPS://", section
)
if times:
start_times = list(dict.fromkeys(
parse_time(t) for t in times
))
movies.append(
Movie(movie_name, start_times, duration, genres),
)
# Deduplicate movies (same movie might appear multiple times)
seen: set[str] = set()
unique_movies: list[Movie] = []
for movie in movies:
if movie.name not in seen:
seen.add(movie.name)
unique_movies.append(movie)
return unique_movies, schedule_date
def parse_cinema_city_pdf(filepath: str) -> list[Movie]:
"""Parse Cinema City PDF schedule by extracting text."""
if _pdfplumber is not None:
with _pdfplumber.open(filepath) as pdf:
full_text = ""
for page in pdf.pages:
text = page.extract_text()
if text:
full_text += text + "\n"
return parse_cinema_city_text(full_text)
return _parse_cinema_city_pdf_basic(filepath)
def _parse_cinema_city_pdf_basic(filepath: str) -> list[Movie]:
"""Basic PDF parsing using PyMuPDF or falling back to subprocess."""
if _fitz is not None:
doc = _fitz.open(filepath)
full_text = ""
for page in doc:
full_text += page.get_text() + "\n"
doc.close()
return parse_cinema_city_text(full_text)
pdftotext_path = shutil.which("pdftotext")
if pdftotext_path is None:
_exit_no_pdf_support()
try:
result = subprocess.run(
[pdftotext_path, "-layout", filepath, "-"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError:
_exit_no_pdf_support()
return parse_cinema_city_text(result.stdout)
def _exit_no_pdf_support() -> None:
"""Log PDF support error and exit."""
logger.error(
"Install pdfplumber, PyMuPDF, or poppler-utils for PDF support"
)
logger.error(" pip install pdfplumber")
logger.error(" pip install pymupdf")
logger.error(" pacman -S poppler")
sys.exit(1)
def parse_cinema_city_text(text: str) -> list[Movie]:
"""Parse Cinema City schedule from extracted text."""
movies: list[Movie] = []
lines = text.split("\n")
current_movie: str | None = None
current_duration: int | None = None
current_times: list[int] = []
# Patterns for movie titles (all caps, usually)
movie_title_pattern = re.compile(
r"^([A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9\s:,\.\-\!\?\(\)]+)$"
)
duration_pattern = re.compile(r"(\d+)\s*min")
time_pattern = re.compile(r"\b(\d{1,2}:\d{2})\b")
for i, raw_line in enumerate(lines):
line = raw_line.strip()
if (
movie_title_pattern.match(line)
and len(line) > _MIN_TITLE_LENGTH
):
if current_movie and current_times:
movies.append(Movie(
current_movie,
list(dict.fromkeys(current_times)),
current_duration or _DEFAULT_MOVIE_DURATION,
))
current_movie = line.title()
current_times = []
current_duration = None
# Look ahead for duration
end = min(i + _TITLE_LOOKAHEAD_LINES, len(lines))
for j in range(i + 1, end):
dur_match = duration_pattern.search(lines[j])
if dur_match:
current_duration = int(dur_match.group(1))
break
if current_movie:
times_in_line = time_pattern.findall(line)
for t in times_in_line:
parsed = _try_parse_time(t)
if parsed is not None:
current_times.append(parsed)
# Save last movie
if current_movie and current_times:
movies.append(Movie(
current_movie,
list(dict.fromkeys(current_times)),
current_duration or _DEFAULT_MOVIE_DURATION,
))
return movies

View File

@ -0,0 +1,222 @@
"""Scheduling algorithm and display formatting for cinema plans."""
from __future__ import annotations
from dataclasses import dataclass
import sys
from typing import TYPE_CHECKING, TextIO
if TYPE_CHECKING:
from python_pkg.cinema_planner._cinema_parsing import Movie
# Ads duration before movie starts (Cinema City shows ~15 min of ads)
ADS_DURATION = 15
_SEPARATOR_WIDTH = 60
@dataclass
class Screening:
"""A specific screening of a movie at a particular time."""
movie: str
start: int # minutes from midnight
end: int # minutes from midnight
def overlaps(self, other: Screening, buffer: int = 0) -> bool:
"""Check if this screening overlaps with another, considering buffer."""
# Account for ADS_DURATION grace period
return not (
self.end + buffer <= other.start + ADS_DURATION
or other.end + buffer <= self.start + ADS_DURATION
)
def start_str(self) -> str:
"""Format start time as HH:MM."""
return f"{self.start // 60:02d}:{self.start % 60:02d}"
def end_str(self) -> str:
"""Format end time as HH:MM."""
return f"{self.end // 60:02d}:{self.end % 60:02d}"
def find_best_schedule(
movies: list[Movie],
buffer: int,
) -> list[list[Screening]]:
"""Find ALL schedules that maximize number of movies watched."""
movie_screenings: list[list[Screening]] = [
[
Screening(movie.name, start, start + movie.duration)
for start in movie.start_times
]
for movie in movies
]
best_count = 0
all_best_schedules: list[list[Screening]] = []
def _backtrack(
movie_idx: int,
current_schedule: list[Screening],
) -> None:
nonlocal best_count, all_best_schedules
if movie_idx == len(movie_screenings):
if len(current_schedule) > best_count:
best_count = len(current_schedule)
all_best_schedules = [current_schedule.copy()]
elif (
len(current_schedule) == best_count
and best_count > 0
):
all_best_schedules.append(current_schedule.copy())
return
# Pruning: can't beat the best
remaining = len(movie_screenings) - movie_idx
if len(current_schedule) + remaining < best_count:
return
# Try each screening of current movie
for screening in movie_screenings[movie_idx]:
conflicts = any(
screening.overlaps(s, buffer)
for s in current_schedule
)
if not conflicts:
current_schedule.append(screening)
_backtrack(movie_idx + 1, current_schedule)
current_schedule.pop()
# Also try skipping this movie
_backtrack(movie_idx + 1, current_schedule)
_backtrack(0, [])
# Sort each schedule by start time and return
return [
sorted(schedule, key=lambda s: s.start)
for schedule in all_best_schedules
]
def _format_single_schedule(
schedule: list[Screening],
output: TextIO,
) -> None:
"""Format a single schedule to the output stream."""
for i, screening in enumerate(schedule, 1):
duration = screening.end - screening.start
hours, mins = divmod(duration, 60)
actual_start = screening.start + ADS_DURATION
actual_start_str = (
f"{actual_start // 60:02d}:{actual_start % 60:02d}"
)
output.write(
f" {i}. {screening.start_str()} - "
f"{screening.end_str()} {screening.movie}\n"
)
output.write(
f" Duration: {hours}h {mins}m "
f"(movie starts ~{actual_start_str})\n"
)
if i < len(schedule):
gap = schedule[i].start - screening.end
if gap > 0:
output.write(f" [{gap} min break]\n")
output.write("\n")
def _format_schedules(
schedules: list[list[Screening]],
all_movies: list[str],
date: str | None = None,
max_display: int = 5,
*,
output: TextIO | None = None,
) -> None:
"""Format optimal schedules to the output stream."""
if output is None:
output = sys.stdout
sep = "=" * _SEPARATOR_WIDTH
thin_sep = "\u2500" * _SEPARATOR_WIDTH
if not schedules or not schedules[0]:
output.write("No movies can be scheduled!\n")
return
num_movies = len(schedules[0])
num_schedules = len(schedules)
output.write(f"\n{sep}\n")
if date:
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
else:
output.write(" OPTIMAL CINEMA SCHEDULES\n")
output.write(
f" {num_movies} movies, "
f"{num_schedules} possible combination(s)\n"
)
output.write(f"{sep}\n\n")
display_count = min(num_schedules, max_display)
for idx, schedule in enumerate(schedules[:display_count], 1):
if num_schedules > 1:
output.write(f"{thin_sep}\n")
output.write(f" OPTION {idx}:\n")
output.write(f"{thin_sep}\n\n")
_format_single_schedule(schedule, output)
if num_schedules > display_count:
output.write(f"{thin_sep}\n")
output.write(
f" ... and {num_schedules - display_count} "
"more combinations\n"
)
output.write(" (use -n to show more, e.g., -n 10)\n")
output.write("\n")
# Show skipped movies (from first schedule as reference)
scheduled_movies = {s.movie for s in schedules[0]}
skipped = [m for m in all_movies if m not in scheduled_movies]
if skipped and num_schedules == 1:
output.write(f"{thin_sep}\n")
output.write(f" Skipped movies ({len(skipped)}):\n")
for movie in skipped:
output.write(f" - {movie}\n")
output.write("\n")
def _format_all_movies(
movies: list[Movie],
date: str | None = None,
*,
output: TextIO | None = None,
) -> None:
"""Format all parsed movies to the output stream."""
if output is None:
output = sys.stdout
thin_sep = "\u2500" * _SEPARATOR_WIDTH
output.write(f"\n{thin_sep}\n")
if date:
output.write(f" Parsed {len(movies)} movies for {date}:\n")
else:
output.write(f" Parsed {len(movies)} movies:\n")
output.write(f"{thin_sep}\n")
for movie in movies:
times_str = ", ".join(
f"{t // 60:02d}:{t % 60:02d}"
for t in sorted(movie.start_times)
)
genre_str = (
f" [{', '.join(movie.genres)}]" if movie.genres else ""
)
output.write(
f" {movie.name} ({movie.duration} min){genre_str}\n"
)
output.write(f" Times: {times_str}\n")
output.write("\n")

View File

@ -16,568 +16,35 @@ from __future__ import annotations
import argparse
from contextlib import suppress
from dataclasses import dataclass, field
import importlib
from io import StringIO
import logging
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING, TextIO
if TYPE_CHECKING:
import types
from python_pkg.cinema_planner._cinema_parsing import (
Movie,
_try_parse_interactive_line,
_try_parse_manual_line,
parse_cinema_city_html,
parse_cinema_city_pdf,
)
from python_pkg.cinema_planner._cinema_scheduling import (
Screening,
_format_all_movies,
_format_schedules,
find_best_schedule,
)
logger = logging.getLogger(__name__)
# Default genres to exclude (can be overridden with --all-genres)
DEFAULT_EXCLUDED_GENRES = {"horror"}
# Ads duration before movie starts (Cinema City shows ~15 min of ads)
ADS_DURATION = 15
# Constants for validation and parsing
_MIN_MANUAL_LINE_PARTS = 3
_MIN_TITLE_LENGTH = 3
_DEFAULT_MOVIE_DURATION = 120
_TITLE_LOOKAHEAD_LINES = 5
_SEPARATOR_WIDTH = 60
def _try_import(name: str) -> types.ModuleType | None:
"""Attempt to import a module, returning None if unavailable."""
try:
return importlib.import_module(name)
except ImportError:
return None
_pdfplumber = _try_import("pdfplumber")
_fitz = _try_import("fitz")
@dataclass
class Movie:
"""A movie with screening times and metadata."""
name: str
start_times: list[int]
duration: int
genres: list[str] = field(default_factory=list)
@dataclass
class Screening:
"""A specific screening of a movie at a particular time."""
movie: str
start: int # minutes from midnight
end: int # minutes from midnight
def overlaps(self, other: Screening, buffer: int = 0) -> bool:
"""Check if this screening overlaps with another, considering buffer."""
# Account for ADS_DURATION grace period
return not (
self.end + buffer <= other.start + ADS_DURATION
or other.end + buffer <= self.start + ADS_DURATION
)
def start_str(self) -> str:
"""Format start time as HH:MM."""
return f"{self.start // 60:02d}:{self.start % 60:02d}"
def end_str(self) -> str:
"""Format end time as HH:MM."""
return f"{self.end // 60:02d}:{self.end % 60:02d}"
def parse_time(time_str: str) -> int:
"""Parse time string like '18:20' to minutes from midnight."""
time_str = time_str.strip().replace(".", ":")
match = re.match(r"(\d{1,2}):(\d{2})", time_str)
if not match:
msg = f"Invalid time format: {time_str}"
raise ValueError(msg)
hours, minutes = int(match.group(1)), int(match.group(2))
return hours * 60 + minutes
def parse_duration(duration_str: str) -> int:
"""Parse duration like '1h 46m', '1:46', '106m', '110 min', etc."""
duration_str = duration_str.strip().lower()
# Try "X min" format (from Cinema City)
match = re.search(r"(\d+)\s*min", duration_str)
if match:
return int(match.group(1))
hours = 0
minutes = 0
h_match = re.search(r"(\d+)\s*h", duration_str)
m_match = re.search(r"(\d+)\s*m(?!in)", duration_str)
if h_match or m_match:
if h_match:
hours = int(h_match.group(1))
if m_match:
minutes = int(m_match.group(1))
return hours * 60 + minutes
# Try "H:MM" format
match = re.match(r"(\d+):(\d{2})", duration_str)
if match:
return int(match.group(1)) * 60 + int(match.group(2))
# Try pure minutes
match = re.match(r"(\d+)", duration_str)
if match:
return int(match.group(1))
msg = f"Invalid duration format: {duration_str}"
raise ValueError(msg)
def parse_manual_line(line: str) -> Movie | None:
"""Parse a manual format line like 'Movie A, 18:20 or 20:50, 1h 46m'."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split(",")
if len(parts) < _MIN_MANUAL_LINE_PARTS:
msg = f"Invalid line format: {line}"
raise ValueError(msg)
movie = parts[0].strip()
times_str = parts[1].strip()
duration_str = ",".join(parts[2:]).strip()
start_times = [
parse_time(time_part)
for time_part in re.split(r"\s+or\s+", times_str, flags=re.IGNORECASE)
]
duration = parse_duration(duration_str)
return Movie(movie, start_times, duration)
def _try_parse_time(time_str: str) -> int | None:
"""Try to parse a time string, returning None on failure."""
try:
return parse_time(time_str)
except ValueError:
return None
def _try_parse_manual_line(
line: str,
error_stream: TextIO | None = None,
) -> Movie | None:
"""Try to parse a manual line, writing errors to error_stream."""
try:
return parse_manual_line(line)
except ValueError as e:
if error_stream is not None:
error_stream.write(f"Warning: {e}\n")
return None
def _try_parse_interactive_line(line: str) -> Movie | None:
"""Try to parse a line in interactive mode, logging errors."""
try:
result = parse_manual_line(line)
except ValueError:
logger.exception(" Error parsing input")
return None
if result:
logger.info(" Added: %s", result.name)
return result
def extract_date_from_html(content: str) -> str | None:
"""Extract schedule date from Cinema City HTML."""
# Look for date in YYYY-MM-DD format
match = re.search(r"(202\d-\d{2}-\d{2})", content)
if match:
return match.group(1)
return None
def parse_cinema_city_html(
filepath: str,
) -> tuple[list[Movie], str | None]:
"""Parse Cinema City HTML schedule.
Returns:
Tuple of (movies, date).
"""
with Path(filepath).open(encoding="utf-8") as f:
content = f.read()
movies: list[Movie] = []
schedule_date = extract_date_from_html(content)
# Split content by movie sections
sections = re.split(r'class="row movie-row', content)
for section in sections[1:]: # Skip first (before any movie)
# Get movie name
name_match = re.search(r'qb-movie-name">([^<]+)<', section)
if not name_match:
continue
movie_name = name_match.group(1).strip()
# Get genres
genre_match = re.search(
r'class="mr-sm"[^>]*>([^<]+)<\s*span', section
)
genres: list[str] = []
if genre_match:
genre_text = genre_match.group(1).strip()
genres = [
g.strip() for g in genre_text.split(",") if g.strip()
]
# Get duration
duration_match = re.search(r"(\d+)\s*min", section)
if not duration_match:
continue
duration = int(duration_match.group(1))
# Get screening times - look for time buttons
times = re.findall(
r'btn btn-primary btn-lg">\s*(\d{2}:\d{2})\s*<', section
)
if not times:
# Try alternate pattern
times = re.findall(
r">\s*(\d{2}:\d{2})\s*\(HTTPS://", section
)
if times:
start_times = list(dict.fromkeys(
parse_time(t) for t in times
))
movies.append(
Movie(movie_name, start_times, duration, genres),
)
# Deduplicate movies (same movie might appear multiple times)
seen: set[str] = set()
unique_movies: list[Movie] = []
for movie in movies:
if movie.name not in seen:
seen.add(movie.name)
unique_movies.append(movie)
return unique_movies, schedule_date
def parse_cinema_city_pdf(filepath: str) -> list[Movie]:
"""Parse Cinema City PDF schedule by extracting text."""
if _pdfplumber is not None:
with _pdfplumber.open(filepath) as pdf:
full_text = ""
for page in pdf.pages:
text = page.extract_text()
if text:
full_text += text + "\n"
return parse_cinema_city_text(full_text)
return _parse_cinema_city_pdf_basic(filepath)
def _parse_cinema_city_pdf_basic(filepath: str) -> list[Movie]:
"""Basic PDF parsing using PyMuPDF or falling back to subprocess."""
if _fitz is not None:
doc = _fitz.open(filepath)
full_text = ""
for page in doc:
full_text += page.get_text() + "\n"
doc.close()
return parse_cinema_city_text(full_text)
pdftotext_path = shutil.which("pdftotext")
if pdftotext_path is None:
_exit_no_pdf_support()
try:
result = subprocess.run(
[pdftotext_path, "-layout", filepath, "-"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError:
_exit_no_pdf_support()
return parse_cinema_city_text(result.stdout)
def _exit_no_pdf_support() -> None:
"""Log PDF support error and exit."""
logger.error(
"Install pdfplumber, PyMuPDF, or poppler-utils for PDF support"
)
logger.error(" pip install pdfplumber")
logger.error(" pip install pymupdf")
logger.error(" pacman -S poppler")
sys.exit(1)
def parse_cinema_city_text(text: str) -> list[Movie]:
"""Parse Cinema City schedule from extracted text."""
movies: list[Movie] = []
lines = text.split("\n")
current_movie: str | None = None
current_duration: int | None = None
current_times: list[int] = []
# Patterns for movie titles (all caps, usually)
movie_title_pattern = re.compile(
r"^([A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9\s:,\.\-\!\?\(\)]+)$"
)
duration_pattern = re.compile(r"(\d+)\s*min")
time_pattern = re.compile(r"\b(\d{1,2}:\d{2})\b")
for i, raw_line in enumerate(lines):
line = raw_line.strip()
if (
movie_title_pattern.match(line)
and len(line) > _MIN_TITLE_LENGTH
):
if current_movie and current_times:
movies.append(Movie(
current_movie,
list(dict.fromkeys(current_times)),
current_duration or _DEFAULT_MOVIE_DURATION,
))
current_movie = line.title()
current_times = []
current_duration = None
# Look ahead for duration
end = min(i + _TITLE_LOOKAHEAD_LINES, len(lines))
for j in range(i + 1, end):
dur_match = duration_pattern.search(lines[j])
if dur_match:
current_duration = int(dur_match.group(1))
break
if current_movie:
times_in_line = time_pattern.findall(line)
for t in times_in_line:
parsed = _try_parse_time(t)
if parsed is not None:
current_times.append(parsed)
# Save last movie
if current_movie and current_times:
movies.append(Movie(
current_movie,
list(dict.fromkeys(current_times)),
current_duration or _DEFAULT_MOVIE_DURATION,
))
return movies
def find_best_schedule(
movies: list[Movie],
buffer: int,
) -> list[list[Screening]]:
"""Find ALL schedules that maximize number of movies watched."""
movie_screenings: list[list[Screening]] = [
[
Screening(movie.name, start, start + movie.duration)
for start in movie.start_times
]
for movie in movies
]
best_count = 0
all_best_schedules: list[list[Screening]] = []
def _backtrack(
movie_idx: int,
current_schedule: list[Screening],
) -> None:
nonlocal best_count, all_best_schedules
if movie_idx == len(movie_screenings):
if len(current_schedule) > best_count:
best_count = len(current_schedule)
all_best_schedules = [current_schedule.copy()]
elif (
len(current_schedule) == best_count
and best_count > 0
):
all_best_schedules.append(current_schedule.copy())
return
# Pruning: can't beat the best
remaining = len(movie_screenings) - movie_idx
if len(current_schedule) + remaining < best_count:
return
# Try each screening of current movie
for screening in movie_screenings[movie_idx]:
conflicts = any(
screening.overlaps(s, buffer)
for s in current_schedule
)
if not conflicts:
current_schedule.append(screening)
_backtrack(movie_idx + 1, current_schedule)
current_schedule.pop()
# Also try skipping this movie
_backtrack(movie_idx + 1, current_schedule)
_backtrack(0, [])
# Sort each schedule by start time and return
return [
sorted(schedule, key=lambda s: s.start)
for schedule in all_best_schedules
]
def _format_single_schedule(
schedule: list[Screening],
output: TextIO,
) -> None:
"""Format a single schedule to the output stream."""
for i, screening in enumerate(schedule, 1):
duration = screening.end - screening.start
hours, mins = divmod(duration, 60)
actual_start = screening.start + ADS_DURATION
actual_start_str = (
f"{actual_start // 60:02d}:{actual_start % 60:02d}"
)
output.write(
f" {i}. {screening.start_str()} - "
f"{screening.end_str()} {screening.movie}\n"
)
output.write(
f" Duration: {hours}h {mins}m "
f"(movie starts ~{actual_start_str})\n"
)
if i < len(schedule):
gap = schedule[i].start - screening.end
if gap > 0:
output.write(f" [{gap} min break]\n")
output.write("\n")
def _format_schedules(
schedules: list[list[Screening]],
all_movies: list[str],
date: str | None = None,
max_display: int = 5,
*,
output: TextIO | None = None,
) -> None:
"""Format optimal schedules to the output stream."""
if output is None:
output = sys.stdout
sep = "=" * _SEPARATOR_WIDTH
thin_sep = "\u2500" * _SEPARATOR_WIDTH
if not schedules or not schedules[0]:
output.write("No movies can be scheduled!\n")
return
num_movies = len(schedules[0])
num_schedules = len(schedules)
output.write(f"\n{sep}\n")
if date:
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
else:
output.write(" OPTIMAL CINEMA SCHEDULES\n")
output.write(
f" {num_movies} movies, "
f"{num_schedules} possible combination(s)\n"
)
output.write(f"{sep}\n\n")
display_count = min(num_schedules, max_display)
for idx, schedule in enumerate(schedules[:display_count], 1):
if num_schedules > 1:
output.write(f"{thin_sep}\n")
output.write(f" OPTION {idx}:\n")
output.write(f"{thin_sep}\n\n")
_format_single_schedule(schedule, output)
if num_schedules > display_count:
output.write(f"{thin_sep}\n")
output.write(
f" ... and {num_schedules - display_count} "
"more combinations\n"
)
output.write(" (use -n to show more, e.g., -n 10)\n")
output.write("\n")
# Show skipped movies (from first schedule as reference)
scheduled_movies = {s.movie for s in schedules[0]}
skipped = [m for m in all_movies if m not in scheduled_movies]
if skipped and num_schedules == 1:
output.write(f"{thin_sep}\n")
output.write(f" Skipped movies ({len(skipped)}):\n")
for movie in skipped:
output.write(f" - {movie}\n")
output.write("\n")
def _format_all_movies(
movies: list[Movie],
date: str | None = None,
*,
output: TextIO | None = None,
) -> None:
"""Format all parsed movies to the output stream."""
if output is None:
output = sys.stdout
thin_sep = "\u2500" * _SEPARATOR_WIDTH
output.write(f"\n{thin_sep}\n")
if date:
output.write(f" Parsed {len(movies)} movies for {date}:\n")
else:
output.write(f" Parsed {len(movies)} movies:\n")
output.write(f"{thin_sep}\n")
for movie in movies:
times_str = ", ".join(
f"{t // 60:02d}:{t % 60:02d}"
for t in sorted(movie.start_times)
)
genre_str = (
f" [{', '.join(movie.genres)}]" if movie.genres else ""
)
output.write(
f" {movie.name} ({movie.duration} min){genre_str}\n"
)
output.write(f" Times: {times_str}\n")
output.write("\n")
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the cinema planner."""
parser = argparse.ArgumentParser(
description=(
"Plan your cinema day to watch "
"as many movies as possible."
),
description=("Plan your cinema day to watch " "as many movies as possible."),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Supports Cinema City HTML/PDF schedules (auto-detected).
@ -590,9 +57,7 @@ Example:
The Matrix, 12:00 or 16:45, 2h 16m
""",
)
parser.add_argument(
"input_file", nargs="?", help="Input file (HTML/PDF/TXT)"
)
parser.add_argument("input_file", nargs="?", help="Input file (HTML/PDF/TXT)")
parser.add_argument(
"-b",
"--buffer",
@ -714,14 +179,8 @@ def _filter_movies(
) -> tuple[list[Movie], set[str]]:
"""Apply name and genre filters to movies."""
if args.select:
select_terms = [
t.strip().lower() for t in args.select.split(",")
]
movies = [
m
for m in movies
if any(t in m.name.lower() for t in select_terms)
]
select_terms = [t.strip().lower() for t in args.select.split(",")]
movies = [m for m in movies if any(t in m.name.lower() for t in select_terms)]
logger.info(
"Selected %d movies matching: %s",
len(movies),
@ -729,13 +188,9 @@ def _filter_movies(
)
if args.exclude:
exclude_terms = [
t.strip().lower() for t in args.exclude.split(",")
]
exclude_terms = [t.strip().lower() for t in args.exclude.split(",")]
movies = [
m
for m in movies
if not any(t in m.name.lower() for t in exclude_terms)
m for m in movies if not any(t in m.name.lower() for t in exclude_terms)
]
logger.info("After name exclusion: %d movies", len(movies))
@ -743,18 +198,12 @@ def _filter_movies(
if not args.all_genres:
excluded_genres.update(DEFAULT_EXCLUDED_GENRES)
if args.exclude_genre:
excluded_genres.update(
g.strip().lower() for g in args.exclude_genre.split(",")
)
excluded_genres.update(g.strip().lower() for g in args.exclude_genre.split(","))
if excluded_genres:
before_count = len(movies)
movies = [
m
for m in movies
if not any(
g.lower() in excluded_genres for g in m.genres
)
m for m in movies if not any(g.lower() in excluded_genres for g in m.genres)
]
filtered_count = before_count - len(movies)
if filtered_count > 0:
@ -776,10 +225,7 @@ def _apply_must_watch_filter(
filtered = [
s
for s in schedules
if any(
must_watch_lower in screening.movie.lower()
for screening in s
)
if any(must_watch_lower in screening.movie.lower() for screening in s)
]
if filtered:
logger.info(
@ -789,9 +235,7 @@ def _apply_must_watch_filter(
)
return filtered
logger.warning(
"No optimal schedules contain '%s'", must_watch
)
logger.warning("No optimal schedules contain '%s'", must_watch)
logger.warning("Showing all schedules instead.")
return schedules
@ -822,16 +266,11 @@ def _output_schedules(
else Path(f"cinema_plan_{schedule_date}.txt")
)
with output_file.open("w") as f:
f.write(
f"Generated: {schedule_date or 'unknown date'}\n"
)
f.write(f"Generated: {schedule_date or 'unknown date'}\n")
f.write(f"Movies considered: {len(all_movie_names)}\n")
f.write(f"Buffer time: {args.buffer} minutes\n")
if excluded_genres:
f.write(
"Excluded genres: "
f"{', '.join(sorted(excluded_genres))}\n"
)
f.write("Excluded genres: " f"{', '.join(sorted(excluded_genres))}\n")
f.write(schedule_output)
logger.info("Schedule saved to: %s", output_file)
@ -865,20 +304,14 @@ def main() -> None:
_format_all_movies(movies, schedule_date)
return
logger.info(
"\nOptimizing schedule for %d movies...", len(movies)
)
logger.info(
"Buffer time between movies: %d minutes", args.buffer
)
logger.info("\nOptimizing schedule for %d movies...", len(movies))
logger.info("Buffer time between movies: %d minutes", args.buffer)
schedules = find_best_schedule(movies, args.buffer)
all_movie_names = [m.name for m in movies]
if args.must_watch:
schedules = _apply_must_watch_filter(
schedules, args.must_watch
)
schedules = _apply_must_watch_filter(schedules, args.must_watch)
_output_schedules(
schedules,

View File

@ -0,0 +1,88 @@
"""Dictionary loading for the keyboard cooperative word game."""
from __future__ import annotations
import json
import logging
from pathlib import Path
_logger = logging.getLogger(__name__)
_FALLBACK_DICTIONARY = {
"cat",
"dog",
"car",
"bat",
"rat",
"hat",
"mat",
"sat",
"fat",
"pat",
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"her",
"was",
"one",
"our",
"out",
"day",
"get",
"has",
"him",
"his",
"how",
"man",
"new",
"now",
"old",
"see",
"two",
"way",
"who",
"boy",
"work",
"know",
"place",
"year",
"live",
"me",
"back",
"give",
"good",
}
def load_dictionary(dictionary_dir: Path) -> set[str]:
"""Load dictionary from words_dictionary.json file.
Args:
dictionary_dir: Directory containing words_dictionary.json.
Returns:
Set of valid English words.
"""
try:
dictionary_path = dictionary_dir / "words_dictionary.json"
with dictionary_path.open(encoding="utf-8") as f:
dictionary_data = json.load(f)
# Convert to set for faster lookup (we only need the keys)
return set(dictionary_data.keys())
except FileNotFoundError:
_logger.warning(
"words_dictionary.json not found, using fallback dictionary"
)
return set(_FALLBACK_DICTIONARY)
except json.JSONDecodeError:
_logger.warning(
"Error reading words_dictionary.json, using fallback dictionary"
)
return set(_FALLBACK_DICTIONARY)

View File

@ -4,7 +4,6 @@ Players take turns selecting adjacent keys to form valid English words.
"""
from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
import secrets
@ -12,6 +11,8 @@ import sys
import pygame
from python_pkg.keyboard_coop._dictionary import load_dictionary
_logger = logging.getLogger(__name__)
# Use cryptographically secure random number generator
@ -116,7 +117,7 @@ class KeyboardCoopGame:
)
# Load dictionary
self.dictionary = self._load_dictionary()
self.dictionary = load_dictionary(Path(__file__).parent)
# Initialize game state
self.state = GameState()
@ -127,106 +128,6 @@ class KeyboardCoopGame:
# Generate random keyboard layout and adjacency
self._generate_random_keyboard()
def _load_dictionary(self) -> set[str]:
"""Load dictionary from words_dictionary.json file."""
try:
dictionary_path = Path(__file__).parent / "words_dictionary.json"
with dictionary_path.open(encoding="utf-8") as f:
dictionary_data = json.load(f)
# Convert to set for faster lookup (we only need the keys)
return set(dictionary_data.keys())
except FileNotFoundError:
_logger.warning(
"words_dictionary.json not found, using fallback dictionary"
)
# Fallback to a smaller dictionary if file not found
return {
"cat",
"dog",
"car",
"bat",
"rat",
"hat",
"mat",
"sat",
"fat",
"pat",
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"her",
"was",
"one",
"our",
"out",
"day",
"get",
"has",
"him",
"his",
"how",
"man",
"new",
"now",
"old",
"see",
"two",
"way",
"who",
"boy",
"work",
"know",
"place",
"year",
"live",
"me",
"back",
"give",
"good",
}
except json.JSONDecodeError:
_logger.warning(
"Error reading words_dictionary.json, using fallback dictionary"
)
return {
"cat",
"dog",
"car",
"bat",
"rat",
"hat",
"mat",
"sat",
"fat",
"pat",
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"work",
"know",
"place",
"year",
"live",
"me",
"back",
"give",
"good",
}
def _generate_random_keyboard(self) -> None:
"""Generate a random keyboard layout and calculate adjacencies."""
# All 26 letters

View File

@ -0,0 +1,293 @@
"""Game logic and challenge handling helpers for the Lichess bot."""
from __future__ import annotations
import contextlib
import datetime
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import chess
import chess.pgn
import requests
if TYPE_CHECKING:
from python_pkg.lichess_bot.lichess_api import LichessAPI
from python_pkg.lichess_bot.main import BotContext, GameMeta, GameState
_logger = logging.getLogger(__name__)
def _update_clocks_from_state(state_data: dict[str, object], state: GameState) -> None:
"""Update clock values from state data."""
wtime = state_data.get("wtime")
btime = state_data.get("btime")
if state.color == "white":
state.my_ms = int(wtime) if isinstance(wtime, int | float) else None
state.opp_ms = int(btime) if isinstance(btime, int | float) else None
else:
state.my_ms = int(btime) if isinstance(btime, int | float) else None
state.opp_ms = int(wtime) if isinstance(wtime, int | float) else None
inc = state_data.get("winc") or state_data.get("binc")
state.inc_ms = int(inc) if isinstance(inc, int | float) else 0
def _extract_player_info(
event: dict[str, object], state: GameState, meta: GameMeta, api: LichessAPI
) -> None:
"""Extract player info and determine color."""
white_data = event.get("white", {})
black_data = event.get("black", {})
if not isinstance(white_data, dict) or not isinstance(black_data, dict):
return
white_id = white_data.get("id")
black_id = black_data.get("id")
meta.white_name = str(white_data.get("name") or white_id or "?")
meta.black_name = str(black_data.get("name") or black_id or "?")
me = api.get_my_user_id()
if me == white_id:
state.color = "white"
elif me == black_id:
state.color = "black"
def _extract_game_full_data(
event: dict[str, object],
state: GameState,
meta: GameMeta,
api: LichessAPI,
) -> tuple[str, str | None]:
"""Extract data from a gameFull event.
Returns:
Tuple of (moves_string, status).
"""
state_data = event.get("state", {})
if not isinstance(state_data, dict):
state_data = {}
moves = str(state_data.get("moves", ""))
status = state_data.get("status")
_update_clocks_from_state(state_data, state)
_extract_player_info(event, state, meta, api)
# Extract date
with contextlib.suppress(Exception):
created_ms = event.get("createdAt") or event.get("createdAtDate")
if created_ms is not None:
meta.date_iso = datetime.datetime.fromtimestamp(
int(str(created_ms)) / 1000,
tz=datetime.timezone.utc,
).strftime("%Y.%m.%d")
meta.site_url = f"https://lichess.org/{meta.game_id}"
return moves, str(status) if status else None
def _extract_game_state_data(
event: dict[str, object], state: GameState
) -> tuple[str, str | None]:
"""Extract data from a gameState event.
Returns:
Tuple of (moves_string, status).
"""
moves = str(event.get("moves", ""))
status = event.get("status")
# Update clocks based on color
if state.color == "white":
state.my_ms = event.get("wtime", state.my_ms) # type: ignore[assignment]
state.opp_ms = event.get("btime", state.opp_ms) # type: ignore[assignment]
state.inc_ms = event.get("winc", state.inc_ms) # type: ignore[assignment]
elif state.color == "black":
state.my_ms = event.get("btime", state.my_ms) # type: ignore[assignment]
state.opp_ms = event.get("wtime", state.opp_ms) # type: ignore[assignment]
state.inc_ms = event.get("binc", state.inc_ms) # type: ignore[assignment]
return moves, str(status) if status else None
def _calculate_time_budget(
state: GameState, board: chess.Board, max_time_sec: float
) -> float:
"""Calculate time budget for the next move."""
est_moves_left = max(10, min(60, 30 - board.fullmove_number // 2))
time_left_sec = (state.my_ms or 0) / 1000.0
inc_sec = (state.inc_ms or 0) / 1000.0
budget = 0.6 * (time_left_sec / max(1, est_moves_left)) + 0.5 * inc_sec
# Double the budget for more thoughtful moves
budget *= 2.0
return max(0.05, min(max_time_sec, budget))
def _log_move_to_file(
log_path: Path | None, ply: int, move: chess.Move, reason: str
) -> None:
"""Log a move to the game log file."""
if log_path:
with log_path.open("a") as lf:
lf.write(f"ply {ply}: {move.uci()}\n{reason}\n\n")
def _attempt_move(
ctx: BotContext,
state: GameState,
meta: GameMeta,
board: chess.Board,
) -> bool:
"""Attempt to make a move. Returns True if game should continue."""
budget = _calculate_time_budget(state, board, ctx.engine.max_time_sec)
move, reason = ctx.engine.choose_move_with_explanation(
board, time_budget_sec=budget
)
if move is None:
_logger.info("Game %s: no legal moves (game likely over)", meta.game_id)
return False
time_left_sec = (state.my_ms or 0) / 1000.0
inc_sec = (state.inc_ms or 0) / 1000.0
try:
if move not in board.legal_moves:
_logger.info(
"Game %s: selected move no longer legal; skipping send", meta.game_id
)
else:
_logger.info(
"Game %s: playing %s (budget=%.2fs, my_time_left=%.1fs, inc=%.2fs)",
meta.game_id,
move.uci(),
budget,
time_left_sec,
inc_sec,
)
_log_move_to_file(state.log_path, state.last_handled_len + 1, move, reason)
ctx.api.make_move(meta.game_id, move)
except requests.RequestException as e:
_logger.warning("Game %s: move %s failed: %s", meta.game_id, move.uci(), e)
return True
def _is_my_turn(board: chess.Board, color: str | None) -> bool:
"""Check if it's our turn to move."""
is_white_turn = board.turn
return (is_white_turn and color == "white") or (
(not is_white_turn) and color == "black"
)
def _handle_move_if_needed(
ctx: BotContext,
state: GameState,
meta: GameMeta,
et: str,
new_len: int,
) -> bool:
"""Handle making a move if it's our turn. Returns False if game ends."""
my_turn = _is_my_turn(state.board, state.color)
turn_str = "white" if state.board.turn else "black"
_logger.info("Game %s: turn=%s, my_turn=%s", meta.game_id, turn_str, my_turn)
# Move policy
allow_move = (et == "gameState") or (et == "gameFull" and not new_len)
if my_turn and allow_move and not _attempt_move(ctx, state, meta, state.board):
return False
# Mark position as handled
if et == "gameState" or (my_turn and allow_move):
state.last_handled_len = new_len
return True
def _handle_challenge(
challenge: dict[str, object], api: LichessAPI, *, decline_correspondence: bool
) -> None:
"""Handle an incoming challenge."""
ch_id = challenge.get("id", "")
variant_data = challenge.get("variant", {})
variant = (
variant_data.get("key", "standard")
if isinstance(variant_data, dict)
else "standard"
)
speed = challenge.get("speed")
perf_ok = speed in {"bullet", "blitz", "rapid", "classical"}
not_corr = speed != "correspondence" or not decline_correspondence
if variant == "standard" and perf_ok and not_corr:
_logger.info("Accepting challenge %s (%s)", ch_id, speed)
api.accept_challenge(str(ch_id))
else:
_logger.info(
"Declining challenge %s (variant=%s, speed=%s)", ch_id, variant, speed
)
api.decline_challenge(str(ch_id))
def _write_pgn_to_log(log_path: Path, board: chess.Board, meta: GameMeta) -> None:
"""Write PGN to the game log file."""
game = chess.pgn.Game.from_board(board)
with contextlib.suppress(Exception):
game.headers["BotVersion"] = f"v{meta.bot_version}"
if meta.site_url:
game.headers["Site"] = meta.site_url
if meta.date_iso:
game.headers["Date"] = meta.date_iso
if meta.white_name:
game.headers["White"] = meta.white_name
if meta.black_name:
game.headers["Black"] = meta.black_name
with log_path.open("a") as lf:
lf.write("\nPGN:\n")
exporter = chess.pgn.StringExporter(
headers=True, variations=False, comments=False
)
lf.write(game.accept(exporter))
lf.write("\n")
def _insert_analysis_into_log(
log_path: Path, analysis_text: str, meta: GameMeta
) -> None:
"""Insert analysis text into the log file before PGN section."""
try:
with log_path.open(encoding="utf-8", errors="replace") as f:
content = f.read()
# Find insertion point (before PGN)
insert_idx = 0
p = content.find("\nPGN:\n")
if p != -1:
insert_idx = p + 1
elif content.startswith("PGN:\n"):
insert_idx = 0
else:
insert_idx = len(content)
# Build meta block
meta_lines = []
if meta.date_iso:
meta_lines.append(f"Date: {meta.date_iso}")
if meta.white_name or meta.black_name:
meta_lines.append(
f"Players: {meta.white_name or '?'} vs {meta.black_name or '?'}"
)
meta_block = "\n".join(meta_lines) + "\n" if meta_lines else ""
analysis_block = f"{meta_block}ANALYSIS:\n{analysis_text.rstrip()}\n\n"
new_content = content[:insert_idx] + analysis_block + content[insert_idx:]
with log_path.open("w", encoding="utf-8") as f:
f.write(new_content)
except OSError as e:
_logger.debug("Game %s: could not write analysis to log: %s", meta.game_id, e)

View File

@ -3,9 +3,7 @@
from __future__ import annotations
import argparse
import contextlib
from dataclasses import dataclass, field
import datetime
import json
import logging
import os
@ -17,9 +15,22 @@ import threading
from typing import TYPE_CHECKING
import chess
import chess.pgn
import requests
from python_pkg.lichess_bot._game_logic import (
_attempt_move,
_calculate_time_budget,
_extract_game_full_data,
_extract_game_state_data,
_extract_player_info,
_handle_challenge,
_handle_move_if_needed,
_insert_analysis_into_log,
_is_my_turn,
_log_move_to_file,
_update_clocks_from_state,
_write_pgn_to_log,
)
from python_pkg.lichess_bot.engine import RandomEngine
from python_pkg.lichess_bot.lichess_api import LichessAPI
from python_pkg.lichess_bot.utils import backoff_sleep, get_and_increment_version
@ -91,168 +102,6 @@ def _init_game_log(game_id: str, bot_version: int) -> Path | None:
return game_log_path
def _update_clocks_from_state(state_data: dict[str, object], state: GameState) -> None:
"""Update clock values from state data."""
wtime = state_data.get("wtime")
btime = state_data.get("btime")
if state.color == "white":
state.my_ms = int(wtime) if isinstance(wtime, int | float) else None
state.opp_ms = int(btime) if isinstance(btime, int | float) else None
else:
state.my_ms = int(btime) if isinstance(btime, int | float) else None
state.opp_ms = int(wtime) if isinstance(wtime, int | float) else None
inc = state_data.get("winc") or state_data.get("binc")
state.inc_ms = int(inc) if isinstance(inc, int | float) else 0
def _extract_player_info(
event: dict[str, object], state: GameState, meta: GameMeta, api: LichessAPI
) -> None:
"""Extract player info and determine color."""
white_data = event.get("white", {})
black_data = event.get("black", {})
if not isinstance(white_data, dict) or not isinstance(black_data, dict):
return
white_id = white_data.get("id")
black_id = black_data.get("id")
meta.white_name = str(white_data.get("name") or white_id or "?")
meta.black_name = str(black_data.get("name") or black_id or "?")
me = api.get_my_user_id()
if me == white_id:
state.color = "white"
elif me == black_id:
state.color = "black"
def _extract_game_full_data(
event: dict[str, object],
state: GameState,
meta: GameMeta,
api: LichessAPI,
) -> tuple[str, str | None]:
"""Extract data from a gameFull event.
Returns:
Tuple of (moves_string, status).
"""
state_data = event.get("state", {})
if not isinstance(state_data, dict):
state_data = {}
moves = str(state_data.get("moves", ""))
status = state_data.get("status")
_update_clocks_from_state(state_data, state)
_extract_player_info(event, state, meta, api)
# Extract date
with contextlib.suppress(Exception):
created_ms = event.get("createdAt") or event.get("createdAtDate")
if created_ms is not None:
meta.date_iso = datetime.datetime.fromtimestamp(
int(str(created_ms)) / 1000,
tz=datetime.timezone.utc,
).strftime("%Y.%m.%d")
meta.site_url = f"https://lichess.org/{meta.game_id}"
return moves, str(status) if status else None
def _extract_game_state_data(
event: dict[str, object], state: GameState
) -> tuple[str, str | None]:
"""Extract data from a gameState event.
Returns:
Tuple of (moves_string, status).
"""
moves = str(event.get("moves", ""))
status = event.get("status")
# Update clocks based on color
if state.color == "white":
state.my_ms = event.get("wtime", state.my_ms) # type: ignore[assignment]
state.opp_ms = event.get("btime", state.opp_ms) # type: ignore[assignment]
state.inc_ms = event.get("winc", state.inc_ms) # type: ignore[assignment]
elif state.color == "black":
state.my_ms = event.get("btime", state.my_ms) # type: ignore[assignment]
state.opp_ms = event.get("wtime", state.opp_ms) # type: ignore[assignment]
state.inc_ms = event.get("binc", state.inc_ms) # type: ignore[assignment]
return moves, str(status) if status else None
def _calculate_time_budget(
state: GameState, board: chess.Board, max_time_sec: float
) -> float:
"""Calculate time budget for the next move."""
est_moves_left = max(10, min(60, 30 - board.fullmove_number // 2))
time_left_sec = (state.my_ms or 0) / 1000.0
inc_sec = (state.inc_ms or 0) / 1000.0
budget = 0.6 * (time_left_sec / max(1, est_moves_left)) + 0.5 * inc_sec
# Double the budget for more thoughtful moves
budget *= 2.0
return max(0.05, min(max_time_sec, budget))
def _log_move_to_file(
log_path: Path | None, ply: int, move: chess.Move, reason: str
) -> None:
"""Log a move to the game log file."""
if log_path:
with log_path.open("a") as lf:
lf.write(f"ply {ply}: {move.uci()}\n{reason}\n\n")
def _attempt_move(
ctx: BotContext,
state: GameState,
meta: GameMeta,
board: chess.Board,
) -> bool:
"""Attempt to make a move. Returns True if game should continue."""
budget = _calculate_time_budget(state, board, ctx.engine.max_time_sec)
move, reason = ctx.engine.choose_move_with_explanation(
board, time_budget_sec=budget
)
if move is None:
_logger.info("Game %s: no legal moves (game likely over)", meta.game_id)
return False
time_left_sec = (state.my_ms or 0) / 1000.0
inc_sec = (state.inc_ms or 0) / 1000.0
try:
if move not in board.legal_moves:
_logger.info(
"Game %s: selected move no longer legal; skipping send", meta.game_id
)
else:
_logger.info(
"Game %s: playing %s (budget=%.2fs, my_time_left=%.1fs, inc=%.2fs)",
meta.game_id,
move.uci(),
budget,
time_left_sec,
inc_sec,
)
_log_move_to_file(state.log_path, state.last_handled_len + 1, move, reason)
ctx.api.make_move(meta.game_id, move)
except requests.RequestException as e:
_logger.warning("Game %s: move %s failed: %s", meta.game_id, move.uci(), e)
return True
def _is_my_turn(board: chess.Board, color: str | None) -> bool:
"""Check if it's our turn to move."""
is_white_turn = board.turn
return (is_white_turn and color == "white") or (
(not is_white_turn) and color == "black"
)
def _rebuild_board_from_moves(moves_list: list[str], game_id: str) -> chess.Board:
"""Rebuild board from list of moves."""
board = chess.Board()
@ -261,31 +110,6 @@ def _rebuild_board_from_moves(moves_list: list[str], game_id: str) -> chess.Boar
return board
def _handle_move_if_needed(
ctx: BotContext,
state: GameState,
meta: GameMeta,
et: str,
new_len: int,
) -> bool:
"""Handle making a move if it's our turn. Returns False if game ends."""
my_turn = _is_my_turn(state.board, state.color)
turn_str = "white" if state.board.turn else "black"
_logger.info("Game %s: turn=%s, my_turn=%s", meta.game_id, turn_str, my_turn)
# Move policy
allow_move = (et == "gameState") or (et == "gameFull" and not new_len)
if my_turn and allow_move and not _attempt_move(ctx, state, meta, state.board):
return False
# Mark position as handled
if et == "gameState" or (my_turn and allow_move):
state.last_handled_len = new_len
return True
def _process_game_event(
event: dict[str, object],
ctx: BotContext,
@ -345,29 +169,6 @@ def _process_game_event(
return True
def _write_pgn_to_log(log_path: Path, board: chess.Board, meta: GameMeta) -> None:
"""Write PGN to the game log file."""
game = chess.pgn.Game.from_board(board)
with contextlib.suppress(Exception):
game.headers["BotVersion"] = f"v{meta.bot_version}"
if meta.site_url:
game.headers["Site"] = meta.site_url
if meta.date_iso:
game.headers["Date"] = meta.date_iso
if meta.white_name:
game.headers["White"] = meta.white_name
if meta.black_name:
game.headers["Black"] = meta.black_name
with log_path.open("a") as lf:
lf.write("\nPGN:\n")
exporter = chess.pgn.StringExporter(
headers=True, variations=False, comments=False
)
lf.write(game.accept(exporter))
lf.write("\n")
def _run_analysis_subprocess(
game_id: str, log_path: Path, total_plies: int
) -> str | None:
@ -469,43 +270,6 @@ def _log_analysis_progress(game_id: str, analyzed: int, total_plies: int) -> Non
)
def _insert_analysis_into_log(
log_path: Path, analysis_text: str, meta: GameMeta
) -> None:
"""Insert analysis text into the log file before PGN section."""
try:
with log_path.open(encoding="utf-8", errors="replace") as f:
content = f.read()
# Find insertion point (before PGN)
insert_idx = 0
p = content.find("\nPGN:\n")
if p != -1:
insert_idx = p + 1
elif content.startswith("PGN:\n"):
insert_idx = 0
else:
insert_idx = len(content)
# Build meta block
meta_lines = []
if meta.date_iso:
meta_lines.append(f"Date: {meta.date_iso}")
if meta.white_name or meta.black_name:
meta_lines.append(
f"Players: {meta.white_name or '?'} vs {meta.black_name or '?'}"
)
meta_block = "\n".join(meta_lines) + "\n" if meta_lines else ""
analysis_block = f"{meta_block}ANALYSIS:\n{analysis_text.rstrip()}\n\n"
new_content = content[:insert_idx] + analysis_block + content[insert_idx:]
with log_path.open("w", encoding="utf-8") as f:
f.write(new_content)
except OSError as e:
_logger.debug("Game %s: could not write analysis to log: %s", meta.game_id, e)
def _finalize_game(state: GameState, meta: GameMeta) -> None:
"""Finalize game: write PGN and run analysis."""
if not state.log_path:
@ -573,32 +337,6 @@ def _handle_game(game_id: str, ctx: BotContext, my_color: str | None = None) ->
_logger.info("Ending game thread for %s", game_id)
def _handle_challenge(
challenge: dict[str, object], api: LichessAPI, *, decline_correspondence: bool
) -> None:
"""Handle an incoming challenge."""
ch_id = challenge.get("id", "")
variant_data = challenge.get("variant", {})
variant = (
variant_data.get("key", "standard")
if isinstance(variant_data, dict)
else "standard"
)
speed = challenge.get("speed")
perf_ok = speed in {"bullet", "blitz", "rapid", "classical"}
not_corr = speed != "correspondence" or not decline_correspondence
if variant == "standard" and perf_ok and not_corr:
_logger.info("Accepting challenge %s (%s)", ch_id, speed)
api.accept_challenge(str(ch_id))
else:
_logger.info(
"Declining challenge %s (variant=%s, speed=%s)", ch_id, variant, speed
)
api.decline_challenge(str(ch_id))
def _process_bot_event(
event: dict[str, object],
ctx: BotContext,

View File

@ -473,208 +473,3 @@ class TestGetMyUserId:
user_id = api.get_my_user_id()
assert user_id is None
class TestRequestEdgeCases:
"""Additional tests for _request edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_request_error_with_attribute_error_on_text(self, api: LichessAPI) -> None:
"""Test error response when text property raises AttributeError."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
# Make text property raise AttributeError when accessed
del mock_response.text # Remove the default mock
type(mock_response).text = property(
fget=lambda _self: (_ for _ in ()).throw(AttributeError("no text"))
)
with patch.object(api.session, "request", return_value=mock_response):
result = api._request("GET", "http://test.com")
assert result == mock_response
def test_request_error_with_type_error_on_text(self, api: LichessAPI) -> None:
"""Test error response when text causes TypeError."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
# Make text return something that causes TypeError when sliced
mock_response.text = 12345 # integer can't be sliced with [:200]
with patch.object(api.session, "request", return_value=mock_response):
result = api._request("GET", "http://test.com")
assert result == mock_response
class TestStreamEventsNon429Error:
"""Test stream_events with non-429 HTTP errors."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_stream_events_raises_non_429_error(self, api: LichessAPI) -> None:
"""Test stream_events raises non-429 HTTP errors."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
mock_response.raise_for_status.side_effect = requests.HTTPError(
response=MagicMock(status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
pytest.raises(requests.HTTPError),
):
# Try to get the first event - should raise
next(api.stream_events())
class TestJoinGameStreamEdgeCases:
"""Additional tests for join_game_stream edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_join_game_stream_skips_empty_lines(self, api: LichessAPI) -> None:
"""Test join_game_stream skips empty lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
event = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter(["", "", event])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_skips_invalid_json(self, api: LichessAPI) -> None:
"""Test join_game_stream skips invalid JSON lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
event = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter(["not json", event])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_skips_non_gamefull_events(self, api: LichessAPI) -> None:
"""Test join_game_stream skips non-gameFull events before gameFull."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Emit a non-gameFull event first, then gameFull
non_game_full = json.dumps({"type": "gameState", "moves": "e2e4"})
game_full = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter([non_game_full, game_full])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_no_gamefull_event(self, api: LichessAPI) -> None:
"""Test join_game_stream when stream ends without gameFull event."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Only non-gameFull events, no gameFull - loop exhausts without break
events = [
json.dumps({"type": "gameState", "moves": "e2e4"}),
json.dumps({"type": "chatLine", "text": "hello"}),
]
mock_response.iter_lines.return_value = iter(events)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
board, color = api.join_game_stream("game123", "black")
# When no gameFull is found, returns default/provided color
assert color == "black"
# Board should be empty since no moves were parsed
assert board.fen() == chess.STARTING_FEN
class TestStreamGameEventsEdgeCases:
"""Additional tests for stream_game_events edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_stream_game_events_skips_empty_lines(self, api: LichessAPI) -> None:
"""Test stream_game_events skips empty lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.iter_lines.return_value = iter(
["", '{"type": "gameFull"}', "", '{"type": "gameState"}']
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
events = list(api.stream_game_events("game123"))
assert len(events) == 2
def test_stream_game_events_skips_invalid_json(self, api: LichessAPI) -> None:
"""Test stream_game_events skips invalid JSON lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.iter_lines.return_value = iter(
['{"type": "gameFull"}', "invalid json", '{"type": "gameState"}']
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
events = list(api.stream_game_events("game123"))
assert len(events) == 2

View File

@ -0,0 +1,218 @@
"""Unit tests for lichess_bot lichess_api module (edge cases)."""
from __future__ import annotations
from http import HTTPStatus
import json
from unittest.mock import MagicMock, patch
import chess
import pytest
import requests
from python_pkg.lichess_bot.lichess_api import LichessAPI
class TestRequestEdgeCases:
"""Additional tests for _request edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_request_error_with_attribute_error_on_text(self, api: LichessAPI) -> None:
"""Test error response when text property raises AttributeError."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
# Make text property raise AttributeError when accessed
del mock_response.text # Remove the default mock
type(mock_response).text = property(
fget=lambda _self: (_ for _ in ()).throw(AttributeError("no text"))
)
with patch.object(api.session, "request", return_value=mock_response):
result = api._request("GET", "http://test.com")
assert result == mock_response
def test_request_error_with_type_error_on_text(self, api: LichessAPI) -> None:
"""Test error response when text causes TypeError."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.BAD_REQUEST
# Make text return something that causes TypeError when sliced
mock_response.text = 12345 # integer can't be sliced with [:200]
with patch.object(api.session, "request", return_value=mock_response):
result = api._request("GET", "http://test.com")
assert result == mock_response
class TestStreamEventsNon429Error:
"""Test stream_events with non-429 HTTP errors."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_stream_events_raises_non_429_error(self, api: LichessAPI) -> None:
"""Test stream_events raises non-429 HTTP errors."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
mock_response.raise_for_status.side_effect = requests.HTTPError(
response=MagicMock(status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
pytest.raises(requests.HTTPError),
):
# Try to get the first event - should raise
next(api.stream_events())
class TestJoinGameStreamEdgeCases:
"""Additional tests for join_game_stream edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_join_game_stream_skips_empty_lines(self, api: LichessAPI) -> None:
"""Test join_game_stream skips empty lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
event = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter(["", "", event])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_skips_invalid_json(self, api: LichessAPI) -> None:
"""Test join_game_stream skips invalid JSON lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
event = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter(["not json", event])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_skips_non_gamefull_events(self, api: LichessAPI) -> None:
"""Test join_game_stream skips non-gameFull events before gameFull."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Emit a non-gameFull event first, then gameFull
non_game_full = json.dumps({"type": "gameState", "moves": "e2e4"})
game_full = json.dumps(
{
"type": "gameFull",
"white": {"id": "my_user"},
"black": {"id": "opponent"},
"state": {"moves": ""},
}
)
mock_response.iter_lines.return_value = iter([non_game_full, game_full])
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with (
patch.object(api, "_request", return_value=mock_response),
patch.object(api, "get_my_user_id", return_value="my_user"),
):
__board, color = api.join_game_stream("game123", None)
assert color == "white"
def test_join_game_stream_no_gamefull_event(self, api: LichessAPI) -> None:
"""Test join_game_stream when stream ends without gameFull event."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
# Only non-gameFull events, no gameFull - loop exhausts without break
events = [
json.dumps({"type": "gameState", "moves": "e2e4"}),
json.dumps({"type": "chatLine", "text": "hello"}),
]
mock_response.iter_lines.return_value = iter(events)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
board, color = api.join_game_stream("game123", "black")
# When no gameFull is found, returns default/provided color
assert color == "black"
# Board should be empty since no moves were parsed
assert board.fen() == chess.STARTING_FEN
class TestStreamGameEventsEdgeCases:
"""Additional tests for stream_game_events edge cases."""
@pytest.fixture
def api(self) -> LichessAPI:
"""Create API instance."""
return LichessAPI("test_token")
def test_stream_game_events_skips_empty_lines(self, api: LichessAPI) -> None:
"""Test stream_game_events skips empty lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.iter_lines.return_value = iter(
["", '{"type": "gameFull"}', "", '{"type": "gameState"}']
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
events = list(api.stream_game_events("game123"))
assert len(events) == 2
def test_stream_game_events_skips_invalid_json(self, api: LichessAPI) -> None:
"""Test stream_game_events skips invalid JSON lines."""
mock_response = MagicMock()
mock_response.status_code = HTTPStatus.OK
mock_response.iter_lines.return_value = iter(
['{"type": "gameFull"}', "invalid json", '{"type": "gameState"}']
)
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(api, "_request", return_value=mock_response):
events = list(api.stream_game_events("game123"))
assert len(events) == 2

View File

@ -0,0 +1,378 @@
"""Core MusicGen model loading, device selection, and audio generation."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# VRAM thresholds for model selection (in GB)
VRAM_THRESHOLD_LARGE = 12 # Use large model with 12GB+ VRAM
VRAM_THRESHOLD_MEDIUM = 8 # Use medium model with 8GB+ VRAM
# Generation settings for segmented long audio
SEGMENT_DURATION = 25 # Seconds per segment (under 30s MusicGen limit)
CROSSFADE_DURATION = 2 # Seconds of crossfade between segments
def get_device() -> str:
"""Get the best available device (CUDA or MPS). No CPU fallback for NVIDIA.
Raises:
RuntimeError: If NVIDIA GPU is detected but CUDA is not available.
"""
import torch
# Check for NVIDIA GPU first
nvidia_gpu_present = False
try:
import shutil
import subprocess
nvidia_smi_path = shutil.which("nvidia-smi")
if nvidia_smi_path:
result = subprocess.run(
[nvidia_smi_path],
capture_output=True,
text=True,
check=False,
)
nvidia_gpu_present = result.returncode == 0
except FileNotFoundError:
pass
if nvidia_gpu_present:
if not torch.cuda.is_available():
msg = (
"NVIDIA GPU detected but CUDA is not available!\n"
"Please install PyTorch with CUDA support:\n"
" pip install torch torchaudio --index-url "
"https://download.pytorch.org/whl/cu121"
)
raise RuntimeError(msg)
device = "cuda"
gpu_name = torch.cuda.get_device_name(0)
vram = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"Using CUDA GPU: {gpu_name} ({vram:.1f}GB VRAM)")
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
print("Using Apple Silicon (MPS)")
else:
device = "cpu"
print("Using CPU (this will be slow)")
return device
def get_vram_gb() -> float | None:
"""Get available VRAM in GB. Returns None if no CUDA GPU."""
import torch
if torch.cuda.is_available():
return torch.cuda.get_device_properties(0).total_memory / 1024**3
return None
def select_model_size(user_choice: str | None = None) -> str:
"""Select model size based on user choice or available VRAM.
Args:
user_choice: User's explicit model choice, or None for auto-selection.
Returns:
Model size: 'small', 'medium', or 'large'
"""
if user_choice is not None:
return user_choice
vram = get_vram_gb()
if vram is None:
# No GPU, use medium as a safe default
print("No CUDA GPU detected, defaulting to medium model")
return "medium"
# Select based on VRAM:
# - large: needs ~10GB VRAM (safe with 12GB+)
# - medium: needs ~6GB VRAM (safe with 8GB+)
# - small: needs ~3GB VRAM
if vram >= VRAM_THRESHOLD_LARGE:
selected = "large"
elif vram >= VRAM_THRESHOLD_MEDIUM:
selected = "medium"
else:
selected = "small"
print(f"Auto-selected '{selected}' model based on {vram:.1f}GB VRAM")
return selected
def load_model(
model_size: str = "medium",
) -> tuple[Any, Any]:
"""Load the MusicGen model.
Args:
model_size: One of 'small', 'medium', or 'large'
- small: ~500MB, fastest, lower quality
- medium: ~3.3GB, good balance (recommended)
- large: ~6.5GB, best quality, needs more VRAM
Returns:
Tuple of (model, processor)
"""
from transformers import AutoProcessor, MusicgenForConditionalGeneration
model_name = f"facebook/musicgen-{model_size}"
print(f"\nLoading MusicGen {model_size} model...")
print("(First run will download the model, this may take a while)")
device = get_device()
processor = AutoProcessor.from_pretrained(model_name)
# Use safetensors format to avoid torch.load security issues with older PyTorch
model = MusicgenForConditionalGeneration.from_pretrained(
model_name,
use_safetensors=True,
)
model = model.to(device)
print(f"Model loaded successfully on {device}!")
return model, processor
def crossfade_audio(
audio1: object,
audio2: object,
crossfade_samples: int,
) -> object:
"""Crossfade two audio segments together.
Args:
audio1: First audio segment (numpy array)
audio2: Second audio segment (numpy array)
crossfade_samples: Number of samples to use for crossfade
Returns:
Combined audio with crossfade applied (numpy array)
"""
import numpy as np
if crossfade_samples <= 0 or len(audio1) < crossfade_samples:
return np.concatenate([audio1, audio2])
# Create fade curves
fade_out = np.linspace(1.0, 0.0, crossfade_samples)
fade_in = np.linspace(0.0, 1.0, crossfade_samples)
# Apply fades
audio1_end = audio1[-crossfade_samples:] * fade_out
audio2_start = audio2[:crossfade_samples] * fade_in
# Combine
crossfaded = audio1_end + audio2_start
# Build final audio
return np.concatenate(
[
audio1[:-crossfade_samples],
crossfaded,
audio2[crossfade_samples:],
]
)
def generate_segment(
prompt: str,
model: object,
processor: object,
duration_seconds: int,
device: str,
) -> object:
"""Generate a single audio segment.
Args:
prompt: Text description of the music
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Length of segment to generate
device: Device to generate on
Returns:
Audio data as numpy array
"""
import torch
inputs = processor(
text=[prompt],
padding=True,
return_tensors="pt",
)
inputs = {k: v.to(device) for k, v in inputs.items()}
max_new_tokens = int(duration_seconds * 50)
with torch.no_grad():
audio_values = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
)
return audio_values[0, 0].cpu().numpy()
def _calculate_segment_duration(
segment_index: int,
num_segments: int,
generated_samples: int,
sample_rate: int,
total_duration: int,
) -> int:
"""Calculate duration for a specific segment.
Args:
segment_index: Current segment index
num_segments: Total number of segments
generated_samples: Number of samples generated so far
sample_rate: Audio sample rate
total_duration: Target total duration
Returns:
Duration in seconds for this segment
"""
if segment_index == num_segments - 1:
# Last segment: calculate remaining time
generated_so_far = generated_samples / sample_rate
remaining = total_duration - generated_so_far
min_duration = max(5, int(remaining) + CROSSFADE_DURATION)
return min(SEGMENT_DURATION, min_duration)
return SEGMENT_DURATION
def _generate_long_audio(
prompt: str,
model: object,
processor: object,
duration_seconds: int,
) -> object:
"""Generate long audio by segmenting with crossfades.
Args:
prompt: Text description of the music
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Total duration to generate
Returns:
Audio data as numpy array
"""
import numpy as np
device = str(next(model.parameters()).device)
sample_rate = model.config.audio_encoder.sampling_rate
crossfade_samples = CROSSFADE_DURATION * sample_rate
effective_segment = SEGMENT_DURATION - CROSSFADE_DURATION
total = duration_seconds + effective_segment - 1
num_segments = max(1, total // effective_segment)
print(f"Generating {num_segments} segments of ~{SEGMENT_DURATION}s each...")
audio_data = np.array([], dtype=np.float32)
for i in range(num_segments):
segment_duration = _calculate_segment_duration(
i,
num_segments,
len(audio_data),
sample_rate,
duration_seconds,
)
seg_num = i + 1
msg = f" Segment {seg_num}/{num_segments} ({segment_duration}s)..."
print(msg, end=" ", flush=True)
segment = generate_segment(
prompt,
model,
processor,
segment_duration,
device,
)
if len(audio_data) == 0:
audio_data = segment
else:
audio_data = crossfade_audio(audio_data, segment, crossfade_samples)
print(f"done (total: {len(audio_data) / sample_rate:.1f}s)")
# Trim to exact duration if needed
target_samples = int(duration_seconds * sample_rate)
if len(audio_data) > target_samples:
audio_data = audio_data[:target_samples]
return audio_data
def generate_music(
prompt: str,
model: object,
processor: object,
duration_seconds: int = 10,
output_dir: Path | None = None,
) -> Path:
"""Generate music from a text prompt.
For durations over 30 seconds, generates in segments with crossfading.
Args:
prompt: Text description of the music to generate
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Length of audio to generate (any duration supported)
output_dir: Directory to save output (defaults to ./output)
Returns:
Path to the generated audio file
"""
import scipy.io.wavfile
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
sample_rate = model.config.audio_encoder.sampling_rate
# For short durations, generate directly
if duration_seconds <= SEGMENT_DURATION:
print(f"\nGenerating {duration_seconds}s of music...")
print(f"Prompt: {prompt!r}")
device = str(next(model.parameters()).device)
audio_data = generate_segment(
prompt,
model,
processor,
duration_seconds,
device,
)
else:
# Long duration: generate in segments with crossfading
print(f"\nGenerating {duration_seconds}s of music in segments...")
print(f"Prompt: {prompt!r}")
audio_data = _generate_long_audio(prompt, model, processor, duration_seconds)
# Create filename with timestamp and sanitized prompt
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30])
safe_prompt = safe_prompt.strip().replace(" ", "_")
filename = f"{timestamp}_{safe_prompt}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, sample_rate, audio_data)
print(f"\nSaved to: {output_path}")
print(f"Duration: {len(audio_data) / sample_rate:.1f}s")
return output_path

View File

@ -0,0 +1,380 @@
"""Bark speech synthesis, vocal generation, and song mixing."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from python_pkg.music_gen._music_generation import (
SEGMENT_DURATION,
_generate_long_audio,
generate_segment,
load_model,
select_model_size,
)
BARK_MAX_CHARS = 200 # Max characters per Bark segment (~13s of speech)
# Available Bark voice presets
BARK_VOICES = [
"v2/en_speaker_0",
"v2/en_speaker_1",
"v2/en_speaker_2",
"v2/en_speaker_3",
"v2/en_speaker_4",
"v2/en_speaker_5",
"v2/en_speaker_6",
"v2/en_speaker_7",
"v2/en_speaker_8",
"v2/en_speaker_9",
]
def generate_speech(
text: str,
voice: str = "v2/en_speaker_6",
output_dir: Path | None = None,
) -> Path:
"""Generate speech audio from text using Bark.
Bark supports various speech patterns:
- [laughter], [laughs], [sighs], [music]
- [gasps], [clears throat], or ... for hesitations
- for singing
Args:
text: Text to convert to speech (max ~13s per segment)
voice: Voice preset to use (see BARK_VOICES)
output_dir: Directory to save output (defaults to ./output)
Returns:
Path to the generated audio file
"""
import functools
import numpy as np
import scipy.io.wavfile
import torch
# Bark uses older checkpoint format with pickle
# Monkey-patch torch.load to allow unsafe loading for Bark models
original_torch_load = torch.load
@functools.wraps(original_torch_load)
def patched_load(*args: object, **kwargs: object) -> object:
kwargs.setdefault("weights_only", False)
return original_torch_load(*args, **kwargs)
torch.load = patched_load
try:
from bark import SAMPLE_RATE, generate_audio, preload_models
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
print("\nLoading Bark model...")
print("(First run will download models, ~5GB total)")
preload_models()
print(f"\nGenerating speech with voice: {voice}")
print(f"Text: {text!r}")
# Bark can only generate ~13s at a time
# For longer text, we need to split into sentences
audio_segments = []
# Split on sentence boundaries for longer texts
sentences = _split_into_sentences(text)
for i, sentence in enumerate(sentences):
if len(sentences) > 1:
print(f" Generating segment {i + 1}/{len(sentences)}...")
audio = generate_audio(
sentence.strip(),
history_prompt=voice,
)
audio_segments.append(audio)
# Combine segments
if len(audio_segments) > 1:
audio_data = np.concatenate(audio_segments)
else:
audio_data = audio_segments[0]
# Create filename
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_text = "".join(c if c.isalnum() or c in " -_" else "" for c in text[:30])
safe_text = safe_text.strip().replace(" ", "_")
filename = f"{timestamp}_speech_{safe_text}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, SAMPLE_RATE, audio_data)
print(f"\nSaved to: {output_path}")
print(f"Duration: {len(audio_data) / SAMPLE_RATE:.1f}s")
return output_path
finally:
# Restore original torch.load
torch.load = original_torch_load
def _split_into_sentences(text: str) -> list[str]:
"""Split text into sentences for Bark processing.
Args:
text: Text to split
Returns:
List of sentences
"""
import re
# Split on sentence-ending punctuation followed by space
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
# Group very short sentences together
result = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) < BARK_MAX_CHARS:
current = f"{current} {sentence}".strip()
else:
if current:
result.append(current)
current = sentence
if current:
result.append(current)
return result or [text]
def _resample_audio(
audio: object,
orig_sr: int,
target_sr: int,
) -> object:
"""Resample audio to a different sample rate.
Args:
audio: Audio data as numpy array
orig_sr: Original sample rate
target_sr: Target sample rate
Returns:
Resampled audio data
"""
import numpy as np
from scipy import signal
if orig_sr == target_sr:
return audio
# Calculate the resampling ratio
duration = len(audio) / orig_sr
target_length = int(duration * target_sr)
return signal.resample(audio, target_length).astype(np.float32)
def _mix_audio(
instrumental: object,
vocals: object,
vocal_volume: float = 0.8,
instrumental_volume: float = 0.6,
) -> object:
"""Mix vocals over instrumental track.
Args:
instrumental: Instrumental audio (numpy array)
vocals: Vocal audio (numpy array)
vocal_volume: Volume multiplier for vocals (0.0-1.0)
instrumental_volume: Volume multiplier for instrumental (0.0-1.0)
Returns:
Mixed audio data
"""
import numpy as np
# Ensure same length - pad or trim vocals to match instrumental
if len(vocals) < len(instrumental):
# Pad vocals with silence at the end
vocals = np.pad(vocals, (0, len(instrumental) - len(vocals)))
elif len(vocals) > len(instrumental):
# Trim vocals to match instrumental
vocals = vocals[: len(instrumental)]
# Mix the tracks
mixed = (instrumental * instrumental_volume) + (vocals * vocal_volume)
# Normalize to prevent clipping
max_val = np.max(np.abs(mixed))
if max_val > 1.0:
mixed = mixed / max_val
return mixed.astype(np.float32)
def _generate_vocals_for_song(lyrics: str, voice: str) -> tuple[object, int]:
"""Generate vocals using Bark for song mixing.
Args:
lyrics: Text/lyrics to sing
voice: Bark voice preset
Returns:
Tuple of (vocal audio array, sample rate)
"""
import functools
import numpy as np
import torch
# Patch torch.load for Bark compatibility
original_torch_load = torch.load
@functools.wraps(original_torch_load)
def patched_load(*args: object, **kwargs: object) -> object:
kwargs.setdefault("weights_only", False)
return original_torch_load(*args, **kwargs)
torch.load = patched_load
try:
from bark import SAMPLE_RATE as BARK_SR
from bark import generate_audio, preload_models
print("Loading Bark model...")
preload_models()
print(f"Generating vocals with voice: {voice}")
print(f"Lyrics: {lyrics!r}")
sentences = _split_into_sentences(lyrics)
vocal_segments = []
for i, sentence in enumerate(sentences):
if len(sentences) > 1:
print(f" Vocal segment {i + 1}/{len(sentences)}...")
audio = generate_audio(sentence.strip(), history_prompt=voice)
vocal_segments.append(audio)
if len(vocal_segments) > 1:
vocals = np.concatenate(vocal_segments)
else:
vocals = vocal_segments[0]
return vocals, BARK_SR
finally:
torch.load = original_torch_load
def _generate_instrumental_for_song(
music_prompt: str,
duration: int,
) -> tuple[object, int]:
"""Generate instrumental music using MusicGen for song mixing.
Args:
music_prompt: Description of the music
duration: Duration in seconds
Returns:
Tuple of (instrumental audio array, sample rate)
"""
model_size = select_model_size(None)
model, processor = load_model(model_size)
print(f"Music prompt: {music_prompt!r}")
print(f"Duration: {duration}s")
device = str(next(model.parameters()).device)
sample_rate = model.config.audio_encoder.sampling_rate
if duration <= SEGMENT_DURATION:
instrumental = generate_segment(
music_prompt,
model,
processor,
duration,
device,
)
else:
instrumental = _generate_long_audio(
music_prompt,
model,
processor,
duration,
)
return instrumental, sample_rate
def generate_song(
lyrics: str,
music_prompt: str,
voice: str = "v2/en_speaker_6",
output_dir: Path | None = None,
) -> Path:
"""Generate a complete song with vocals over instrumental music.
This combines Bark for vocals and MusicGen for instrumental backing.
Args:
lyrics: The lyrics/text to sing (use for singing style)
music_prompt: Description of the instrumental music
voice: Bark voice preset (default: v2/en_speaker_6)
output_dir: Directory to save output
Returns:
Path to the generated song file
"""
import scipy.io.wavfile
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
print("=" * 60)
print("GENERATING SONG WITH VOCALS")
print("=" * 60)
# Step 1: Generate vocals
print("\n[1/3] Generating vocals...")
vocals, bark_sr = _generate_vocals_for_song(lyrics, voice)
vocal_duration = len(vocals) / bark_sr
print(f"Vocals generated: {vocal_duration:.1f}s")
# Step 2: Generate instrumental (match vocal duration + buffer)
print("\n[2/3] Generating instrumental music...")
music_duration = int(vocal_duration) + 2
instrumental, musicgen_sr = _generate_instrumental_for_song(
music_prompt,
music_duration,
)
print(f"Instrumental generated: {len(instrumental) / musicgen_sr:.1f}s")
# Step 3: Mix vocals and instrumental
print("\n[3/3] Mixing vocals and instrumental...")
vocals_resampled = _resample_audio(vocals, bark_sr, musicgen_sr)
mixed = _mix_audio(instrumental, vocals_resampled)
# Save the song
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_lyrics = "".join(c if c.isalnum() or c in " -_" else "" for c in lyrics[:20])
safe_lyrics = safe_lyrics.strip().replace(" ", "_")
filename = f"{timestamp}_song_{safe_lyrics}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, musicgen_sr, mixed)
print("\n" + "=" * 60)
print(f"Song saved to: {output_path}")
print(f"Duration: {len(mixed) / musicgen_sr:.1f}s")
print("=" * 60)
return output_path

View File

@ -14,24 +14,69 @@ Usage:
from __future__ import annotations
import argparse
from datetime import datetime, timezone
from pathlib import Path
import sys
from typing import Any
import warnings
from python_pkg.music_gen._music_generation import (
CROSSFADE_DURATION,
SEGMENT_DURATION,
VRAM_THRESHOLD_LARGE,
VRAM_THRESHOLD_MEDIUM,
_calculate_segment_duration,
_generate_long_audio,
crossfade_audio,
generate_music,
generate_segment,
get_device,
get_vram_gb,
load_model,
select_model_size,
)
from python_pkg.music_gen._music_speech import (
BARK_MAX_CHARS,
BARK_VOICES,
_generate_instrumental_for_song,
_generate_vocals_for_song,
_mix_audio,
_resample_audio,
_split_into_sentences,
generate_song,
generate_speech,
)
# Suppress warnings for cleaner output
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
# VRAM thresholds for model selection (in GB)
VRAM_THRESHOLD_LARGE = 12 # Use large model with 12GB+ VRAM
VRAM_THRESHOLD_MEDIUM = 8 # Use medium model with 8GB+ VRAM
# Generation settings for segmented long audio
SEGMENT_DURATION = 25 # Seconds per segment (under 30s MusicGen limit)
CROSSFADE_DURATION = 2 # Seconds of crossfade between segments
BARK_MAX_CHARS = 200 # Max characters per Bark segment (~13s of speech)
# Re-export all public symbols for backwards compatibility
__all__ = [
"BARK_MAX_CHARS",
"BARK_VOICES",
"CROSSFADE_DURATION",
"SEGMENT_DURATION",
"VRAM_THRESHOLD_LARGE",
"VRAM_THRESHOLD_MEDIUM",
"_calculate_segment_duration",
"_generate_instrumental_for_song",
"_generate_long_audio",
"_generate_vocals_for_song",
"_mix_audio",
"_resample_audio",
"_split_into_sentences",
"check_dependencies",
"crossfade_audio",
"generate_music",
"generate_segment",
"generate_song",
"generate_speech",
"get_device",
"get_vram_gb",
"interactive_mode",
"load_model",
"main",
"select_model_size",
]
def check_dependencies(*, include_bark: bool = False) -> bool:
@ -69,734 +114,6 @@ def check_dependencies(*, include_bark: bool = False) -> bool:
return True
def get_device() -> str:
"""Get the best available device (CUDA or MPS). No CPU fallback for NVIDIA.
Raises:
RuntimeError: If NVIDIA GPU is detected but CUDA is not available.
"""
import torch
# Check for NVIDIA GPU first
nvidia_gpu_present = False
try:
import shutil
import subprocess
nvidia_smi_path = shutil.which("nvidia-smi")
if nvidia_smi_path:
result = subprocess.run(
[nvidia_smi_path],
capture_output=True,
text=True,
check=False,
)
nvidia_gpu_present = result.returncode == 0
except FileNotFoundError:
pass
if nvidia_gpu_present:
if not torch.cuda.is_available():
msg = (
"NVIDIA GPU detected but CUDA is not available!\n"
"Please install PyTorch with CUDA support:\n"
" pip install torch torchaudio --index-url "
"https://download.pytorch.org/whl/cu121"
)
raise RuntimeError(msg)
device = "cuda"
gpu_name = torch.cuda.get_device_name(0)
vram = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"Using CUDA GPU: {gpu_name} ({vram:.1f}GB VRAM)")
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
print("Using Apple Silicon (MPS)")
else:
device = "cpu"
print("Using CPU (this will be slow)")
return device
def get_vram_gb() -> float | None:
"""Get available VRAM in GB. Returns None if no CUDA GPU."""
import torch
if torch.cuda.is_available():
return torch.cuda.get_device_properties(0).total_memory / 1024**3
return None
def select_model_size(user_choice: str | None = None) -> str:
"""Select model size based on user choice or available VRAM.
Args:
user_choice: User's explicit model choice, or None for auto-selection.
Returns:
Model size: 'small', 'medium', or 'large'
"""
if user_choice is not None:
return user_choice
vram = get_vram_gb()
if vram is None:
# No GPU, use medium as a safe default
print("No CUDA GPU detected, defaulting to medium model")
return "medium"
# Select based on VRAM:
# - large: needs ~10GB VRAM (safe with 12GB+)
# - medium: needs ~6GB VRAM (safe with 8GB+)
# - small: needs ~3GB VRAM
if vram >= VRAM_THRESHOLD_LARGE:
selected = "large"
elif vram >= VRAM_THRESHOLD_MEDIUM:
selected = "medium"
else:
selected = "small"
print(f"Auto-selected '{selected}' model based on {vram:.1f}GB VRAM")
return selected
def load_model(
model_size: str = "medium",
) -> tuple[Any, Any]:
"""Load the MusicGen model.
Args:
model_size: One of 'small', 'medium', or 'large'
- small: ~500MB, fastest, lower quality
- medium: ~3.3GB, good balance (recommended)
- large: ~6.5GB, best quality, needs more VRAM
Returns:
Tuple of (model, processor)
"""
from transformers import AutoProcessor, MusicgenForConditionalGeneration
model_name = f"facebook/musicgen-{model_size}"
print(f"\nLoading MusicGen {model_size} model...")
print("(First run will download the model, this may take a while)")
device = get_device()
processor = AutoProcessor.from_pretrained(model_name)
# Use safetensors format to avoid torch.load security issues with older PyTorch
model = MusicgenForConditionalGeneration.from_pretrained(
model_name,
use_safetensors=True,
)
model = model.to(device)
print(f"Model loaded successfully on {device}!")
return model, processor
# Available Bark voice presets
BARK_VOICES = [
"v2/en_speaker_0",
"v2/en_speaker_1",
"v2/en_speaker_2",
"v2/en_speaker_3",
"v2/en_speaker_4",
"v2/en_speaker_5",
"v2/en_speaker_6",
"v2/en_speaker_7",
"v2/en_speaker_8",
"v2/en_speaker_9",
]
def generate_speech(
text: str,
voice: str = "v2/en_speaker_6",
output_dir: Path | None = None,
) -> Path:
"""Generate speech audio from text using Bark.
Bark supports various speech patterns:
- [laughter], [laughs], [sighs], [music]
- [gasps], [clears throat], or ... for hesitations
- for singing
Args:
text: Text to convert to speech (max ~13s per segment)
voice: Voice preset to use (see BARK_VOICES)
output_dir: Directory to save output (defaults to ./output)
Returns:
Path to the generated audio file
"""
import functools
import numpy as np
import scipy.io.wavfile
import torch
# Bark uses older checkpoint format with pickle
# Monkey-patch torch.load to allow unsafe loading for Bark models
original_torch_load = torch.load
@functools.wraps(original_torch_load)
def patched_load(*args: object, **kwargs: object) -> object:
kwargs.setdefault("weights_only", False)
return original_torch_load(*args, **kwargs)
torch.load = patched_load
try:
from bark import SAMPLE_RATE, generate_audio, preload_models
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
print("\nLoading Bark model...")
print("(First run will download models, ~5GB total)")
preload_models()
print(f"\nGenerating speech with voice: {voice}")
print(f"Text: {text!r}")
# Bark can only generate ~13s at a time
# For longer text, we need to split into sentences
audio_segments = []
# Split on sentence boundaries for longer texts
sentences = _split_into_sentences(text)
for i, sentence in enumerate(sentences):
if len(sentences) > 1:
print(f" Generating segment {i + 1}/{len(sentences)}...")
audio = generate_audio(
sentence.strip(),
history_prompt=voice,
)
audio_segments.append(audio)
# Combine segments
if len(audio_segments) > 1:
audio_data = np.concatenate(audio_segments)
else:
audio_data = audio_segments[0]
# Create filename
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_text = "".join(c if c.isalnum() or c in " -_" else "" for c in text[:30])
safe_text = safe_text.strip().replace(" ", "_")
filename = f"{timestamp}_speech_{safe_text}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, SAMPLE_RATE, audio_data)
print(f"\nSaved to: {output_path}")
print(f"Duration: {len(audio_data) / SAMPLE_RATE:.1f}s")
return output_path
finally:
# Restore original torch.load
torch.load = original_torch_load
def _split_into_sentences(text: str) -> list[str]:
"""Split text into sentences for Bark processing.
Args:
text: Text to split
Returns:
List of sentences
"""
import re
# Split on sentence-ending punctuation followed by space
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
# Group very short sentences together
result = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) < BARK_MAX_CHARS:
current = f"{current} {sentence}".strip()
else:
if current:
result.append(current)
current = sentence
if current:
result.append(current)
return result or [text]
def _resample_audio(
audio: object,
orig_sr: int,
target_sr: int,
) -> object:
"""Resample audio to a different sample rate.
Args:
audio: Audio data as numpy array
orig_sr: Original sample rate
target_sr: Target sample rate
Returns:
Resampled audio data
"""
import numpy as np
from scipy import signal
if orig_sr == target_sr:
return audio
# Calculate the resampling ratio
duration = len(audio) / orig_sr
target_length = int(duration * target_sr)
return signal.resample(audio, target_length).astype(np.float32)
def _mix_audio(
instrumental: object,
vocals: object,
vocal_volume: float = 0.8,
instrumental_volume: float = 0.6,
) -> object:
"""Mix vocals over instrumental track.
Args:
instrumental: Instrumental audio (numpy array)
vocals: Vocal audio (numpy array)
vocal_volume: Volume multiplier for vocals (0.0-1.0)
instrumental_volume: Volume multiplier for instrumental (0.0-1.0)
Returns:
Mixed audio data
"""
import numpy as np
# Ensure same length - pad or trim vocals to match instrumental
if len(vocals) < len(instrumental):
# Pad vocals with silence at the end
vocals = np.pad(vocals, (0, len(instrumental) - len(vocals)))
elif len(vocals) > len(instrumental):
# Trim vocals to match instrumental
vocals = vocals[: len(instrumental)]
# Mix the tracks
mixed = (instrumental * instrumental_volume) + (vocals * vocal_volume)
# Normalize to prevent clipping
max_val = np.max(np.abs(mixed))
if max_val > 1.0:
mixed = mixed / max_val
return mixed.astype(np.float32)
def _generate_vocals_for_song(lyrics: str, voice: str) -> tuple[object, int]:
"""Generate vocals using Bark for song mixing.
Args:
lyrics: Text/lyrics to sing
voice: Bark voice preset
Returns:
Tuple of (vocal audio array, sample rate)
"""
import functools
import numpy as np
import torch
# Patch torch.load for Bark compatibility
original_torch_load = torch.load
@functools.wraps(original_torch_load)
def patched_load(*args: object, **kwargs: object) -> object:
kwargs.setdefault("weights_only", False)
return original_torch_load(*args, **kwargs)
torch.load = patched_load
try:
from bark import SAMPLE_RATE as BARK_SR
from bark import generate_audio, preload_models
print("Loading Bark model...")
preload_models()
print(f"Generating vocals with voice: {voice}")
print(f"Lyrics: {lyrics!r}")
sentences = _split_into_sentences(lyrics)
vocal_segments = []
for i, sentence in enumerate(sentences):
if len(sentences) > 1:
print(f" Vocal segment {i + 1}/{len(sentences)}...")
audio = generate_audio(sentence.strip(), history_prompt=voice)
vocal_segments.append(audio)
if len(vocal_segments) > 1:
vocals = np.concatenate(vocal_segments)
else:
vocals = vocal_segments[0]
return vocals, BARK_SR
finally:
torch.load = original_torch_load
def _generate_instrumental_for_song(
music_prompt: str,
duration: int,
) -> tuple[object, int]:
"""Generate instrumental music using MusicGen for song mixing.
Args:
music_prompt: Description of the music
duration: Duration in seconds
Returns:
Tuple of (instrumental audio array, sample rate)
"""
model_size = select_model_size(None)
model, processor = load_model(model_size)
print(f"Music prompt: {music_prompt!r}")
print(f"Duration: {duration}s")
device = str(next(model.parameters()).device)
sample_rate = model.config.audio_encoder.sampling_rate
if duration <= SEGMENT_DURATION:
instrumental = generate_segment(
music_prompt,
model,
processor,
duration,
device,
)
else:
instrumental = _generate_long_audio(
music_prompt,
model,
processor,
duration,
)
return instrumental, sample_rate
def generate_song(
lyrics: str,
music_prompt: str,
voice: str = "v2/en_speaker_6",
output_dir: Path | None = None,
) -> Path:
"""Generate a complete song with vocals over instrumental music.
This combines Bark for vocals and MusicGen for instrumental backing.
Args:
lyrics: The lyrics/text to sing (use for singing style)
music_prompt: Description of the instrumental music
voice: Bark voice preset (default: v2/en_speaker_6)
output_dir: Directory to save output
Returns:
Path to the generated song file
"""
import scipy.io.wavfile
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
print("=" * 60)
print("GENERATING SONG WITH VOCALS")
print("=" * 60)
# Step 1: Generate vocals
print("\n[1/3] Generating vocals...")
vocals, bark_sr = _generate_vocals_for_song(lyrics, voice)
vocal_duration = len(vocals) / bark_sr
print(f"Vocals generated: {vocal_duration:.1f}s")
# Step 2: Generate instrumental (match vocal duration + buffer)
print("\n[2/3] Generating instrumental music...")
music_duration = int(vocal_duration) + 2
instrumental, musicgen_sr = _generate_instrumental_for_song(
music_prompt,
music_duration,
)
print(f"Instrumental generated: {len(instrumental) / musicgen_sr:.1f}s")
# Step 3: Mix vocals and instrumental
print("\n[3/3] Mixing vocals and instrumental...")
vocals_resampled = _resample_audio(vocals, bark_sr, musicgen_sr)
mixed = _mix_audio(instrumental, vocals_resampled)
# Save the song
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_lyrics = "".join(c if c.isalnum() or c in " -_" else "" for c in lyrics[:20])
safe_lyrics = safe_lyrics.strip().replace(" ", "_")
filename = f"{timestamp}_song_{safe_lyrics}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, musicgen_sr, mixed)
print("\n" + "=" * 60)
print(f"Song saved to: {output_path}")
print(f"Duration: {len(mixed) / musicgen_sr:.1f}s")
print("=" * 60)
return output_path
def crossfade_audio(
audio1: object,
audio2: object,
crossfade_samples: int,
) -> object:
"""Crossfade two audio segments together.
Args:
audio1: First audio segment (numpy array)
audio2: Second audio segment (numpy array)
crossfade_samples: Number of samples to use for crossfade
Returns:
Combined audio with crossfade applied (numpy array)
"""
import numpy as np
if crossfade_samples <= 0 or len(audio1) < crossfade_samples:
return np.concatenate([audio1, audio2])
# Create fade curves
fade_out = np.linspace(1.0, 0.0, crossfade_samples)
fade_in = np.linspace(0.0, 1.0, crossfade_samples)
# Apply fades
audio1_end = audio1[-crossfade_samples:] * fade_out
audio2_start = audio2[:crossfade_samples] * fade_in
# Combine
crossfaded = audio1_end + audio2_start
# Build final audio
return np.concatenate(
[
audio1[:-crossfade_samples],
crossfaded,
audio2[crossfade_samples:],
]
)
def generate_segment(
prompt: str,
model: object,
processor: object,
duration_seconds: int,
device: str,
) -> object:
"""Generate a single audio segment.
Args:
prompt: Text description of the music
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Length of segment to generate
device: Device to generate on
Returns:
Audio data as numpy array
"""
import torch
inputs = processor(
text=[prompt],
padding=True,
return_tensors="pt",
)
inputs = {k: v.to(device) for k, v in inputs.items()}
max_new_tokens = int(duration_seconds * 50)
with torch.no_grad():
audio_values = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
)
return audio_values[0, 0].cpu().numpy()
def _calculate_segment_duration(
segment_index: int,
num_segments: int,
generated_samples: int,
sample_rate: int,
total_duration: int,
) -> int:
"""Calculate duration for a specific segment.
Args:
segment_index: Current segment index
num_segments: Total number of segments
generated_samples: Number of samples generated so far
sample_rate: Audio sample rate
total_duration: Target total duration
Returns:
Duration in seconds for this segment
"""
if segment_index == num_segments - 1:
# Last segment: calculate remaining time
generated_so_far = generated_samples / sample_rate
remaining = total_duration - generated_so_far
min_duration = max(5, int(remaining) + CROSSFADE_DURATION)
return min(SEGMENT_DURATION, min_duration)
return SEGMENT_DURATION
def _generate_long_audio(
prompt: str,
model: object,
processor: object,
duration_seconds: int,
) -> object:
"""Generate long audio by segmenting with crossfades.
Args:
prompt: Text description of the music
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Total duration to generate
Returns:
Audio data as numpy array
"""
import numpy as np
device = str(next(model.parameters()).device)
sample_rate = model.config.audio_encoder.sampling_rate
crossfade_samples = CROSSFADE_DURATION * sample_rate
effective_segment = SEGMENT_DURATION - CROSSFADE_DURATION
total = duration_seconds + effective_segment - 1
num_segments = max(1, total // effective_segment)
print(f"Generating {num_segments} segments of ~{SEGMENT_DURATION}s each...")
audio_data = np.array([], dtype=np.float32)
for i in range(num_segments):
segment_duration = _calculate_segment_duration(
i,
num_segments,
len(audio_data),
sample_rate,
duration_seconds,
)
seg_num = i + 1
msg = f" Segment {seg_num}/{num_segments} ({segment_duration}s)..."
print(msg, end=" ", flush=True)
segment = generate_segment(
prompt,
model,
processor,
segment_duration,
device,
)
if len(audio_data) == 0:
audio_data = segment
else:
audio_data = crossfade_audio(audio_data, segment, crossfade_samples)
print(f"done (total: {len(audio_data) / sample_rate:.1f}s)")
# Trim to exact duration if needed
target_samples = int(duration_seconds * sample_rate)
if len(audio_data) > target_samples:
audio_data = audio_data[:target_samples]
return audio_data
def generate_music(
prompt: str,
model: object,
processor: object,
duration_seconds: int = 10,
output_dir: Path | None = None,
) -> Path:
"""Generate music from a text prompt.
For durations over 30 seconds, generates in segments with crossfading.
Args:
prompt: Text description of the music to generate
model: The MusicGen model
processor: The MusicGen processor
duration_seconds: Length of audio to generate (any duration supported)
output_dir: Directory to save output (defaults to ./output)
Returns:
Path to the generated audio file
"""
import scipy.io.wavfile
if output_dir is None:
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
sample_rate = model.config.audio_encoder.sampling_rate
# For short durations, generate directly
if duration_seconds <= SEGMENT_DURATION:
print(f"\nGenerating {duration_seconds}s of music...")
print(f"Prompt: {prompt!r}")
device = str(next(model.parameters()).device)
audio_data = generate_segment(
prompt,
model,
processor,
duration_seconds,
device,
)
else:
# Long duration: generate in segments with crossfading
print(f"\nGenerating {duration_seconds}s of music in segments...")
print(f"Prompt: {prompt!r}")
audio_data = _generate_long_audio(prompt, model, processor, duration_seconds)
# Create filename with timestamp and sanitized prompt
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30])
safe_prompt = safe_prompt.strip().replace(" ", "_")
filename = f"{timestamp}_{safe_prompt}.wav"
output_path = output_dir / filename
scipy.io.wavfile.write(output_path, sample_rate, audio_data)
print(f"\nSaved to: {output_path}")
print(f"Duration: {len(audio_data) / sample_rate:.1f}s")
return output_path
def interactive_mode(model: object, processor: object) -> None:
"""Run interactive prompt mode."""
print("\n" + "=" * 60)

View File

@ -0,0 +1,274 @@
"""Algorithm step definitions for Q02 shortest path visualization.
Contains step sequences for Dijkstra, Bellman-Ford, A*, and the
comparison slide used in the final video.
"""
from __future__ import annotations
from moviepy import (
ColorClip,
CompositeVideoClip,
VideoClip,
)
from moviepy.video.fx import FadeIn, FadeOut
from python_pkg.praca_magisterska_video.visualize_q02 import (
BG,
EDGES_BF,
EDGES_DIJKSTRA,
FONT_B,
FONT_R,
INF,
NODE_POS,
H,
W,
_make_step,
_StepConfig,
_tc,
)
def _dijkstra_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_DIJKSTRA
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
current="S",
step_text="Inicjalizacja: d[S]=0, reszta=∞. Wybierz S (min d).",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": INF},
current="S",
active_edge=("S", "A"),
step_text="Relaksacja S→A: d[A]=0+2=2. S→B: d[B]=0+5=5.",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="A",
visited={"S"},
active_edge=("A", "C"),
step_text="Zamknij S. Min=A(2). Relaksacja A→C: d[C]=2+3=5.",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="B",
visited={"S", "A"},
active_edge=("B", "A"),
step_text=(
"Zamknij A. Min=B(5). B→A: 5+1=6>2, "
"nie zmieniaj. B→C: 5+6=11>5."
),
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="C",
visited={"S", "A", "B"},
step_text=(
"Zamknij B. Min=C(5). Koniec! "
"Wynik: d={S:0, A:2, B:5, C:5}."
),
algo_name="Dijkstra -- WYNIK",
),
),
]
def _bellman_ford_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_BF
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
step_text=(
"Bellman-Ford: relaksuj WSZYSTKIE "
"krawędzie V-1=3 razy. Ujemne wagi OK!"
),
algo_name="Algorytm Bellmana-Forda",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
active_edge=("S", "A"),
step_text=(
"Iteracja 1: S→A:2, A→C:5, S→B:5. "
"Potem B→A: 5+(-4)=1 < 2 → A=1!"
),
algo_name="Bellman-Ford -- iteracja 1",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "5"},
active_edge=("B", "A"),
step_text=(
"B→A z ujemną wagą -4: d[A] poprawione "
"z 2 na 1! (Dijkstra by to pominął!)"
),
algo_name="Bellman-Ford -- ujemna waga",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "4"},
active_edge=("A", "C"),
step_text=(
"Iteracja 2: A→C: 1+3=4 < 5 → C=4. "
"Propagacja poprawionego A."
),
algo_name="Bellman-Ford -- iteracja 2",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "4"},
step_text=(
"Iteracja 3: brak zmian. V-ta iteracja: "
"brak popraw → brak cyklu ujemnego."
),
algo_name="Bellman-Ford -- WYNIK, O(V*E)",
),
),
]
def _astar_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_DIJKSTRA
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
current="S",
step_text=(
"A*: f(n)=g(n)+h(n). Cel=C. "
"h(S)=5, h(A)=3, h(B)=4, h(C)=0. f(S)=0+5=5."
),
algo_name="Algorytm A*",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": INF},
current="S",
active_edge=("S", "A"),
step_text=(
"Relaksuj S: A(g=2,f=2+3=5), "
"B(g=5,f=5+4=9). Min f → A(5)."
),
algo_name="A* -- rozwijanie S",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="A",
visited={"S"},
active_edge=("A", "C"),
step_text=(
"Rozwiń A(f=5): A→C: g=2+3=5, "
"f=5+0=5. Min f → C(5) = CEL!"
),
algo_name="A* -- rozwijanie A",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="C",
visited={"S", "A"},
step_text=(
"Dotarliśmy do C! Koszt=5. "
"A* NIE przetwarza B (3 vs 4 w Dijkstrze)."
),
algo_name="A* -- cel osiągnięty!",
),
),
]
def _comparison_slide() -> CompositeVideoClip:
bg = ColorClip(size=(W, H), color=BG).with_duration(12.0)
title = (
_tc(
text="Porównanie algorytmów",
font_size=40,
color="white",
font=FONT_B,
)
.with_duration(12.0)
.with_position(("center", 40))
)
rows = [
("Cecha", "Dijkstra", "Bellman-Ford", "A*"),
("Typ", "Zachłanny", "Prog. dynamiczne", "Heurystyczny"),
("Problem", "SSSP", "SSSP", "Single-pair"),
("Ujemne wagi", "NIE", "TAK", "NIE"),
("Cykl ujemny", "NIE wykrywa", "TAK wykrywa", "NIE"),
("Złożoność", "O((V+E)log V)", "O(V*E)", "Zależy od h(n)"),
]
clips: list[VideoClip] = [bg, title]
for i, row in enumerate(rows):
y_pos = 120 + i * 85
for j, cell in enumerate(row):
x_pos = 60 + j * 300
fs = 18 if i > 0 else 22
color = "#64B5F6" if i == 0 else "#CFD8DC"
tc = (
_tc(
text=cell,
font_size=fs,
color=color,
font=FONT_B if i == 0 else FONT_R,
)
.with_duration(12.0)
.with_position((x_pos, y_pos))
)
clips.append(tc)
return CompositeVideoClip(clips, size=(W, H)).with_effects(
[FadeIn(0.5), FadeOut(0.5)]
)

View File

@ -0,0 +1,482 @@
"""Cognitive agent diagrams (Behavior Tree, BDI Model)."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
ArrowCfg,
BoxStyle,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
# --- DIAGRAM 3: Behavior Tree Example ---
def draw_behavior_tree() -> None:
"""Draw behavior tree."""
fig, ax = plt.subplots(
1, 1, figsize=(7.5, 4.5), facecolor=BG
)
ax.set_xlim(0, 7.5)
ax.set_ylim(0, 4.5)
ax.axis("off")
ax.set_title(
"Behavior Tree: robot przenosz\u0105cy"
" obiekt (pick-and-place)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
def draw_bt_node(
pos: tuple[float, float],
text: str,
ntype: str = "act",
size: tuple[float, float] = (1.0, 0.45),
) -> tuple[float, float]:
"""Draw a behavior tree node."""
x, y = pos
w, h = size
if ntype == "seq":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(rect)
ax.text(
x,
y,
f"\u2192 {text}",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
elif ntype == "sel":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.5,
edgecolor=LN,
facecolor=GRAY3,
)
ax.add_patch(rect)
ax.text(
x,
y,
f"? {text}",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
elif ntype == "cond":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.0,
edgecolor=LN,
facecolor="white",
linestyle="--",
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=6.5,
fontstyle="italic",
)
else: # action
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.0,
edgecolor=LN,
facecolor=GRAY1,
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=6.5,
)
return x, y
# Root: Sequence "Przenies obiekt"
root = draw_bt_node(
(3.75, 3.8), "Przenie\u015b obiekt", "seq",
(1.6, 0.45),
)
# Level 2 children
find = draw_bt_node(
(1.2, 2.8), "Znajd\u017a obiekt", "sel",
(1.3, 0.45),
)
nav = draw_bt_node(
(3.75, 2.8), "Jed\u017a do obiektu", "act",
(1.3, 0.45),
)
pick = draw_bt_node(
(6.3, 2.8), "Chwy\u0107 i dostarcz", "seq",
(1.4, 0.45),
)
# Arrows from root
arrow_thin = ArrowCfg(lw=1.0)
for child in (find, nav, pick):
draw_arrow(
ax,
(root[0], root[1] - 0.225),
(child[0], child[1] + 0.225),
arrow_thin,
)
# Level 3: children of "Znajdz obiekt"
arrow_08 = ArrowCfg(lw=0.8)
vis = draw_bt_node(
(0.55, 1.7), "Widz\u0119\nobiekt?", "cond",
(0.85, 0.5),
)
scan = draw_bt_node(
(1.85, 1.7), "Skanuj\notoczenie", "act",
(0.85, 0.5),
)
for child in (vis, scan):
draw_arrow(
ax,
(find[0], find[1] - 0.225),
(child[0], child[1] + 0.25),
arrow_08,
)
# Level 3: children of "Chwyt i dostarcz"
pick_children = [
draw_bt_node(
(5.4, 1.7), "Chwy\u0107\nobject", "act",
(0.85, 0.5),
),
draw_bt_node(
(6.5, 1.7), "Jed\u017a do\ncelu", "act",
(0.85, 0.5),
),
draw_bt_node(
(7.2, 1.7), "Pu\u015b\u0107", "act",
(0.55, 0.5),
),
]
for child in pick_children:
draw_arrow(
ax,
(pick[0], pick[1] - 0.225),
(child[0], child[1] + 0.25),
arrow_08,
)
# Legend
leg_y = 0.5
draw_bt_node(
(0.8, leg_y), "\u2192 Sequence", "seq",
(1.1, 0.35),
)
draw_bt_node(
(2.3, leg_y), "? Selector", "sel",
(1.0, 0.35),
)
draw_bt_node(
(3.6, leg_y), "Akcja", "act", (0.8, 0.35)
)
draw_bt_node(
(4.8, leg_y), "Warunek", "cond", (0.8, 0.35)
)
ax.text(
0.3,
leg_y,
"Legenda:",
ha="left",
va="center",
fontsize=6.5,
fontweight="bold",
)
# Execution note
ax.text(
3.75,
0.05,
"Wykonanie: od lewej do prawej."
" Sequence (\u2192) = wszystkie po kolei."
" Selector (?) = pierwszy sukces.",
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#555555",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_behavior_tree.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
_logger.info(" \u2713 %s", path)
# --- DIAGRAM 4: BDI Model ---
def draw_bdi_model() -> None:
"""Draw bdi model."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 4), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 4)
ax.axis("off")
ax.set_title(
"Model BDI agenta"
" (Beliefs-Desires-Intentions)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
bw = 1.6
bh = 1.4
bold8 = BoxStyle(
fill=GRAY1, fontsize=8, fontweight="bold"
)
# BELIEFS box
draw_box(ax, (0.3, 1.3), (bw, bh), "", bold8)
ax.text(
0.3 + bw / 2,
1.3 + bh - 0.15,
"BELIEFS",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
0.3 + bw / 2,
1.3 + bh / 2 - 0.1,
"(wiedza o \u015bwiecie)\n\n"
"\u2022 mapa pokoju\n"
"\u2022 pozycja robota\n"
"\u2022 drzwi zamkni\u0119te\n"
"\u2022 bateria: 45%",
ha="center",
va="center",
fontsize=6.5,
)
# DESIRES box
draw_box(
ax,
(2.7, 1.3),
(bw, bh),
"",
BoxStyle(
fill=GRAY2, fontsize=8, fontweight="bold"
),
)
ax.text(
2.7 + bw / 2,
1.3 + bh - 0.15,
"DESIRES",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
2.7 + bw / 2,
1.3 + bh / 2 - 0.1,
"(cele agenta)\n\n"
"\u2022 dostarczy\u0107 paczk\u0119\n"
" do pokoju 5\n"
"\u2022 na\u0142adowa\u0107 bateri\u0119\n"
"\u2022 unika\u0107 kolizji",
ha="center",
va="center",
fontsize=6.5,
)
# INTENTIONS box
draw_box(
ax,
(5.1, 1.3),
(bw, bh),
"",
BoxStyle(
fill=GRAY3, fontsize=8, fontweight="bold"
),
)
ax.text(
5.1 + bw / 2,
1.3 + bh - 0.15,
"INTENTIONS",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
5.1 + bw / 2,
1.3 + bh / 2 - 0.1,
"(aktualny plan)\n\n"
"\u2192 jed\u017a do drzwi\n"
" bocznych\n"
"\u2192 otw\u00f3rz drzwi\n"
"\u2192 wjed\u017a do pokoju 5",
ha="center",
va="center",
fontsize=6.5,
)
# Arrows
draw_arrow(
ax,
(0.3 + bw, 1.3 + bh / 2 + 0.15),
(2.7, 1.3 + bh / 2 + 0.15),
ArrowCfg(
lw=1.3,
label="informuje",
label_offset=0.08,
),
)
draw_arrow(
ax,
(2.7 + bw, 1.3 + bh / 2 + 0.15),
(5.1, 1.3 + bh / 2 + 0.15),
ArrowCfg(
lw=1.3,
label="filtruje \u2192 wybiera",
label_offset=0.08,
),
)
# Feedback: intentions back to beliefs
ax.annotate(
"",
xy=(0.3 + bw / 2, 1.3),
xytext=(5.1 + bw / 2, 1.3),
arrowprops={
"arrowstyle": "->",
"color": "#666666",
"lw": 1.0,
"linestyle": "dashed",
"connectionstyle": "arc3,rad=0.3",
},
)
ax.text(
3.5,
0.75,
"aktualizacja wiedzy po wykonaniu akcji",
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#666666",
)
# Sensor input arrow
draw_arrow(
ax,
(0.3 + bw / 2, 3.5),
(0.3 + bw / 2, 1.3 + bh),
ArrowCfg(
lw=1.3,
label="percepcja (sensory)",
label_offset=0.05,
),
)
ax.text(
0.3 + bw / 2,
3.55,
"\u015aRODOWISKO",
ha="center",
va="bottom",
fontsize=7,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
# Action output arrow
draw_arrow(
ax,
(5.1 + bw / 2, 1.3 + bh),
(5.1 + bw / 2, 3.5),
ArrowCfg(
lw=1.3,
label="akcja (efektory)",
label_offset=0.05,
),
)
ax.text(
5.1 + bw / 2,
3.55,
"EFEKTORY",
ha="center",
va="bottom",
fontsize=7,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
path = str(Path(OUTPUT_DIR) / "agent_bdi_model.png")
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
_logger.info(" \u2713 %s", path)
# --- MAIN ---

View File

@ -0,0 +1,406 @@
"""Reactive agent diagrams (See-Think-Act, 3T Architecture)."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import (
BG,
DPI,
FS,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
ArrowCfg,
BoxStyle,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
# --- DIAGRAM 1: See-Think-Act Cycle ---
def draw_see_think_act() -> None:
"""Draw see think act."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 4.5), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 4.5)
ax.axis("off")
ax.set_title(
"Cykl agenta upostaciowionego:"
" Percepcja \u2192 Deliberacja \u2192 Akcja",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Environment box (large background)
env_rect = FancyBboxPatch(
(0.2, 0.2),
6.6,
1.0,
boxstyle="round,pad=0.08",
lw=1.5,
edgecolor=LN,
facecolor=GRAY1,
linestyle="--",
)
ax.add_patch(env_rect)
ax.text(
3.5,
0.7,
"\u015aRODOWISKO FIZYCZNE\n"
"(przeszkody, obiekty, ludzie)",
ha="center",
va="center",
fontsize=FS,
fontstyle="italic",
)
# Agent body (large rounded box)
agent_rect = FancyBboxPatch(
(0.5, 1.5),
6.0,
2.6,
boxstyle="round,pad=0.1",
lw=2.0,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(agent_rect)
ax.text(
3.5,
3.85,
"AGENT UPOSTACIOWIONY (robot)",
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
# Three main phases
bw = 1.4
bh = 0.7
by = 2.2
bold_fs8 = BoxStyle(
fill=GRAY2, fontsize=8, fontweight="bold"
)
# SEE
draw_box(
ax,
(0.8, by),
(bw, bh),
"SEE\n(Percepcja)",
bold_fs8,
)
ax.text(
1.5,
by - 0.2,
"kamery, LIDAR\nczujniki dotyku",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# THINK
draw_box(
ax,
(2.8, by),
(bw, bh),
"THINK\n(Deliberacja)",
BoxStyle(
fill=GRAY3, fontsize=8, fontweight="bold"
),
)
ax.text(
3.5,
by - 0.2,
"planowanie trasy\nmodel BDI",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# ACT
draw_box(
ax,
(4.8, by),
(bw, bh),
"ACT\n(Akcja)",
bold_fs8,
)
ax.text(
5.5,
by - 0.2,
"silniki, chwytaki\nkomendy PWM",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# Arrows between phases
draw_arrow(
ax,
(0.8 + bw, by + bh / 2),
(2.8, by + bh / 2),
ArrowCfg(lw=1.5, label="dane sensoryczne"),
)
draw_arrow(
ax,
(2.8 + bw, by + bh / 2),
(4.8, by + bh / 2),
ArrowCfg(
lw=1.5, label="komendy steruj\u0105ce"
),
)
# Arrows to/from environment
draw_arrow(
ax,
(1.5, 1.2),
(1.5, by),
ArrowCfg(
lw=1.3,
label="odczyt",
label_offset=0.08,
),
)
draw_arrow(
ax,
(5.5, by),
(5.5, 1.2),
ArrowCfg(
lw=1.3,
label="dzia\u0142anie",
label_offset=0.08,
),
)
# Feedback loop arrow
ax.annotate(
"",
xy=(1.5, 1.15),
xytext=(5.5, 1.15),
arrowprops={
"arrowstyle": "->",
"color": "#555555",
"lw": 1.0,
"linestyle": "dashed",
"connectionstyle": "arc3,rad=-0.15",
},
)
ax.text(
3.5,
0.35,
"\u2190 sprz\u0119\u017cenie zwrotne"
" (efekt akcji zmienia \u015brodowisko) \u2192",
ha="center",
va="center",
fontsize=6,
color="#555555",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_see_think_act.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
_logger.info(" \u2713 %s", path)
# --- DIAGRAM 2: 3T Architecture ---
def draw_3t_architecture() -> None:
"""Draw 3t architecture."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 5.5), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 5.5)
ax.axis("off")
ax.set_title(
"Architektura 3T sterownika robota"
" (3-Layer Architecture)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
layers = [
{
"y": 4.0,
"name": "WARSTWA 3: PLANNER\n(Deliberacja)",
"time": "sekundy \u2013 minuty",
"fill": GRAY1,
"example": (
'Cel: "Jed\u017a do kuchni po kubek"\n'
"Planowanie trasy A \u2192 B \u2192 C"
),
},
{
"y": 2.6,
"name": "WARSTWA 2: SEQUENCER\n(Wykonawca)",
"time": "100 ms \u2013 sekundy",
"fill": GRAY2,
"example": (
"Sekwencja: Jed\u017a do drzwi \u2192\n"
"Otw\u00f3rz \u2192 Jed\u017a do blatu"
" \u2192 Chwy\u0107"
),
},
{
"y": 1.2,
"name": "WARSTWA 1: CONTROLLER\n(Reaktywny)",
"time": "milisekundy",
"fill": GRAY3,
"example": (
"PID: utrzymaj pr\u0119dko\u015b\u0107"
" 0.5 m/s\n"
"Unikaj kolizji (emergency stop)"
),
},
]
bw = 4.0
bh = 0.85
for layer in layers:
y = layer["y"]
draw_box(
ax,
(0.3, y),
(bw, bh),
layer["name"],
BoxStyle(
fill=layer["fill"],
fontsize=8,
fontweight="bold",
),
)
ax.text(
0.15,
y + bh / 2,
layer["time"],
ha="right",
va="center",
fontsize=6,
fontstyle="italic",
rotation=0,
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": "white",
"edgecolor": LN,
"lw": 0.5,
},
)
draw_box(
ax,
(4.5, y),
(2.3, bh),
layer["example"],
BoxStyle(fontsize=6.5),
)
# Arrows between layers
for i in range(len(layers) - 1):
y_top = layers[i]["y"]
y_bot = layers[i + 1]["y"] + 0.85
draw_arrow(
ax,
(1.8, y_top),
(1.8, y_bot),
ArrowCfg(
lw=1.3,
label="polecenia \u2193",
label_offset=0.02,
),
)
draw_arrow(
ax,
(2.8, y_bot),
(2.8, y_top),
ArrowCfg(
lw=1.0,
color="#666666",
label="\u2191 status",
label_offset=0.02,
),
)
# Environment at bottom
env_rect = FancyBboxPatch(
(0.3, 0.3),
bw,
0.6,
boxstyle="round,pad=0.05",
lw=1.5,
edgecolor=LN,
facecolor=GRAY4,
linestyle="--",
)
ax.add_patch(env_rect)
ax.text(
0.3 + bw / 2,
0.6,
"SPRZ\u0118T: silniki, czujniki, efektory",
ha="center",
va="center",
fontsize=7,
fontstyle="italic",
)
draw_arrow(
ax, (2.3, 1.2), (2.3, 0.9), ArrowCfg(lw=1.3)
)
# Abstraction label on the right
ax.annotate(
"",
xy=(6.9, 4.8),
xytext=(6.9, 0.5),
arrowprops={
"arrowstyle": "<->",
"color": "#888888",
"lw": 1.0,
},
)
ax.text(
6.95,
2.65,
"abstrakcja",
ha="left",
va="center",
fontsize=7,
rotation=90,
color="#888888",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_3t_architecture.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
_logger.info(" \u2713 %s", path)

View File

@ -0,0 +1,294 @@
"""C4 Model diagram generation (4 zoom levels)."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
_draw_class,
draw_arrow,
draw_box,
draw_line,
)
if TYPE_CHECKING:
from matplotlib.axes import Axes
_logger = logging.getLogger(__name__)
def _draw_c4_system_context(ax1: Axes) -> None:
"""Draw C4 Level 1: System Context."""
# Person
ax1.add_patch(
plt.Circle(
(20, 55), 4, lw=1.5,
edgecolor=LN, facecolor=GRAY1,
)
)
# Head
ax1.add_patch(
plt.Circle(
(20, 57.5), 1.5, lw=1.2,
edgecolor=LN, facecolor="white",
)
)
# Body
draw_line(ax1, 20, 56, 20, 52.5, lw=1.2)
draw_line(ax1, 17, 55, 23, 55, lw=1.2)
ax1.text(
20, 48, "Klient",
ha="center", fontsize=8, fontweight="bold",
)
draw_box(
ax1, 38, 43, 24, 18,
"System\nE-commerce",
fill=GRAY2, lw=2, fontsize=9,
fontweight="bold", rounded=True,
)
draw_box(
ax1, 72, 48, 20, 12,
"System\nP\u0142atno\u015bci\n(zewn.)",
fill=GRAY4, lw=1.5, fontsize=7,
rounded=True,
)
ax1.add_patch(
plt.Rectangle(
(72, 48), 20, 12, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
draw_arrow(ax1, 24, 54, 38, 54)
ax1.text(
31, 56, "sk\u0142ada\nzam\u00f3wienia",
fontsize=6, ha="center",
)
draw_arrow(ax1, 62, 54, 72, 54)
ax1.text(67, 56, "API", fontsize=6, ha="center")
ax1.text(
50, 20,
"Kto u\u017cywa systemu?\nZ czym si\u0119 integruje?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_container(ax2: Axes) -> None:
"""Draw C4 Level 2: Container."""
ax2.add_patch(
plt.Rectangle(
(5, 15), 90, 58, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
ax2.text(
50, 75, "System E-commerce",
ha="center", fontsize=8,
fontweight="bold", fontstyle="italic",
)
containers = [
("SPA\n(React)", 15, 50, 18, 12, GRAY1),
("API\nServer\n(Node.js)", 42, 50, 18, 12, GRAY2),
("Database\n(PostgreSQL)", 70, 50, 18, 12, GRAY3),
("Worker\n(Python)", 42, 25, 18, 12, GRAY1),
]
for label, x, y, w, h, fill in containers:
draw_box(
ax2, x, y, w, h, label,
fill=fill, lw=1.5, fontsize=7,
fontweight="bold", rounded=True,
)
draw_arrow(ax2, 33, 56, 42, 56)
ax2.text(37.5, 58, "REST", fontsize=6, ha="center")
draw_arrow(ax2, 60, 56, 70, 56)
ax2.text(65, 58, "SQL", fontsize=6, ha="center")
draw_arrow(ax2, 51, 50, 51, 37)
ax2.text(53, 44, "async", fontsize=6)
ax2.text(
50, 8,
"Jakie kontenery techniczne\n"
"sk\u0142adaj\u0105 si\u0119 na system?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_component(ax3: Axes) -> None:
"""Draw C4 Level 3: Component."""
ax3.add_patch(
plt.Rectangle(
(5, 15), 90, 58, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
ax3.text(
50, 75, "API Server (Node.js)",
ha="center", fontsize=8,
fontweight="bold", fontstyle="italic",
)
components = [
("OrderController", 10, 50, 22, 10, GRAY1),
("AuthService", 40, 50, 22, 10, GRAY2),
("PaymentGateway\n(adapter)", 70, 50, 22, 10, GRAY1),
("OrderRepository", 25, 25, 22, 10, GRAY2),
("NotificationService", 57, 25, 22, 10, GRAY1),
]
for label, x, y, w, h, fill in components:
draw_box(
ax3, x, y, w, h, label,
fill=fill, lw=1.5, fontsize=6.5,
fontweight="bold", rounded=True,
)
draw_arrow(ax3, 32, 55, 40, 55)
draw_arrow(ax3, 62, 55, 70, 55)
draw_arrow(ax3, 21, 50, 30, 35)
draw_arrow(ax3, 51, 50, 62, 35)
ax3.text(
50, 8,
"Jakie modu\u0142y/komponenty\n"
"wewn\u0105trz kontenera?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_code(ax4: Axes) -> None:
"""Draw C4 Level 4: Code (UML)."""
_draw_class(
ax4, 5, 40,
"\u00abinterface\u00bb\nIOrderRepository",
[],
["+save(order)", "+findById(id)"],
w=32, fill=GRAY4,
)
_draw_class(
ax4, 55, 40,
"OrderRepository",
["-db: Database"],
["+save(order)", "+findById(id)"],
w=32, fill=GRAY1,
)
_draw_class(
ax4, 30, 10,
"Order",
["-id: UUID", "-items: List", "-total: Money"],
["+addItem(item)", "+calculateTotal()"],
w=32, fill=GRAY2,
)
ax4.annotate(
"",
xy=(37, 46),
xytext=(55, 50),
arrowprops={
"arrowstyle": "-|>",
"color": LN,
"lw": 1.2,
"linestyle": "--",
},
)
ax4.text(
46, 52, "\u00abimplements\u00bb",
fontsize=6, ha="center", fontstyle="italic",
)
draw_arrow(ax4, 71, 40, 50, 24)
ax4.text(64, 32, "uses", fontsize=6, fontstyle="italic")
ax4.text(
50, 3,
"Diagramy klas UML\n"
"(opcjonalny poziom szczeg\u00f3\u0142owo\u015bci)",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def generate_c4() -> None:
"""Generate c4."""
fig, axes = plt.subplots(2, 2, figsize=(8.27, 10))
fig.patch.set_facecolor(BG)
fig.suptitle(
"C4 Model (Simon Brown) \u2014 4 poziomy zoomu",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
titles = [
"Level 1: System Context",
"Level 2: Container",
"Level 3: Component",
"Level 4: Code (UML)",
]
for idx, ax_item in enumerate(axes.flat):
ax_item.set_xlim(0, 100)
ax_item.set_ylim(0, 80)
ax_item.set_aspect("equal")
ax_item.axis("off")
ax_item.set_title(
titles[idx], fontsize=10,
fontweight="bold", pad=8,
)
_draw_c4_system_context(axes[0, 0])
_draw_c4_container(axes[0, 1])
_draw_c4_component(axes[1, 0])
_draw_c4_code(axes[1, 1])
fig.tight_layout(rect=[0, 0, 1, 0.96])
fig.savefig(
str(Path(OUTPUT_DIR) / "c4_model.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK C4 Model")

View File

@ -0,0 +1,360 @@
"""Zachman Framework and ArchiMate layer diagram generation."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
# =========================================================================
# 4. Zachman Framework Grid
# =========================================================================
def generate_zachman() -> None:
"""Generate zachman."""
fig, ax = plt.subplots(figsize=(8.27, 6))
ax.set_xlim(0, 100)
ax.set_ylim(0, 65)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Zachman Framework \u2014 taksonomia architektury",
fontsize=FS_TITLE,
fontweight="bold",
pad=12,
)
rows = [
"Kontekst\n(Planner)",
"Konceptualny\n(Owner)",
"Logiczny\n(Designer)",
"Fizyczny\n(Builder)",
"Szczeg\u00f3\u0142owy\n(Subcontractor)",
]
cols = [
"Co?\n(dane)",
"Jak?\n(funkcje)",
"Gdzie?\n(sie\u0107)",
"Kto?\n(ludzie)",
"Kiedy?\n(czas)",
"Dlaczego?\n(cel)",
]
n_rows = len(rows)
n_cols = len(cols)
x0 = 18
y0 = 5
cw = 12.5 # cell width
ch = 9 # cell height
rh_label = 14 # row label width
# Column headers
for j, col in enumerate(cols):
x = x0 + j * cw
draw_box(
ax,
x,
y0 + n_rows * ch,
cw,
7,
col,
fill=GRAY2,
lw=1.5,
fontsize=6.5,
fontweight="bold",
)
# Row headers
for i, row in enumerate(rows):
y = y0 + (n_rows - 1 - i) * ch
draw_box(
ax,
x0 - rh_label,
y,
rh_label,
ch,
row,
fill=GRAY2,
lw=1.5,
fontsize=6.5,
fontweight="bold",
)
# Cells
fills = [GRAY4, "white"]
for i in range(n_rows):
for j in range(n_cols):
x = x0 + j * cw
y = y0 + (n_rows - 1 - i) * ch
fill = fills[(i + j) % 2]
ax.add_patch(
plt.Rectangle((x, y), cw, ch, lw=0.8, edgecolor=LN, facecolor=fill)
)
# Sample content in a few cells
examples = {
(0, 0): "Lista\nencji",
(0, 1): "Lista\nproces\u00f3w",
(0, 2): "Lokalizacje",
(1, 0): "Model\npoj\u0119ciowy",
(1, 1): "Model\nproces\u00f3w",
(2, 0): "ERD",
(2, 1): "Data Flow",
(3, 0): "Schemat\nDB",
(3, 1): "Kod\nprogramu",
(0, 3): "Role",
(1, 3): "Org chart",
(0, 4): "Harmonogram",
(0, 5): "Cele\nbiznesowe",
}
for (i, j), text in examples.items():
x = x0 + j * cw
y = y0 + (n_rows - 1 - i) * ch
ax.text(
x + cw / 2,
y + ch / 2,
text,
ha="center",
va="center",
fontsize=5.5,
fontstyle="italic",
color="#444444",
)
# Note
ax.text(
50,
1,
"Każda komórka = artefakt opisujący system"
" z danej perspektywy i aspektu.\n"
"Zachman nie mówi JAK modelować"
" — mówi CO należy udokumentować.",
ha="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "zachman_framework.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK Zachman Framework")
# =========================================================================
# 5. ArchiMate Layers
# =========================================================================
def generate_archimate() -> None:
"""Generate archimate."""
fig, ax = plt.subplots(figsize=(8.27, 9))
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"ArchiMate \u2014 3 warstwy \u00d7 3 aspekty",
fontsize=FS_TITLE,
fontweight="bold",
pad=12,
)
# Column headers (aspects)
headers = [
("Active Structure\n(KTO?)", 0),
("Behavior\n(CO robi?)", 1),
("Passive Structure\n(NA CZYM?)", 2),
]
x0 = 10
y0 = 10
cw = 26
ch = 20
gap = 1
header_h = 8
row_label_w = 14
# Column headers
for label, j in headers:
x = x0 + row_label_w + j * (cw + gap)
draw_box(
ax,
x,
y0 + 3 * (ch + gap),
cw,
header_h,
label,
fill=GRAY3,
lw=1.5,
fontsize=8,
fontweight="bold",
)
# Layer rows
layers = [
(
"Business\nLayer",
GRAY1,
[
("Business\nActor", "Business\nProcess", "Business\nObject"),
("(Kto wykonuje?)", "(Co si\u0119 dzieje?)", "(Na czym dzia\u0142a?)"),
(
"np. Klient,\nHandlowiec",
"np. Obs\u0142uga\nzam\u00f3wienia",
"np. Zam\u00f3wienie,\nFaktura",
),
],
),
(
"Application\nLayer",
GRAY4,
[
("Application\nComponent", "Application\nService", "Data\nObject"),
("(Jaki modu\u0142?)", "(Jaka us\u0142uga?)", "(Jakie dane?)"),
("np. CRM,\nERP", "np. API\nzam\u00f3wie\u0144", "np. tabela\nOrders"),
],
),
(
"Technology\nLayer",
"white",
[
("Node /\nDevice", "Infrastructure\nService", "Artifact"),
("(Jaki sprz\u0119t?)", "(Jaka infra?)", "(Jaki plik?)"),
(
"np. Serwer\nLinux, K8s",
"np. Load\nBalancer",
"np. .jar,\n.war, image",
),
],
),
]
for i, (layer_name, fill, cells) in enumerate(layers):
y = y0 + (2 - i) * (ch + gap)
# Row label
draw_box(
ax,
x0,
y,
row_label_w,
ch,
layer_name,
fill=GRAY2,
lw=1.5,
fontsize=8,
fontweight="bold",
)
for j in range(3):
x = x0 + row_label_w + j * (cw + gap)
ax.add_patch(
plt.Rectangle((x, y), cw, ch, lw=1.5, edgecolor=LN, facecolor=fill)
)
# Element name (bold)
ax.text(
x + cw / 2,
y + ch - 3,
cells[0][j],
ha="center",
va="top",
fontsize=7,
fontweight="bold",
)
# Role description
ax.text(
x + cw / 2,
y + ch / 2,
cells[1][j],
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#555555",
)
# Example
ax.text(
x + cw / 2,
y + 3,
cells[2][j],
ha="center",
va="bottom",
fontsize=6,
color="#333333",
)
# Vertical arrows between layers
for j in range(3):
x = x0 + row_label_w + j * (cw + gap) + cw / 2
for i in range(2):
y_top = y0 + (2 - i) * (ch + gap)
y_bot = y0 + (2 - i - 1) * (ch + gap) + ch
draw_arrow(ax, x, y_top, x, y_bot + 0.3, lw=1)
# Arrow labels
mid_x = x0 + row_label_w - 3
ax.text(
mid_x,
y0 + 2 * (ch + gap) - gap / 2,
"realizacja \u2193",
fontsize=6,
ha="right",
va="center",
fontstyle="italic",
rotation=90,
)
ax.text(
mid_x,
y0 + 1 * (ch + gap) - gap / 2,
"realizacja \u2193",
fontsize=6,
ha="right",
va="center",
fontstyle="italic",
rotation=90,
)
# Note
ax.text(
50,
4,
"Warstwy czytamy z g\u00f3ry (biznes) na d\u00f3\u0142 (technologia).\n"
"Ni\u017csze warstwy REALIZUJ\u0104 wy\u017csze. "
"ArchiMate jest komplementarny z TOGAF.",
ha="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "archimate_layers.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK ArchiMate")

View File

@ -0,0 +1,224 @@
"""Shared constants, dataclasses, and drawing helpers for automata diagrams."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib as mpl
mpl.use("Agg")
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
DPI = 300
BG = "white"
LN = "black"
FS = 8
FS_TITLE = 11
FS_SMALL = 6.5
OUTPUT_DIR = str(Path(__file__).resolve().parent / "img")
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
GRAY1 = "#E8E8E8"
GRAY2 = "#D0D0D0"
GRAY3 = "#B8B8B8"
GRAY4 = "#F5F5F5"
GRAY5 = "#C0C0C0"
LIGHT_GREEN = "#D5E8D4"
LIGHT_RED = "#F8D7DA"
LIGHT_BLUE = "#D6EAF8"
LIGHT_YELLOW = "#FFF9C4"
INNER_RATIO = 0.82
ARROW_OFFSET = 0.4
LOOP_RAD = 1.8
LOOP_OFFSET = 0.12
LOOP_LABEL_OFFSET = 0.35
MUTATION_SCALE = 12
HEAD_MARKER_FONTSIZE = 8
@dataclass(frozen=True)
class StateStyle:
"""Optional styling for automaton state circles."""
accepting: bool = False
initial: bool = False
fillcolor: str = "white"
fontsize: float = FS
@dataclass(frozen=True)
class ArrowStyle:
"""Optional styling for curved arrows."""
connectionstyle: str = "arc3,rad=0.3"
fontsize: float = FS_SMALL
label_offset: tuple[float, float] = (0, 0)
@dataclass(frozen=True)
class LoopStyle:
"""Optional styling for self-loops."""
direction: str = "top"
fontsize: float = FS_SMALL
def draw_state_circle(
ax: Axes,
pos: tuple[float, float],
r: float,
label: str,
style: StateStyle | None = None,
) -> None:
"""Draw an automaton state circle."""
s = style or StateStyle()
x, y = pos
circle = plt.Circle(
(x, y),
r,
fill=True,
facecolor=s.fillcolor,
edgecolor=LN,
linewidth=1.5,
zorder=3,
)
ax.add_patch(circle)
if s.accepting:
inner = plt.Circle(
(x, y),
r * INNER_RATIO,
fill=False,
edgecolor=LN,
linewidth=1.2,
zorder=3,
)
ax.add_patch(inner)
if s.initial:
ax.annotate(
"",
xy=(x - r, y),
xytext=(x - r - ARROW_OFFSET, y),
arrowprops={
"arrowstyle": "->",
"color": LN,
"lw": 1.5,
},
zorder=4,
)
ax.text(
x,
y,
label,
ha="center",
va="center",
fontsize=s.fontsize,
fontweight="bold",
zorder=5,
)
def draw_curved_arrow(
ax: Axes,
start: tuple[float, float],
end: tuple[float, float],
label: str,
style: ArrowStyle | None = None,
) -> None:
"""Draw a curved arrow between points with label."""
s = style or ArrowStyle()
x1, y1 = start
x2, y2 = end
ax.annotate(
"",
xy=(x2, y2),
xytext=(x1, y1),
arrowprops={
"arrowstyle": "->",
"color": LN,
"lw": 1.2,
"connectionstyle": s.connectionstyle,
},
zorder=2,
)
mx = (x1 + x2) / 2 + s.label_offset[0]
my = (y1 + y2) / 2 + s.label_offset[1]
ax.text(
mx,
my,
label,
ha="center",
va="center",
fontsize=s.fontsize,
fontstyle="italic",
zorder=5,
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": "white",
"edgecolor": "none",
"alpha": 0.9,
},
)
def draw_self_loop(
ax: Axes,
pos: tuple[float, float],
r: float,
label: str,
style: LoopStyle | None = None,
) -> None:
"""Draw a self-loop on a state."""
s = style or LoopStyle()
x, y = pos
if s.direction == "top":
loop = mpatches.FancyArrowPatch(
(x - LOOP_OFFSET, y + r),
(x + LOOP_OFFSET, y + r),
connectionstyle=f"arc3,rad=-{LOOP_RAD}",
arrowstyle="->",
mutation_scale=MUTATION_SCALE,
lw=1.2,
color=LN,
zorder=2,
)
ax.add_patch(loop)
ax.text(
x,
y + r + LOOP_LABEL_OFFSET,
label,
ha="center",
va="center",
fontsize=s.fontsize,
fontstyle="italic",
zorder=5,
)
elif s.direction == "bottom":
loop = mpatches.FancyArrowPatch(
(x - LOOP_OFFSET, y - r),
(x + LOOP_OFFSET, y - r),
connectionstyle=f"arc3,rad={LOOP_RAD}",
arrowstyle="->",
mutation_scale=MUTATION_SCALE,
lw=1.2,
color=LN,
zorder=2,
)
ax.add_patch(loop)
ax.text(
x,
y - r - LOOP_LABEL_OFFSET,
label,
ha="center",
va="center",
fontsize=s.fontsize,
fontstyle="italic",
zorder=5,
)

View File

@ -0,0 +1,225 @@
"""FA recognition diagram — DFA for strings ending in 'ab'."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._automata_common import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY2,
GRAY3,
GRAY4,
LIGHT_GREEN,
LN,
OUTPUT_DIR,
ArrowStyle,
LoopStyle,
StateStyle,
draw_curved_arrow,
draw_self_loop,
draw_state_circle,
)
logger = logging.getLogger(__name__)
def draw_fa_recognition() -> None:
"""FA state diagram + step-by-step trace for 'baab'."""
_fig, axes = plt.subplots(
1,
2,
figsize=(11.69, 4),
gridspec_kw={"width_ratios": [1, 1.3]},
)
# --- Left: State diagram ---
ax = axes[0]
ax.set_xlim(-1, 5.5)
ax.set_ylim(-1.5, 2.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"DFA — diagram stanów\n"
'L = {słowa nad {a,b} kończące się na "ab"}',
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
state_r = 0.35
states = {
"q₀": (0.8, 0.5),
"q₁": (2.8, 0.5),
"q₂": (4.8, 0.5),
}
draw_state_circle(
ax,
states["q₀"],
state_r,
"q₀",
StateStyle(initial=True),
)
draw_state_circle(ax, states["q₁"], state_r, "q₁")
draw_state_circle(
ax,
states["q₂"],
state_r,
"q₂",
StateStyle(
accepting=True, fillcolor=LIGHT_GREEN
),
)
# Transitions
# q₀ --a--> q₁
draw_curved_arrow(
ax,
(states["q₀"][0] + state_r, states["q₀"][1] + 0.05),
(states["q₁"][0] - state_r, states["q₁"][1] + 0.05),
"a",
ArrowStyle(
connectionstyle="arc3,rad=0.15",
label_offset=(0, 0.25),
),
)
# q₁ --b--> q₂
draw_curved_arrow(
ax,
(states["q₁"][0] + state_r, states["q₁"][1] + 0.05),
(states["q₂"][0] - state_r, states["q₂"][1] + 0.05),
"b",
ArrowStyle(
connectionstyle="arc3,rad=0.15",
label_offset=(0, 0.25),
),
)
# q₂ --a--> q₁
draw_curved_arrow(
ax,
(states["q₂"][0] - state_r, states["q₂"][1] - 0.05),
(states["q₁"][0] + state_r, states["q₁"][1] - 0.05),
"a",
ArrowStyle(
connectionstyle="arc3,rad=0.15",
label_offset=(0, -0.3),
),
)
# q₂ --b--> q₀
draw_curved_arrow(
ax,
(states["q₂"][0] - 0.2, states["q₂"][1] - state_r),
(states["q₀"][0] + 0.2, states["q₀"][1] - state_r),
"b",
ArrowStyle(
connectionstyle="arc3,rad=0.4",
label_offset=(0, -0.4),
),
)
# q₀ --b--> q₀ (self-loop)
draw_self_loop(
ax,
states["q₀"],
state_r,
"b",
LoopStyle(direction="top"),
)
# q₁ --a--> q₁ (self-loop)
draw_self_loop(
ax,
states["q₁"],
state_r,
"a",
LoopStyle(direction="top"),
)
# Legend
ax.text(
0.3,
-1.0,
"→ = start ◎ = akceptujący",
fontsize=FS_SMALL,
ha="left",
va="center",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
# --- Right: Step-by-step trace ---
ax2 = axes[1]
ax2.axis("off")
ax2.set_title(
'Ślad wykonania — wejście: "baab"',
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
trace_data = [
[
"Krok",
"Czytam",
"Stan przed",
"Przejście",
"Stan po",
],
["", "", "q₀ (start)", "", "q₀"],
["1", "b", "q₀", "δ(q₀, b) = q₀", "q₀"],
["2", "a", "q₀", "δ(q₀, a) = q₁", "q₁"],
["3", "a", "q₁", "δ(q₁, a) = q₁", "q₁"],
["4", "b", "q₁", "δ(q₁, b) = q₂", "q₂ ✓"],
]
colors = [GRAY2] + ["white"] * 4 + [LIGHT_GREEN]
table = ax2.table(
cellText=trace_data,
cellLoc="center",
loc="center",
bbox=[0.05, 0.15, 0.9, 0.75],
)
table.auto_set_font_size(auto=False)
table.set_fontsize(FS)
for (row, _col), cell in table.get_celld().items():
cell.set_edgecolor(GRAY3)
if row == 0:
cell.set_facecolor(GRAY2)
cell.set_text_props(fontweight="bold")
else:
cell.set_facecolor(colors[row])
cell.set_height(0.12)
ax2.text(
0.5,
0.05,
'Wynik: q₂ ∈ F → "baab" AKCEPTOWANE ✓',
ha="center",
va="center",
fontsize=FS + 1,
fontweight="bold",
transform=ax2.transAxes,
bbox={
"boxstyle": "round,pad=0.4",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "fa_recognition_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
logger.info(" ✓ fa_recognition_example.png")

View File

@ -0,0 +1,306 @@
"""LBA recognition diagram — LBA for a^n b^n c^n."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._automata_common import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY3,
GRAY4,
HEAD_MARKER_FONTSIZE,
LIGHT_GREEN,
LIGHT_YELLOW,
LN,
OUTPUT_DIR,
)
logger = logging.getLogger(__name__)
def draw_lba_recognition() -> None:
"""LBA tape visualization showing marking rounds for 'aabbcc'."""
_fig, ax = plt.subplots(1, 1, figsize=(11.69, 6.5))
ax.set_xlim(-0.5, 12)
ax.set_ylim(-1, 10.5)
ax.axis("off")
ax.set_title(
"LBA — rozpoznawanie aⁿbⁿcⁿ (n=2)\n"
"Strategia: w każdej rundzie zaznacz jedno a, b, c",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
cell_w = 0.9
cell_h = 0.7
tape_x0 = 1.5
head_color = "#FFD700"
def draw_tape(
tape_y: float,
cells: list[tuple[str, str]],
head_pos: int | None,
label: str,
*,
step_label: str = "",
) -> None:
"""Draw a tape row with cells, head highlighted."""
ax.text(
0.2,
tape_y + cell_h / 2,
label,
ha="right",
va="center",
fontsize=FS,
fontweight="bold",
)
for i, (sym, color) in enumerate(cells):
x = tape_x0 + i * cell_w
fc = head_color if i == head_pos else color
rect = mpatches.FancyBboxPatch(
(x, tape_y),
cell_w,
cell_h,
boxstyle="round,pad=0.03",
lw=1.2,
edgecolor=LN,
facecolor=fc,
)
ax.add_patch(rect)
bold = (
"bold"
if sym in ("X", "Y", "Z")
else "normal"
)
ax.text(
x + cell_w / 2,
tape_y + cell_h / 2,
sym,
ha="center",
va="center",
fontsize=FS + 2,
fontweight=bold,
family="monospace",
)
if head_pos is not None:
hx = (
tape_x0
+ head_pos * cell_w
+ cell_w / 2
)
ax.annotate(
"",
xy=(hx, tape_y + cell_h),
xytext=(hx, tape_y + cell_h + 0.25),
ha="center",
va="bottom",
fontsize=HEAD_MARKER_FONTSIZE,
color="black",
)
if step_label:
sx = tape_x0 + 6 * cell_w + 0.5
ax.text(
sx,
tape_y + cell_h / 2,
step_label,
ha="left",
va="center",
fontsize=FS_SMALL,
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
white = "white"
mk = GRAY1 # marked cell color
# Row 1: Initial tape
tape_y = 9.0
draw_tape(
tape_y,
[
("a", white),
("a", white),
("b", white),
("b", white),
("c", white),
("c", white),
],
0,
"Początek",
step_label=(
"taśma = [a, a, b, b, c, c], głowica na 0"
),
)
# Row 2: After marking first 'a'
tape_y = 7.8
draw_tape(
tape_y,
[
("X", mk),
("a", white),
("b", white),
("b", white),
("c", white),
("c", white),
],
1,
"R1, krok 1",
step_label="zaznacz a→X, szukaj b",
)
# Row 3: After marking first 'b'
tape_y = 6.6
draw_tape(
tape_y,
[
("X", mk),
("a", white),
("Y", mk),
("b", white),
("c", white),
("c", white),
],
3,
"R1, krok 2",
step_label="zaznacz b→Y, szukaj c",
)
# Row 4: After marking first 'c'
tape_y = 5.4
draw_tape(
tape_y,
[
("X", mk),
("a", white),
("Y", mk),
("b", white),
("Z", mk),
("c", white),
],
0,
"R1, krok 3",
step_label="zaznacz c→Z, wróć na początek",
)
# Runda 2 header
tape_y = 4.5
ax.text(
tape_x0 + 3 * cell_w,
tape_y + 0.3,
"═══ RUNDA 2 ═══",
ha="center",
va="center",
fontsize=FS,
fontweight="bold",
color=LN,
)
# Row 5: After marking second 'a'
tape_y = 3.6
draw_tape(
tape_y,
[
("X", mk),
("X", mk),
("Y", mk),
("b", white),
("Z", mk),
("c", white),
],
2,
"R2, krok 1",
step_label="pomiń X, zaznacz a→X, szukaj b",
)
# Row 6: After marking second 'b'
tape_y = 2.4
draw_tape(
tape_y,
[
("X", mk),
("X", mk),
("Y", mk),
("Y", mk),
("Z", mk),
("c", white),
],
4,
"R2, krok 2",
step_label="pomiń Y, zaznacz b→Y, szukaj c",
)
# Row 7: After marking second 'c'
tape_y = 1.2
draw_tape(
tape_y,
[
("X", mk),
("X", mk),
("Y", mk),
("Y", mk),
("Z", mk),
("Z", mk),
],
None,
"R2, krok 3",
step_label="zaznacz c→Z, wróć na początek",
)
# Result
tape_y = 0.0
ax.text(
tape_x0 + 3 * cell_w,
tape_y + 0.3,
"Wszystko zaznaczone → q_acc"
'"aabbcc" AKCEPTOWANE ✓',
ha="center",
va="center",
fontsize=FS + 1,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.4",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
# Key
ax.text(
tape_x0 + 6 * cell_w + 0.5,
tape_y + 0.3,
"Ograniczenie LBA:\n"
"głowica ≤ 6 komórek\n"
'(= |w| = |"aabbcc"|)',
ha="left",
va="center",
fontsize=FS_SMALL,
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_YELLOW,
"edgecolor": GRAY3,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "lba_recognition_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
logger.info(" ✓ lba_recognition_example.png")

View File

@ -0,0 +1,219 @@
"""PDA recognition diagram — PDA for a^n b^n."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._automata_common import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY2,
GRAY3,
GRAY4,
LIGHT_BLUE,
LIGHT_GREEN,
LIGHT_YELLOW,
LN,
OUTPUT_DIR,
ArrowStyle,
LoopStyle,
StateStyle,
draw_curved_arrow,
draw_self_loop,
draw_state_circle,
)
logger = logging.getLogger(__name__)
def draw_pda_recognition() -> None:
"""PDA state diagram + step-by-step trace with stack."""
_fig, axes = plt.subplots(
1,
2,
figsize=(11.69, 5.5),
gridspec_kw={"width_ratios": [1, 1.4]},
)
# --- Left: State diagram ---
ax = axes[0]
ax.set_xlim(-1, 5.5)
ax.set_ylim(-2, 3)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"PDA — diagram stanów\nL = {aⁿbⁿ | n ≥ 1}",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
state_r = 0.38
states = {
"q₀": (0.8, 0.5),
"q₁": (2.8, 0.5),
"q₂": (4.8, 0.5),
}
draw_state_circle(
ax,
states["q₀"],
state_r,
"q₀",
StateStyle(initial=True),
)
draw_state_circle(ax, states["q₁"], state_r, "q₁")
draw_state_circle(
ax,
states["q₂"],
state_r,
"q₂",
StateStyle(
accepting=True, fillcolor=LIGHT_GREEN
),
)
# q₀ --b,A/ε--> q₁
draw_curved_arrow(
ax,
(states["q₀"][0] + state_r, states["q₀"][1]),
(states["q₁"][0] - state_r, states["q₁"][1]),
"b, A → ε\n(pop A)",
ArrowStyle(
connectionstyle="arc3,rad=0.0",
label_offset=(0, 0.4),
),
)
# q₁ --ε,Z₀/Z₀--> q₂
draw_curved_arrow(
ax,
(states["q₁"][0] + state_r, states["q₁"][1]),
(states["q₂"][0] - state_r, states["q₂"][1]),
"ε, Z₀ → Z₀\n(akceptuj)",
ArrowStyle(
connectionstyle="arc3,rad=0.0",
label_offset=(0, 0.45),
),
)
# q₀ self-loop: a, Z₀/AZ₀ and a, A/AA
draw_self_loop(
ax,
states["q₀"],
state_r,
"a, Z₀ → AZ₀\na, A → AA\n(push A)",
LoopStyle(direction="top"),
)
# q₁ self-loop: b, A/ε
draw_self_loop(
ax,
states["q₁"],
state_r,
"b, A → ε\n(pop A)",
LoopStyle(direction="top"),
)
# Key explanation
ax.text(
0.3,
-1.3,
"Notacja: symbol_wejścia, szczyt_stosu"
" → nowy_szczyt\n"
"ε = brak symbolu "
"(przejście spontaniczne lub pusty stos)",
fontsize=FS_SMALL,
ha="left",
va="center",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
# --- Right: Step trace with stack ---
ax2 = axes[1]
ax2.axis("off")
ax2.set_title(
"Ślad wykonania z wizualizacją stosu"
' — wejście: "aabb"',
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
trace_data = [
[
"Krok",
"Czytam",
"Stan",
"Stos (szczyt→)",
"Operacja",
],
["start", "", "q₀", "[Z₀]", ""],
["1", "a", "q₀", "[A, Z₀]", "push A"],
["2", "a", "q₀", "[A, A, Z₀]", "push A"],
["3", "b", "q₁", "[A, Z₀]", "pop A"],
["4", "b", "q₁", "[Z₀]", "pop A"],
["5", "ε", "q₂", "[Z₀]", "akceptuj!"],
]
colors = [
GRAY2,
"white",
LIGHT_BLUE,
LIGHT_BLUE,
LIGHT_YELLOW,
LIGHT_YELLOW,
LIGHT_GREEN,
]
table = ax2.table(
cellText=trace_data,
cellLoc="center",
loc="center",
bbox=[0.02, 0.08, 0.96, 0.82],
)
table.auto_set_font_size(auto=False)
table.set_fontsize(FS)
for (row, _col), cell in table.get_celld().items():
cell.set_edgecolor(GRAY3)
if row == 0:
cell.set_facecolor(GRAY2)
cell.set_text_props(fontweight="bold")
else:
cell.set_facecolor(colors[row])
cell.set_height(0.11)
ax2.text(
0.5,
0.0,
"Wynik: q₂ ∈ F, stos=[Z₀]"
'"aabb" AKCEPTOWANE ✓\n'
'Intuicja: 2x push A (za "aa") '
'+ 2x pop A (za "bb") = stos pusty = OK',
ha="center",
va="center",
fontsize=FS,
fontweight="bold",
transform=ax2.transAxes,
bbox={
"boxstyle": "round,pad=0.4",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "pda_recognition_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
logger.info(" ✓ pda_recognition_example.png")

View File

@ -0,0 +1,229 @@
"""TM recognition diagram — TM for 0^n 1^n."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._automata_common import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY3,
GRAY4,
HEAD_MARKER_FONTSIZE,
LIGHT_GREEN,
LIGHT_YELLOW,
LN,
OUTPUT_DIR,
)
logger = logging.getLogger(__name__)
def draw_tm_recognition() -> None:
"""TM tape visualization for 0ⁿ1ⁿ with infinite tape."""
_fig, ax = plt.subplots(1, 1, figsize=(11.69, 6.5))
ax.set_xlim(-0.5, 13)
ax.set_ylim(-1, 10.5)
ax.axis("off")
ax.set_title(
"TM — rozpoznawanie 0ⁿ1ⁿ (n=2)\n"
"Strategia: zaznacz jedno 0 i jedno 1"
" w każdej rundzie",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
cell_w = 0.9
cell_h = 0.7
tape_x0 = 1.5
head_color = "#FFD700"
def draw_tape(
tape_y: float,
cells: list[tuple[str, str]],
head_pos: int | None,
label: str,
*,
step_label: str = "",
) -> None:
"""Draw tape."""
ax.text(
0.2,
tape_y + cell_h / 2,
label,
ha="right",
va="center",
fontsize=FS,
fontweight="bold",
)
for i, (sym, color) in enumerate(cells):
x = tape_x0 + i * cell_w
fc = head_color if i == head_pos else color
lw = 1.2
ls = "-"
if sym == "":
ls = "--"
rect = mpatches.FancyBboxPatch(
(x, tape_y),
cell_w,
cell_h,
boxstyle="round,pad=0.03",
lw=lw,
edgecolor=LN,
facecolor=fc,
linestyle=ls,
)
ax.add_patch(rect)
bold = (
"bold" if sym in ("X", "Y") else "normal"
)
clr = GRAY3 if sym == "" else LN
ax.text(
x + cell_w / 2,
tape_y + cell_h / 2,
sym,
ha="center",
va="center",
fontsize=FS + 2,
fontweight=bold,
family="monospace",
color=clr,
)
# ∞ arrow
last_x = tape_x0 + len(cells) * cell_w
ax.annotate(
"→ ∞",
xy=(last_x + 0.3, tape_y + cell_h / 2),
fontsize=FS,
ha="left",
va="center",
color=GRAY3,
)
if head_pos is not None:
hx = (
tape_x0
+ head_pos * cell_w
+ cell_w / 2
)
ax.annotate(
"",
xy=(hx, tape_y + cell_h),
xytext=(hx, tape_y + cell_h + 0.25),
ha="center",
va="bottom",
fontsize=HEAD_MARKER_FONTSIZE,
color="black",
)
if step_label:
sx = tape_x0 + 8 * cell_w + 0.8
ax.text(
sx,
tape_y + cell_h / 2,
step_label,
ha="left",
va="center",
fontsize=FS_SMALL,
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
white = "white"
mk = GRAY1
bl = "#F0F0F0" # blank cell
tape_rows = [
(9.0, [("0", white), ("0", white), ("1", white),
("1", white), ("", bl), ("", bl), ("", bl)],
0, "Początek", "taśma = [0,0,1,1,⊔,⊔,...∞]"),
(7.8, [("X", mk), ("0", white), ("1", white),
("1", white), ("", bl), ("", bl), ("", bl)],
1, "R1, krok 1", "zaznacz 0→X, idź w prawo"),
(6.6, [("X", mk), ("0", white), ("Y", mk),
("1", white), ("", bl), ("", bl), ("", bl)],
0, "R1, krok 2", "zaznacz 1→Y, wróć na początek"),
(4.8, [("X", mk), ("X", mk), ("Y", mk),
("1", white), ("", bl), ("", bl), ("", bl)],
2, "R2, krok 1", "pomiń X, zaznacz 0→X"),
(3.6, [("X", mk), ("X", mk), ("Y", mk),
("Y", mk), ("", bl), ("", bl), ("", bl)],
0, "R2, krok 2", "pomiń Y, zaznacz 1→Y, wróć"),
(2.4, [("X", mk), ("X", mk), ("Y", mk),
("Y", mk), ("", bl), ("", bl), ("", bl)],
None, "Sprawdzenie",
"brak niezaznaczonych → q_acc"),
]
# Runda 2 header
runda2_y = 5.8
ax.text(
tape_x0 + 3.5 * cell_w,
runda2_y + 0.3,
"═══ RUNDA 2 ═══",
ha="center",
va="center",
fontsize=FS,
fontweight="bold",
)
for row_y, cells, head, lbl, step in tape_rows:
draw_tape(
row_y, cells, head, lbl, step_label=step
)
# Result + TM vs LBA comparison
tape_y = 0.8
ax.text(
tape_x0 + 3.5 * cell_w,
tape_y + 0.3,
'"0011" AKCEPTOWANE ✓',
ha="center",
va="center",
fontsize=FS + 1,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.4",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
tape_y = -0.3
ax.text(
tape_x0 + 3.5 * cell_w,
tape_y + 0.3,
"Różnica TM vs LBA: taśma TM jest "
"nieskończona (⊔ → ∞)\n"
"LBA: głowica ograniczona do |w| komórek\n"
"TM: głowica może wyjść POZA wejście "
"i pisać na pustych ⊔",
ha="center",
va="center",
fontsize=FS_SMALL,
bbox={
"boxstyle": "round,pad=0.4",
"facecolor": LIGHT_YELLOW,
"edgecolor": GRAY3,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "tm_recognition_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
logger.info(" ✓ tm_recognition_example.png")

View File

@ -0,0 +1,427 @@
"""Bellman-Ford negative weight and cycle diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY3,
GRAY4,
LIGHT_GREEN,
LIGHT_RED,
LIGHT_YELLOW,
LN,
NEG_EDGES,
NEG_POS,
OUTPUT_DIR,
draw_neg_graph,
)
_logger = logging.getLogger(__name__)
def _add_annotation_box(
ax: Axes,
x: float,
y: float,
text: str,
*,
color: str,
bg_color: str,
) -> None:
"""Add a small annotation box near a node."""
ax.text(
x,
y,
text,
fontsize=FS_SMALL,
color=color,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": bg_color,
"edgecolor": color,
"alpha": 0.9,
"lw": 0.5,
},
)
def generate_bf_negative_weights() -> None:
"""Generate two-row figure.
Row 1: Graph structure + Dijkstra WRONG + Bellman-Ford CORRECT
Row 2: B-F iterations 1-3 step by step.
"""
fig = plt.figure(figsize=(14, 10))
fig.suptitle(
"Bellman-Ford \u2014 ujemne wagi vs Dijkstra\n"
"Graf: S\u2192A(2), A\u2192C(3),"
" S\u2192B(5), B\u2192A(-4). Start = S",
fontsize=FS_TITLE + 1,
fontweight="bold",
y=0.99,
)
# Row 1: Graph + Dijkstra wrong + BF correct
# Panel 1: The graph structure
ax1 = fig.add_subplot(2, 3, 1)
draw_neg_graph(
ax1,
NEG_EDGES,
title=(
"Graf z ujemną wagą\n"
"(B→A = -4, zaznaczona na czerwono)"
),
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
)
ax1.annotate(
"START",
xy=(NEG_POS["S"][0] - 0.35, NEG_POS["S"][1]),
xytext=(NEG_POS["S"][0] - 1.2, NEG_POS["S"][1]),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
arrowprops={
"arrowstyle": "->",
"color": "#D32F2F",
"lw": 2,
},
va="center",
)
# Panel 2: Dijkstra — WRONG
ax2 = fig.add_subplot(2, 3, 2)
draw_neg_graph(
ax2,
NEG_EDGES,
title=(
"Dijkstra \u2014 BŁĘDNY wynik\n"
"A zamknięty z d=2, nie poprawia przy B→A"
),
dist={"S": "0", "A": "2", "B": "5", "C": "5"},
visited={"S", "A", "B", "C"},
error_nodes={"A", "C"},
)
_add_annotation_box(
ax2,
NEG_POS["A"][0] + 0.6,
NEG_POS["A"][1] + 0.3,
"✗ powinno 1",
color="#D32F2F",
bg_color=LIGHT_RED,
)
_add_annotation_box(
ax2,
NEG_POS["C"][0] + 0.05,
NEG_POS["C"][1] + 0.55,
"✗ powinno 4",
color="#D32F2F",
bg_color=LIGHT_RED,
)
# Panel 3: Bellman-Ford — CORRECT
ax3 = fig.add_subplot(2, 3, 3)
draw_neg_graph(
ax3,
NEG_EDGES,
title=(
"Bellman-Ford \u2014 POPRAWNY wynik\n"
"Ujemna waga B→A poprawnie propagowana"
),
dist={"S": "0", "A": "1", "B": "5", "C": "4"},
visited={"S", "A", "B", "C"},
relaxed_edges={("B", "A")},
)
_add_annotation_box(
ax3,
NEG_POS["A"][0] + 0.6,
NEG_POS["A"][1] + 0.3,
"✓ poprawne!",
color="#006400",
bg_color=LIGHT_GREEN,
)
_add_annotation_box(
ax3,
NEG_POS["C"][0] + 0.05,
NEG_POS["C"][1] + 0.55,
"✓ poprawne!",
color="#006400",
bg_color=LIGHT_GREEN,
)
# Row 2: B-F iterations step by step
iterations = [
{
"title": (
"B-F Iteracja 1\n"
"Relaksuj WSZYSTKIE krawędzie"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "5",
},
"relaxed": {
("S", "A"), ("A", "C"),
("S", "B"), ("B", "A"),
},
"detail": (
"S→A: 0+2=2<∞ → A=2\n"
"A→C: 2+3=5<∞ → C=5\n"
"S→B: 0+5=5<∞ → B=5\n"
"B→A: 5-4=1<2 → A=1 ✓"
),
},
{
"title": (
"B-F Iteracja 2\n"
"Propagacja poprawionego A"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "4",
},
"relaxed": {("A", "C")},
"detail": (
"S→A: 0+2=2>1 ✗\n"
"A→C: 1+3=4<5 → C=4 ✓\n"
"S→B: 0+5=5=5 ✗\n"
"B→A: 5-4=1=1 ✗"
),
},
{
"title": (
"B-F Iteracja 3\n"
"Brak zmian → stabilne!"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "4",
},
"relaxed": set(),
"detail": (
"Wszystkie krawędzie:\n"
"brak poprawy ✗\n"
"→ wynik stabilny\n"
"→ BRAK cyklu ujemnego"
),
},
]
for i, it in enumerate(iterations):
ax = fig.add_subplot(2, 3, i + 4)
draw_neg_graph(
ax,
NEG_EDGES,
title=it["title"],
dist=it["dist"],
visited={"S", "A", "B", "C"},
relaxed_edges=it["relaxed"],
)
ax.text(
3.2,
-0.5,
it["detail"],
ha="center",
va="top",
fontsize=FS_SMALL,
family="monospace",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
# Bottom note
fig.text(
0.5,
0.01,
"Dijkstra zamyka wierzchołki na stałe"
" (zachłanność) → ujemna waga B→A(-4)"
" nie może poprawić zamkniętego A.\n"
"Bellman-Ford relaksuje WSZYSTKIE krawędzie"
" w każdej iteracji → ujemne wagi"
" propagują się poprawnie.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_YELLOW,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.05, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_negative_weights.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ bellman_ford_negative_weights.png")
def generate_bf_negative_cycle() -> None:
"""Generate figure showing negative cycle detection.
Graph: S->A(2), A->C(3), S->B(5), B->A(-4), C->B(-3)
Cycle: B->A->C->B = -4+3+(-3) = -4 < 0.
"""
fig = plt.figure(figsize=(14, 5.5))
fig.suptitle(
"Bellman-Ford \u2014 wykrywanie cyklu ujemnego\n"
"Dodano krawędź C→B(-3)."
" Cykl: B→A→C→B = -4+3+(-3) = -4 < 0",
fontsize=FS_TITLE + 1,
fontweight="bold",
y=0.99,
)
# Panel 1: Graph with cycle highlighted
ax1 = fig.add_subplot(1, 3, 1)
draw_neg_graph(
ax1,
NEG_EDGES,
title=(
"Graf z cyklem ujemnym\n"
"Dodana krawędź C→B(-3) \u2014 przerywana"
),
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
extra_edges=[("C", "B", -3)],
)
ax1.annotate(
"CYKL\n-4+3+(-3)=-4<0",
xy=(3.3, 2.0),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
ha="center",
va="center",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
"alpha": 0.9,
},
)
# Panel 2: After V-1 iterations — still changing
ax2 = fig.add_subplot(1, 3, 2)
draw_neg_graph(
ax2,
NEG_EDGES,
title=(
"Po V-1=3 iteracjach\n"
"dist wciąż maleje (niestabilne!)"
),
dist={"S": "0", "A": "-7", "B": "-4", "C": "-4"},
visited={"S", "A", "B", "C"},
error_nodes={"A", "B", "C"},
extra_edges=[("C", "B", -3)],
)
ax2.text(
3.2,
-0.4,
"Każde okrążenie cyklu\n"
"zmniejsza dist o 4.\n"
"Dist → -∞ (brak minimum!)",
ha="center",
va="top",
fontsize=FS_SMALL,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
},
)
# Panel 3: V-th iteration detects
ax3 = fig.add_subplot(1, 3, 3)
ax3.axis("off")
ax3.set_xlim(0, 10)
ax3.set_ylim(0, 10)
detection_text = (
"V-ta iteracja (sprawdzenie):\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"for (src, dst, w) in edges:\n"
" if dist[src]+w < dist[dst]:\n"
" return None # CYKL!\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"Sprawdzamy np. krawędź B→A:\n"
" dist[B] + (-4) = -4 + (-4) = -8\n"
" -8 < dist[A] = -7\n"
" → NADAL SIĘ POPRAWIA!\n"
" → CYKL UJEMNY WYKRYTY!\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"Wynik: return None\n"
"(najkrótsza ścieżka nie istnieje)"
)
ax3.text(
5,
5,
detection_text,
ha="center",
va="center",
fontsize=FS + 0.5,
family="monospace",
bbox={
"boxstyle": "round,pad=0.6",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
"lw": 2,
},
)
ax3.set_title(
"Wykrywanie \u2014 V-ta iteracja\n"
"Jeśli cokolwiek się poprawia → cykl ujemny!",
fontsize=FS,
fontweight="bold",
pad=5,
)
# Bottom note
fig.text(
0.5,
0.01,
"Bez cyklu ujemnego: po V-1 iteracjach"
" dist jest stabilne. "
"Z cyklem ujemnym: dist maleje"
" w nieskończoność"
" → V-ta iteracja to wykrywa.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_YELLOW,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.06, 1, 0.94])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_negative_cycle.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ bellman_ford_negative_cycle.png")

View File

@ -0,0 +1,354 @@
"""3NF, BCNF, 4NF normalization diagram functions."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import (
OUTPUT_DIR,
add_arrow,
add_label,
create_figure,
draw_table,
)
logger = logging.getLogger(__name__)
# ============================================================
# DIAGRAM 4: 3NF — no transitive dependencies
# ============================================================
def draw_3nf() -> None:
"""Draw 3nf."""
fig, ax = create_figure(11.69, 6.5)
# Student table after removing transitive dependency
h1 = ["StID*", "Imie", "WydzialID"]
r1 = [["1", "Anna", "W4"], ["2", "Jan", "W4"], ["3", "Ewa", "W2"]]
cw1 = [0.55, 0.55, 0.85]
draw_table(ax, 0.3, 5.8, "Studenci (kl: StID)", h1, r1, cw1, title_fontsize=9)
# Wydzialy (new!)
h2 = ["WydzialID*", "NazwaWydzialu"]
r2 = [["W4", "EiTI"], ["W2", "Fizyka"]]
cw2 = [0.85, 1.2]
draw_table(ax, 2.6, 5.8, "Wydzialy (kl: WydzialID)", h2, r2, cw2, title_fontsize=9)
# Kursy
h3 = ["KursID*", "NazwaKursu"]
r3 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw3 = [0.7, 1.1]
draw_table(ax, 5.2, 5.8, "Kursy (kl: KursID)", h3, r3, cw3, title_fontsize=9)
# Zapisy (highlight BCNF violation)
h4 = ["StID*", "KursID*", "Prowadzacy"]
r4 = [
["1", "K10", "Kowalski"],
["1", "K20", "Nowak"],
["2", "K10", "Kowalski"],
["3", "K30", "Wisniewski"],
]
cw4 = [0.55, 0.7, 1.05]
draw_table(
ax,
7.8,
5.8,
"Zapisy (kl: StID, KursID)",
h4,
r4,
cw4,
highlight_cols={1, 2},
title_fontsize=9,
)
# Annotations
add_label(
ax,
0.3,
3.3,
"KROK: Rozdzielono Studenci -> Studenci + Wydzialy (usun. zal. przechodnia).",
fontsize=9,
)
add_label(
ax,
0.3,
2.95,
" StID -> WydzialID -> NazwaWydzialu"
" rozbito: NazwaWydzialu w osobnej tabeli.",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.55,
'PROBLEM BCNF w "Zapisy": FD: Prowadzacy -> KursID (1 prowadzacy = 1 kurs)',
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
2.2,
" Prowadzacy NIE jest nadkluczem tabeli Zapisy -> NARUSZENIE BCNF.",
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.85,
" 3NF OK, bo KursID jest atrybutem pierwszym (prime) -> wyjatek 3NF.",
fontsize=9,
color="#333333",
)
add_label(
ax,
0.3,
1.5,
" BCNF nie ma takiego wyjatku"
" -> kazda nietrywialna FD wymaga nadklucza po lewej.",
fontsize=9,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_3nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_3nf_tables.png")
# ============================================================
# DIAGRAM 5: BCNF — every determinant is a superkey
# ============================================================
def draw_bcnf() -> None:
"""Draw bcnf."""
fig, ax = create_figure(11.69, 7.5)
# Studenci
h1 = ["StID*", "Imie", "WydzialID"]
r1 = [["1", "Anna", "W4"], ["2", "Jan", "W4"], ["3", "Ewa", "W2"]]
cw1 = [0.55, 0.55, 0.85]
draw_table(ax, 0.3, 6.8, "Studenci", h1, r1, cw1, title_fontsize=9)
# Wydzialy
h2 = ["WydzialID*", "NazwaWydz."]
r2 = [["W4", "EiTI"], ["W2", "Fizyka"]]
cw2 = [0.85, 1.0]
draw_table(ax, 2.5, 6.8, "Wydzialy", h2, r2, cw2, title_fontsize=9)
# Kursy
h3 = ["KursID*", "NazwaKursu"]
r3 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw3 = [0.7, 1.1]
draw_table(ax, 4.8, 6.8, "Kursy", h3, r3, cw3, title_fontsize=9)
# ProwadzacyKurs (NEW - from BCNF decomposition)
h4 = ["Prowadzacy*", "KursID"]
r4 = [["Kowalski", "K10"], ["Nowak", "K20"], ["Wisniewski", "K30"]]
cw4 = [1.05, 0.7]
draw_table(
ax, 7.2, 6.8, "ProwadzacyKurs (kl: Prow.)", h4, r4, cw4, title_fontsize=9
)
# New student-advisor junction table
h5 = ["StID*", "Prowadzacy*"]
r5 = [["1", "Kowalski"], ["1", "Nowak"], ["2", "Kowalski"], ["3", "Wisniewski"]]
cw5 = [0.55, 1.05]
draw_table(ax, 9.5, 6.8, "StudentProw. (kl: oba)", h5, r5, cw5, title_fontsize=9)
# Telefony
h6 = ["StID*", "Telefon*"]
r6 = [["1", "111-222"], ["1", "333-444"], ["2", "555-666"], ["3", "777-888"]]
cw6 = [0.55, 0.85]
draw_table(ax, 0.3, 4.6, "Telefony", h6, r6, cw6, title_fontsize=9)
# Annotations
add_label(
ax, 0.3, 2.9, "KROK: Zapisy(StID, KursID, Prowadzacy) rozbite na:", fontsize=9
)
add_label(
ax,
0.3,
2.55,
" ProwadzacyKurs(Prowadzacy, KursID)"
" — FD: Prowadzacy -> KursID, klucz: Prowadzacy",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.25,
" StudentProwadzacy(StID, Prowadzacy) — ktory student u ktorego prowadzacego",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
1.85,
"Teraz KAZDA nietrywialna FD ma nadklucz po lewej stronie -> BCNF spelnione.",
fontsize=9,
)
add_label(
ax,
0.3,
1.45,
"Rekonstrukcja: StudentProw. JOIN ProwadzacyKurs"
" ON Prowadzacy -> odtworzenie Zapisy.",
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_bcnf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_bcnf_tables.png")
# ============================================================
# DIAGRAM 6: 4NF example — multi-valued dependencies
# ============================================================
def draw_4nf() -> None:
"""Draw 4nf."""
fig, ax = create_figure(11.69, 7.5)
# Before: table with MVD violation
h_before = ["StID*", "Hobby*", "Umiejetnosc*"]
r_before = [
["1", "Szachy", "Python"],
["1", "Szachy", "SQL"],
["1", "Bieganie", "Python"],
["1", "Bieganie", "SQL"],
["2", "Plywanie", "Java"],
]
cw_before = [0.55, 0.9, 1.0]
draw_table(
ax,
0.5,
6.8,
"PRZED: StudentAktywnosci (klucz: StID, Hobby, Umiejetnosc)",
h_before,
r_before,
cw_before,
highlight_cols={1, 2},
title_fontsize=10,
)
# Arrows
add_label(ax, 3.5, 6.3, "StID ->> Hobby", fontsize=9, color="black")
add_label(ax, 3.5, 6.0, "StID ->> Umiejetnosc", fontsize=9, color="black")
add_label(ax, 3.5, 5.6, "NIEZALEZNE MVD w jednej tabeli", fontsize=9, color="black")
add_label(
ax,
3.5,
5.2,
"= iloczyn kartezjanski = NARUSZENIE 4NF",
fontsize=9,
color="black",
)
# After: two tables
add_arrow(ax, 3.0, 4.2, 3.0, 3.7, "", "#333333")
add_label(ax, 3.2, 3.95, "dekompozycja", fontsize=8, color="#333333")
h_hobby = ["StID*", "Hobby*"]
r_hobby = [["1", "Szachy"], ["1", "Bieganie"], ["2", "Plywanie"]]
cw_hobby = [0.55, 0.9]
draw_table(
ax, 0.5, 3.5, "PO: StudentHobby", h_hobby, r_hobby, cw_hobby, title_fontsize=10
)
h_skill = ["StID*", "Umiejetnosc*"]
r_skill = [["1", "Python"], ["1", "SQL"], ["2", "Java"]]
cw_skill = [0.55, 1.0]
draw_table(
ax,
3.5,
3.5,
"PO: StudentUmiejetnosc",
h_skill,
r_skill,
cw_skill,
title_fontsize=10,
)
# Summary on the right side
add_label(ax, 6.5, 6.5, "4NF: BCNF + brak nietrywialnych MVD", fontsize=10)
add_label(
ax, 6.5, 6.1, "MVD X ->> Y: jeden X = ZBIOR Y-ow,", fontsize=8, color="#333333"
)
add_label(
ax, 6.5, 5.8, "niezaleznie od reszty kolumn.", fontsize=8, color="#333333"
)
add_label(
ax, 6.5, 5.35, "Naruszenie: Student 1 ma 2 hobby i 2 umiejetnosci", fontsize=8
)
add_label(
ax, 6.5, 5.05, " -> 2 x 2 = 4 wiersze (iloczyn kartezjanski!)", fontsize=8
)
add_label(
ax, 6.5, 4.65, "Naprawa: rozdziel niezalezne MVD do osobnych tabel.", fontsize=8
)
add_label(
ax,
6.5,
4.25,
"Po dekompozycji: 3 + 3 = 6 wierszy zamiast 5 z ilocz.",
fontsize=8,
color="#333333",
)
add_label(
ax, 6.5, 3.85, " (ale BEZ sztucznych kombinacji!)", fontsize=8, color="#333333"
)
# Key insight box
rect = mpatches.FancyBboxPatch(
(6.3, 2.5),
5.0,
1.0,
boxstyle="round,pad=0.1",
facecolor="#F0F0F0",
edgecolor="black",
linewidth=1.0,
)
ax.add_patch(rect)
add_label(ax, 6.5, 3.2, "ROZNICA 4NF vs BCNF:", fontsize=9)
add_label(
ax,
6.5,
2.85,
"BCNF dotyczy FD (X -> Y, jedna wartosc)",
fontsize=8,
color="#333333",
)
add_label(
ax,
6.5,
2.55,
"4NF dotyczy MVD (X ->> Y, zbior wartosci)",
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_4nf_example.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_4nf_example.png")

View File

@ -0,0 +1,340 @@
"""0NF, 1NF, 2NF normalization diagram functions."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import (
OUTPUT_DIR,
add_arrow,
add_label,
create_figure,
draw_table,
)
logger = logging.getLogger(__name__)
# ============================================================
# DIAGRAM 1: 0NF Table
# ============================================================
def draw_0nf() -> None:
"""Draw 0nf."""
fig, ax = create_figure(11.69, 5.5)
headers = [
"StID",
"Imie",
"Telefony",
"KursID",
"NazwaKursu",
"Prowadzacy",
"WydzialID",
"NazwaWydzialu",
]
rows = [
[
"1",
"Anna",
"111-222, 333-444",
"K10",
"Bazy danych",
"Kowalski",
"W4",
"EiTI",
],
["1", "Anna", "111-222, 333-444", "K20", "Algorytmy", "Nowak", "W4", "EiTI"],
["2", "Jan", "555-666", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["3", "Ewa", "777-888", "K30", "Optyka", "Wisniewski", "W2", "Fizyka"],
]
col_widths = [0.5, 0.55, 1.55, 0.65, 1.1, 1.05, 0.85, 1.2]
# Highlight the non-atomic column
draw_table(
ax,
0.8,
4.5,
"0NF: Rejestr (forma nienormalna)",
headers,
rows,
col_widths,
highlight_cols={2}, # Telefony column
title_fontsize=11,
)
# Annotations
add_label(
ax,
0.8,
1.9,
'PROBLEM: Kolumna "Telefony" zawiera LISTY wartosci (nieatomowe).',
fontsize=9,
color="black",
)
add_label(
ax,
0.8,
1.55,
'Redundancja: "Anna", "W4", "EiTI", "Bazy danych" powtorzone wielokrotnie.',
fontsize=9,
color="black",
)
add_label(
ax,
0.8,
1.2,
(
"Zaleznosci funkcyjne: StID -> Imie, WydzialID"
" | WydzialID -> NazwaWydzialu"
),
fontsize=8,
color="#333333",
)
add_label(
ax,
0.8,
0.9,
(
" KursID -> NazwaKursu | (StID,KursID)"
" -> Prowadzacy | Prowadzacy -> KursID"
),
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_0nf_table.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_0nf_table.png")
# ============================================================
# DIAGRAM 2: 1NF — atomic values
# ============================================================
def draw_1nf() -> None:
"""Draw 1nf."""
fig, ax = create_figure(11.69, 6.0)
# Main table after removing Telefony
headers1 = [
"StID*",
"Imie",
"KursID*",
"NazwaKursu",
"Prowadzacy",
"WydzialID",
"NazwaWydzialu",
]
rows1 = [
["1", "Anna", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["1", "Anna", "K20", "Algorytmy", "Nowak", "W4", "EiTI"],
["2", "Jan", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["3", "Ewa", "K30", "Optyka", "Wisniewski", "W2", "Fizyka"],
]
cw1 = [0.55, 0.55, 0.7, 1.1, 1.05, 0.85, 1.2]
draw_table(
ax,
0.5,
5.2,
"1NF: Rejestr (klucz: StID, KursID)",
headers1,
rows1,
cw1,
title_fontsize=10,
)
# Telefony table
headers2 = ["StID*", "Telefon*"]
rows2 = [
["1", "111-222"],
["1", "333-444"],
["2", "555-666"],
["3", "777-888"],
]
cw2 = [0.55, 0.85]
draw_table(
ax,
7.5,
5.2,
"Telefony (klucz: StID, Telefon)",
headers2,
rows2,
cw2,
title_fontsize=10,
)
# Arrow
add_arrow(ax, 6.6, 4.3, 7.4, 4.3, "wydzielono", "#333333")
# Annotations
add_label(
ax,
0.5,
2.6,
'KROK: Nieatomowa kolumna "Telefony" wydzielona do osobnej tabeli.',
fontsize=9,
)
add_label(
ax,
0.5,
2.25,
"Kazda komorka zawiera JEDNA wartosc. Klucz glowny wyznaczony.",
fontsize=9,
)
add_label(
ax,
0.5,
1.85,
"PROBLEM 2NF: NazwaKursu zalezy TYLKO od KursID (czesc klucza).",
fontsize=9,
color="black",
)
add_label(
ax,
0.5,
1.5,
(
" Imie, WydzialID, NazwaWydzialu"
" zaleza TYLKO od StID (czesc klucza)."
),
fontsize=9,
color="black",
)
add_label(
ax,
0.5,
1.15,
" --> Czesciowe zaleznosci od klucza zlozonego = NARUSZENIE 2NF.",
fontsize=9,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_1nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_1nf_tables.png")
# ============================================================
# DIAGRAM 3: 2NF — no partial dependencies
# ============================================================
def draw_2nf() -> None:
"""Draw 2nf."""
fig, ax = create_figure(11.69, 6.5)
# Studenci
h1 = ["StID*", "Imie", "WydzialID", "NazwaWydzialu"]
r1 = [
["1", "Anna", "W4", "EiTI"],
["2", "Jan", "W4", "EiTI"],
["3", "Ewa", "W2", "Fizyka"],
]
cw1 = [0.55, 0.55, 0.85, 1.2]
draw_table(
ax,
0.3,
5.8,
"Studenci (kl: StID)",
h1,
r1,
cw1,
highlight_cols={2, 3},
title_fontsize=9,
)
# Kursy
h2 = ["KursID*", "NazwaKursu"]
r2 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw2 = [0.7, 1.1]
draw_table(ax, 4.0, 5.8, "Kursy (kl: KursID)", h2, r2, cw2, title_fontsize=9)
# Zapisy
h3 = ["StID*", "KursID*", "Prowadzacy"]
r3 = [
["1", "K10", "Kowalski"],
["1", "K20", "Nowak"],
["2", "K10", "Kowalski"],
["3", "K30", "Wisniewski"],
]
cw3 = [0.55, 0.7, 1.05]
draw_table(ax, 6.8, 5.8, "Zapisy (kl: StID, KursID)", h3, r3, cw3, title_fontsize=9)
# Telefony
h4 = ["StID*", "Telefon*"]
r4 = [["1", "111-222"], ["1", "333-444"], ["2", "555-666"], ["3", "777-888"]]
cw4 = [0.55, 0.85]
draw_table(ax, 9.5, 5.8, "Telefony", h4, r4, cw4, title_fontsize=9)
# Annotations
add_label(
ax,
0.3,
3.3,
(
"KROK: Rozbito czesc. zaleznosci"
" — atrybuty zalezne od czesci klucza wydzielone."
),
fontsize=9,
)
add_label(
ax,
0.3,
2.95,
" StID -> Imie, WydzialID, NazwaWydzialu ==> tabela Studenci",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.65,
" KursID -> NazwaKursu ==> tabela Kursy",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.3,
'PROBLEM 3NF w "Studenci": StID -> WydzialID -> NazwaWydzialu',
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.95,
" NazwaWydzialu zalezy od WydzialID (nie-klucz), nie bezposrednio od StID.",
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.6,
" --> Zaleznosc PRZECHODNIA = NARUSZENIE 3NF.",
fontsize=9,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_2nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_2nf_tables.png")

View File

@ -0,0 +1,314 @@
"""5NF and summary flow normalization diagram functions."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import (
OUTPUT_DIR,
add_arrow,
add_label,
create_figure,
draw_table,
)
logger = logging.getLogger(__name__)
# ============================================================
# DIAGRAM 7: 5NF example — join dependencies
# ============================================================
def draw_5nf() -> None:
"""Draw 5nf."""
fig, ax = create_figure(11.69, 8.5)
# Before: ternary table
h_before = ["Dostawca*", "Czesc*", "Projekt*"]
r_before = [
["Alfa", "Sruba", "Most"],
["Alfa", "Sruba", "Wiezowiec"],
["Alfa", "Nakretka", "Most"],
["Beta", "Sruba", "Wiezowiec"],
["Beta", "Nakretka", "Wiezowiec"],
]
cw_before = [0.9, 0.9, 1.0]
draw_table(
ax,
0.5,
7.8,
"PRZED: Dostawy (klucz: Dostawca, Czesc, Projekt)",
h_before,
r_before,
cw_before,
title_fontsize=10,
)
add_label(ax, 3.8, 7.3, "Tabela w 4NF (brak nietrywialnych MVD),", fontsize=8)
add_label(
ax, 3.8, 7.0, "ale NIE w 5NF jesli zachodzi regula cykliczna:", fontsize=8
)
add_label(
ax, 3.8, 6.55, "Jesli Dostawca dostarcza Czesc", fontsize=8, color="#333333"
)
add_label(
ax, 3.8, 6.25, " I Dostawca dostarcza do Projektu", fontsize=8, color="#333333"
)
add_label(
ax, 3.8, 5.95, " I Czesc jest uzywana w Projekcie", fontsize=8, color="#333333"
)
add_label(
ax,
3.8,
5.65,
" ==> Dostawca dostarcza te Czesc do tego Projektu.",
fontsize=8,
color="black",
)
# Arrow down
add_arrow(ax, 1.8, 5.1, 1.8, 4.6, "dekompozycja 5NF", "#333333")
# After: three binary tables
h1 = ["Dostawca*", "Czesc*"]
r1 = [
["Alfa", "Sruba"],
["Alfa", "Nakretka"],
["Beta", "Sruba"],
["Beta", "Nakretka"],
]
cw1 = [0.9, 0.9]
draw_table(ax, 0.3, 4.3, "DostawcaCzesc", h1, r1, cw1, title_fontsize=9)
h2 = ["Dostawca*", "Projekt*"]
r2 = [["Alfa", "Most"], ["Alfa", "Wiezowiec"], ["Beta", "Wiezowiec"]]
cw2 = [0.9, 1.0]
draw_table(ax, 3.0, 4.3, "DostawcaProjekt", h2, r2, cw2, title_fontsize=9)
h3 = ["Czesc*", "Projekt*"]
r3 = [
["Sruba", "Most"],
["Sruba", "Wiezowiec"],
["Nakretka", "Most"],
["Nakretka", "Wiezowiec"],
]
cw3 = [0.9, 1.0]
draw_table(ax, 5.7, 4.3, "CzescProjekt", h3, r3, cw3, title_fontsize=9)
# Join reconstruction note
rect = mpatches.FancyBboxPatch(
(8.3, 3.5),
3.0,
4.0,
boxstyle="round,pad=0.1",
facecolor="#F0F0F0",
edgecolor="black",
linewidth=1.0,
)
ax.add_patch(rect)
add_label(ax, 8.5, 7.2, "5NF (PJNF):", fontsize=10)
add_label(ax, 8.5, 6.8, "Project-Join NF", fontsize=8, color="#333333")
add_label(ax, 8.5, 6.35, "Kazda zaleznosc", fontsize=8)
add_label(ax, 8.5, 6.05, "zlaczenia (JD)", fontsize=8)
add_label(ax, 8.5, 5.75, "implikowana przez", fontsize=8)
add_label(ax, 8.5, 5.45, "klucze kandydujace.", fontsize=8)
add_label(ax, 8.5, 4.9, "Rekonstrukcja:", fontsize=9)
add_label(ax, 8.5, 4.55, "DC JOIN DP JOIN CP", fontsize=8, color="#333333")
add_label(ax, 8.5, 4.2, "= oryginalna tabela", fontsize=8, color="#333333")
add_label(ax, 8.5, 3.75, "(bezstratnie!)", fontsize=8, color="#333333")
# Verification example at the bottom
add_label(
ax,
0.3,
2.0,
"Weryfikacja: Alfa dostarcza Nakretke?"
" Alfa -> Wiezowiec? Nakretka -> Wiezowiec?",
fontsize=8,
)
add_label(
ax,
0.3,
1.65,
" TAK, TAK, TAK --> wg reguly cyklicznej:"
" Alfa dostarcza Nakretke do Wiezowca.",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
1.25,
"Ale: Alfa dostarcza Nakretke? TAK. Alfa -> Most? TAK. Nakretka -> Most? TAK.",
fontsize=8,
)
add_label(
ax,
0.3,
0.9,
" --> Alfa dostarcza Nakretke do Mostu."
" (Tego wiersza NIE MA w oryginale -- BLAD!)",
fontsize=8,
color="black",
)
add_label(
ax,
0.3,
0.5,
" Dekompozycja 5NF jest poprawna TYLKO"
" jesli regula cykliczna rzeczywiscie zachodzi!",
fontsize=8,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_5nf_example.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_5nf_example.png")
# ============================================================
# DIAGRAM 8: Full normalization summary flowchart
# ============================================================
def draw_summary_flow() -> None:
"""Draw summary flow."""
fig, ax = create_figure(11.69, 6.0)
# Boxes for each NF
box_y = 4.5
box_h = 1.8
box_w = 1.4
gap = 0.25
nf_data = [
("0NF", "Nienormalna", "Listy w\nkomorkach,\nbrak klucza"),
("1NF", "Atomowosc", "Kazda komorka\n= 1 wartosc,\njest klucz"),
("2NF", "Pelny klucz", "Brak czesciowej\nzaleznosci od\nklucza zlozonego"),
("3NF", "Tylko klucz", "Brak zaleznosci\nprzechodniej\nA->B->C"),
("BCNF", "Nadklucz", "Lewa strona\nkazdej FD\n= nadklucz"),
("4NF", "Brak MVD", "Brak nietryw.\nwielowart.\nzaleznosci"),
("5NF", "Brak JD", "Kazda zal.\nzlaczenia\nimpl. kluczem"),
]
for i, (name, subtitle, desc) in enumerate(nf_data):
x = 0.3 + i * (box_w + gap)
# Main box
rect = mpatches.FancyBboxPatch(
(x, box_y - box_h),
box_w,
box_h,
boxstyle="round,pad=0.05",
facecolor="#F5F5F5" if i == 0 else "#FFFFFF",
edgecolor="black",
linewidth=1.2,
)
ax.add_patch(rect)
# NF name
ax.text(
x + box_w / 2,
box_y - 0.15,
name,
fontsize=12,
fontweight="bold",
ha="center",
va="top",
family="monospace",
)
# Subtitle
ax.text(
x + box_w / 2,
box_y - 0.45,
subtitle,
fontsize=7,
ha="center",
va="top",
family="monospace",
color="#333333",
)
# Description
ax.text(
x + box_w / 2,
box_y - 0.75,
desc,
fontsize=6.5,
ha="center",
va="top",
family="monospace",
color="#555555",
linespacing=1.3,
)
# Arrow to next
if i < len(nf_data) - 1:
ax.annotate(
"",
xy=(x + box_w + 0.02, box_y - box_h / 2),
xytext=(x + box_w + gap - 0.02, box_y - box_h / 2),
arrowprops={"arrowstyle": "<-", "color": "black", "lw": 1.5},
)
# Mnemonic quote at the bottom
ax.text(
5.85,
2.2,
'"Klucz, caly klucz i tylko klucz -- tak mi dopomoz Codd"',
fontsize=11,
ha="center",
va="center",
family="monospace",
style="italic",
)
ax.text(
5.85,
1.8,
"1NF: klucz istnieje | 2NF: caly klucz | 3NF: tylko klucz",
fontsize=9,
ha="center",
va="center",
family="monospace",
color="#333333",
)
ax.text(
5.85,
1.4,
"BCNF: kazdy determinant = nadklucz | 4NF: +brak MVD | 5NF: +brak JD",
fontsize=9,
ha="center",
va="center",
family="monospace",
color="#333333",
)
# Hierarchy
ax.text(
5.85,
0.8,
"5NF (zawiera sie w) 4NF (zaw.) BCNF"
" (zaw.) 3NF (zaw.) 2NF (zaw.) 1NF",
fontsize=8,
ha="center",
va="center",
family="monospace",
color="#555555",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_summary_flow.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_summary_flow.png")

View File

@ -0,0 +1,226 @@
"""Pattern language navigation diagram."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import (
BG,
DPI,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY2,
LN,
OUTPUT_DIR,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 5. Pattern Language Navigation Graph
# ============================================================
def generate_pattern_language_navigation() -> None:
"""Generate pattern language navigation graph diagram."""
fig, ax = plt.subplots(figsize=(8.27, 9))
ax.set_xlim(0, 12)
ax.set_ylim(0, 12)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Język wzorców \u2014 nawigacja"
" \u201eproblem \u2192 wzorzec"
" \u2192 nowy problem\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Node positions: (x, y, label, is_pattern, fill)
nodes = [
(1.5, 10.5, "Monolith\nnie skaluje się", False, "white"),
(
1.5, 8.2,
"Jak routować\nżądania do\nserwisów?",
False, "white",
),
(
1.5, 5.9,
"Co gdy serwis\nnie odpowiada?",
False, "white",
),
(
1.5, 3.6,
"Jak zachować\nspójność\ntransakcji?",
False, "white",
),
(
1.5, 1.3,
"Jak odnaleźć\nadres serwisu?",
False, "white",
),
(7.0, 9.3, "Microservices", True, GRAY2),
(7.0, 7.0, "API Gateway", True, GRAY2),
(7.0, 4.7, "Circuit Breaker", True, GRAY2),
(7.0, 2.4, "Saga", True, GRAY2),
(10.0, 5.9, "Service\nDiscovery", True, GRAY1),
]
# Draw nodes
node_w_prob = 2.8
node_h_prob = 1.3
node_w_pat = 2.5
node_h_pat = 1.0
for nx, ny, label, is_pattern, fill in nodes:
if is_pattern:
w, h = node_w_pat, node_h_pat
rect = FancyBboxPatch(
(nx - w / 2, ny - h / 2),
w,
h,
boxstyle="round,pad=0.1",
lw=2,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
nx,
ny,
label,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
else:
w, h = node_w_prob, node_h_prob
rect = FancyBboxPatch(
(nx - w / 2, ny - h / 2),
w,
h,
boxstyle="round,pad=0.1",
lw=1.2,
edgecolor=LN,
facecolor=fill,
linestyle="--",
)
ax.add_patch(rect)
ax.text(
nx,
ny,
label,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
# Arrows: problem -> pattern, pattern -> problem
arrows = [
(2.9, 10.5, 5.75, 9.5, "rozwiązuje →", "->", 1.5),
(7.0, 8.8, 2.9, 8.5, "← rodzi problem", "->", 1.0),
(2.9, 8.0, 5.75, 7.2, "rozwiązuje →", "->", 1.5),
(7.0, 6.5, 2.9, 6.2, "← rodzi problem", "->", 1.0),
(2.9, 5.7, 5.75, 5.0, "rozwiązuje →", "->", 1.5),
(7.0, 4.2, 2.9, 3.9, "← rodzi problem", "->", 1.0),
(2.9, 3.3, 5.75, 2.6, "rozwiązuje →", "->", 1.5),
(8.25, 9.0, 9.5, 6.5, "wymaga →", "->", 1.0),
(2.9, 1.3, 8.75, 5.6, "rozwiązuje →", "->", 1.2),
]
for x1, y1, x2, y2, label, style, lw in arrows:
ax.annotate(
"",
xy=(x2, y2),
xytext=(x1, y1),
arrowprops={
"arrowstyle": style,
"color": LN,
"lw": lw,
"connectionstyle": "arc3,rad=0.05",
},
)
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
ax.text(
mx,
my + 0.2,
label,
ha="center",
va="center",
fontsize=6.5,
fontstyle="italic",
color="#555555",
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": "white",
"edgecolor": "none",
"alpha": 0.8,
},
)
# Legend
legend_y = 0.3
r1 = FancyBboxPatch(
(1.0, legend_y - 0.2),
1.5,
0.4,
boxstyle="round,pad=0.05",
lw=1,
edgecolor=LN,
facecolor="white",
linestyle="--",
)
ax.add_patch(r1)
ax.text(
1.75, legend_y, "Problem",
ha="center", va="center", fontsize=7,
)
r2 = FancyBboxPatch(
(3.5, legend_y - 0.2),
1.5,
0.4,
boxstyle="round,pad=0.05",
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(r2)
ax.text(
4.25,
legend_y,
"Wzorzec",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
ax.text(
6.5,
legend_y,
"Nawigacja: Problem \u2192 Wzorzec"
" \u2192 Nowy Problem \u2192 Wzorzec \u2192 ...",
ha="left",
va="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
out = str(
Path(OUTPUT_DIR) / "q14_pattern_language_navigation.png"
)
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# Main
# ============================================================

View File

@ -0,0 +1,371 @@
"""Three pillars and observer card diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
import numpy as np
from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import (
_BAND_HEIGHTS,
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
draw_arrow,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 3. Three Pillars of Cataloguing
# ============================================================
def generate_three_pillars() -> None:
"""Generate three pillars of cataloguing diagram."""
fig, ax = plt.subplots(figsize=(8.27, 5.5))
ax.set_xlim(0, 12)
ax.set_ylim(0, 7)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Jak są katalogowane wzorce? — Trzy filary",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Roof / banner
roof_pts = np.array([[1, 5.5], [6, 6.8], [11, 5.5]])
roof = plt.Polygon(
roof_pts,
closed=True,
lw=2,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(roof)
ax.text(
6,
6.0,
"KATALOGOWANIE WZORCÓW",
ha="center",
va="center",
fontsize=11,
fontweight="bold",
)
# Three pillars
pillars = [
(
1.3,
"1. SZABLON\nOPISU",
"Każdy wzorzec ma\nte same pola:\n"
"Nazwa → Problem\n→ Siły → Rozwiązanie\n"
"→ Konsekwencje",
"Analogia:\nformatka\nencyklopedii",
),
(
4.8,
"2. KLASYFIKACJA\nWIELOOSIOWA",
"Osie podziału:\n"
"• Skala (arch/proj/idiom)\n"
"• Domena problemu\n"
"• Atrybut jakościowy\n"
"• Domena zastosowania",
"Analogia:\nkategorie\nw bibliotece",
),
(
8.3,
"3. JĘZYK\nWZORCÓW",
"Wzorce referują się\nwzajemnie tworząc\n"
"sieć/graf:\nA → wymaga → B\n"
"B → wariant → C",
"Analogia:\n\u201ezobacz te\u017c\u201d\n"
"w encyklopedii",
),
]
for px, title, desc, analogy in pillars:
pw, ph = 2.8, 5.0
py = 0.5
# Pillar rectangle
rect = FancyBboxPatch(
(px, py),
pw,
ph,
boxstyle="round,pad=0.1",
lw=1.8,
edgecolor=LN,
facecolor="white",
)
ax.add_patch(rect)
# Title
ax.text(
px + pw / 2,
py + ph - 0.55,
title,
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
# Horizontal line under title
ax.plot(
[px + 0.2, px + pw - 0.2],
[py + ph - 1.0, py + ph - 1.0],
color=LN,
lw=0.8,
)
# Description
ax.text(
px + pw / 2,
py + ph / 2 - 0.3,
desc,
ha="center",
va="center",
fontsize=FS_SMALL,
linespacing=1.4,
)
# Analogy box at bottom
analogy_rect = FancyBboxPatch(
(px + 0.2, py + 0.15),
pw - 0.4,
1.0,
boxstyle="round,pad=0.06",
lw=0.8,
edgecolor=GRAY3,
facecolor=GRAY1,
)
ax.add_patch(analogy_rect)
ax.text(
px + pw / 2,
py + 0.65,
analogy,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#555555",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_three_pillars.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 4. Filled-in Observer Pattern Card
# ============================================================
def _get_observer_band_height(index: int) -> float:
"""Return band height for the given field index."""
return _BAND_HEIGHTS[index]
def generate_observer_card_filled() -> None:
"""Generate filled-in Observer pattern card diagram."""
fig, ax = plt.subplots(figsize=(8.27, 8.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Wypełniona karta wzorca — Observer (GoF)",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Main card outline
card_x, card_y, card_w, card_h = 0.8, 0.3, 8.4, 9.2
card = FancyBboxPatch(
(card_x, card_y),
card_w,
card_h,
boxstyle="round,pad=0.15",
lw=2.5,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(card)
# Fields with actual Observer content
fields = [
("Na", "NAZWA", "Observer", GRAY2, True),
(
"P",
"PROBLEM",
"Obiekt (Subject) zmienia stan → wielu"
" zależnych\n"
"obiektów musi zareagować, ale Subject nie\n"
"powinien znać ich konkretnych typów.",
GRAY1,
False,
),
(
"Si",
"SIŁY",
"• loose coupling (nie znać obserwatorów"
" z nazwy)\n"
" vs koszt powiadomień"
" (N obserwatorów = N wywołań)\n"
"• otwartość na rozszerzenia"
" vs złożoność debugowania",
"white",
False,
),
(
"Ro",
"ROZWIĄZANIE",
"Subject przechowuje listę Observer.\n"
"Metody: attach(o), detach(o), notify().\n"
"notify() iteruje po liście i woła update()\n"
"na każdym obserwatorze.",
GRAY1,
False,
),
(
"Ko",
"KONSEKWENCJE",
"(+) Luźne wiązanie — Subject ↔ Observer\n"
"(+) Nowi obserwatorzy bez zmian w Subject\n"
"(-) Kaskada powiadomień może być kosztowna\n"
"(-) Memory leaks jeśli nie detach()",
"white",
False,
),
]
band_x = card_x + 0.3
band_w = card_w - 0.6
start_y = card_y + card_h - 0.65
for i, (abbr, title, content, fill, is_title_field) in enumerate(
fields
):
band_h = _get_observer_band_height(i)
by = start_y - sum(
_get_observer_band_height(j) + 0.15 for j in range(i)
)
# Abbreviation circle
circle = plt.Circle(
(band_x + 0.35, by + band_h / 2),
0.28,
lw=1.5,
edgecolor=LN,
facecolor=GRAY3,
)
ax.add_patch(circle)
ax.text(
band_x + 0.35,
by + band_h / 2,
abbr,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
# Field box
fx = band_x + 0.8
fw = band_w - 0.8
rect = FancyBboxPatch(
(fx, by),
fw,
band_h,
boxstyle="round,pad=0.06",
lw=1,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
if is_title_field:
ax.text(
fx + fw / 2,
by + band_h / 2,
f"{title}: {content}",
ha="center",
va="center",
fontsize=12,
fontweight="bold",
)
else:
ax.text(
fx + 0.15,
by + band_h - 0.2,
title,
ha="left",
va="center",
fontsize=FS,
fontweight="bold",
)
ax.text(
fx + 0.15,
by + band_h / 2 - 0.15,
content,
ha="left",
va="center",
fontsize=FS_SMALL,
family="monospace",
linespacing=1.3,
)
# Arrow
if i < len(fields) - 1:
draw_arrow(
ax,
band_x + 0.35,
by - 0.02,
band_x + 0.35,
by - 0.13,
lw=1.0,
)
# Extra info at bottom
extra_y = 0.55
extras = [
"Powiązane: Mediator (centralizuje),"
" Pub/Sub (rozproszony),"
" MVC (View = Observer)",
"Znane użycia: Java Swing listeners,"
" C# event/delegate,"
" React useState, DOM addEventListener",
]
for j, txt in enumerate(extras):
ax.text(
card_x + card_w / 2,
extra_y + (1 - j) * 0.25,
txt,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#444444",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_observer_card_filled.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)

View File

@ -0,0 +1,416 @@
"""Pattern template and catalog map diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import (
BG,
DPI,
FS,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
GRAY5,
LN,
OUTPUT_DIR,
draw_arrow,
)
_logger = logging.getLogger(__name__)
def generate_pattern_template() -> None:
"""Generate pattern template diagram with NaPSiRoKo mnemonic."""
fig, ax = plt.subplots(figsize=(8.27, 6))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Szablon opisu wzorca \u2014 \u201eNaPSiRoKo\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Main card outline
card_x, card_y, card_w, card_h = 1.5, 0.5, 7, 7
card = FancyBboxPatch(
(card_x, card_y),
card_w,
card_h,
boxstyle="round,pad=0.15",
lw=2.5,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(card)
# Title of card
ax.text(
card_x + card_w / 2,
card_y + card_h - 0.35,
"KARTA WZORCA",
ha="center",
va="center",
fontsize=FS_TITLE,
fontweight="bold",
)
# Fields as horizontal bands
fields = [
("Na", "NAZWA", "Layered, Observer, Microservices", GRAY1),
(
"P",
"PROBLEM / KONTEKST",
"Kiedy stosować? Jaki problem rozwiązuje?",
"white",
),
(
"Si",
"SIŁY (forces)",
"Konkurencyjne wymagania do pogodzenia\n"
"(np. testowalność vs wydajność)",
GRAY1,
),
("Ro", "ROZWIĄZANIE", "Struktura, diagram, zachowanie", "white"),
(
"Ko",
"KONSEKWENCJE",
"Tradeoffs: co zyskujemy, co tracimy",
GRAY1,
),
]
band_x = card_x + 0.3
band_w = card_w - 0.6
band_h = 1.05
start_y = card_y + card_h - 1.1
for i, (abbr, title, desc, fill) in enumerate(fields):
by = start_y - i * (band_h + 0.15)
# Abbreviation circle on the left
circle = plt.Circle(
(band_x + 0.35, by + band_h / 2),
0.28,
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(circle)
ax.text(
band_x + 0.35,
by + band_h / 2,
abbr,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
# Field box
fx = band_x + 0.8
fw = band_w - 0.8
rect = FancyBboxPatch(
(fx, by),
fw,
band_h,
boxstyle="round,pad=0.06",
lw=1,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
fx + 0.15,
by + band_h - 0.25,
title,
ha="left",
va="center",
fontsize=FS,
fontweight="bold",
)
ax.text(
fx + 0.15,
by + 0.25,
desc,
ha="left",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#444444",
)
# Arrow connecting fields
if i < len(fields) - 1:
draw_arrow(
ax,
band_x + 0.35,
by - 0.02,
band_x + 0.35,
by - 0.13,
lw=1.0,
)
# Extra fields note at bottom
ax.text(
card_x + card_w / 2,
card_y + 0.25,
"+ Powiązane wzorce • Znane zastosowania • Warianty",
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
# Mnemonic reminder on the right
ax.text(
9.8,
4,
"Mnemonik:\nNaPSiRoKo",
ha="center",
va="center",
fontsize=10,
fontweight="bold",
rotation=90,
color="#666666",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_pattern_template.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 2. Catalog Classification Map
# ============================================================
def generate_catalog_map() -> None:
"""Generate catalog classification map diagram."""
fig, ax = plt.subplots(figsize=(8.27, 7))
ax.set_xlim(0, 12)
ax.set_ylim(0, 9)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Mapa katalog\u00f3w wzorc\u00f3w \u2014"
" \u201ePawe\u0142 Gra\u0142 Efektownie"
" Pod Chmurami\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Y-axis: Scale (architectural -> design -> idiom)
ax.text(
0.3,
7.8,
"SKALA",
fontsize=10,
fontweight="bold",
ha="center",
va="center",
rotation=90,
)
ax.annotate(
"",
xy=(0.3, 2.0),
xytext=(0.3, 7.5),
arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
)
scale_labels = [
(7.0, "Architektoniczny\n(cały system)"),
(5.0, "Projektowy\n(klasa/obiekt)"),
(3.0, "Idiomatyczny\n(linia kodu)"),
]
for sy, label in scale_labels:
ax.text(
1.0,
sy,
label,
fontsize=FS_SMALL,
ha="left",
va="center",
fontstyle="italic",
)
ax.plot(
[0.15, 0.45], [sy, sy], color=GRAY3, lw=0.8, ls="--"
)
# X-axis: Domain
ax.text(
6.5,
1.2,
"DOMENA ZASTOSOWANIA",
fontsize=10,
fontweight="bold",
ha="center",
va="center",
)
ax.annotate(
"",
xy=(11.5, 1.5),
xytext=(2.0, 1.5),
arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
)
# Catalog boxes positioned by scale and domain
catalogs = [
(
2.5,
6.2,
2.5,
1.4,
"POSA",
"1996 • Buschmann\nLayers, Broker,\n"
"Pipes & Filters, MVC",
GRAY1,
"P",
),
(
2.5,
4.2,
2.5,
1.4,
"GoF",
"1994 • Gamma et al.\n23 wzorce:\n"
"5 kreac. / 7 strukt. / 11 behaw.",
GRAY2,
"G",
),
(
5.5,
6.2,
2.5,
1.4,
"EIP",
"2003 • Hohpe & Woolf\nMessage Channel,\n"
"Router, Aggregator",
GRAY1,
"E",
),
(
5.5,
4.2,
2.5,
1.4,
"PoEAA",
"2002 • M. Fowler\nRepository,"
" Unit of Work,\nDomain Model",
"white",
"P",
),
(
8.5,
6.2,
2.8,
1.4,
"Cloud\nPatterns",
"~2015 • Azure/AWS\nCircuit Breaker,\n"
"Saga, Sidecar",
GRAY1,
"C",
),
]
for cx, cy, cw, ch, name, sub, fill, ml in catalogs:
rect = FancyBboxPatch(
(cx, cy),
cw,
ch,
boxstyle="round,pad=0.1",
lw=1.5,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
cx + cw / 2,
cy + ch - 0.3,
name,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
ax.text(
cx + cw / 2,
cy + 0.4,
sub,
ha="center",
va="center",
fontsize=FS_SMALL,
linespacing=1.3,
)
# Mnemonic letter in corner
circle = plt.Circle(
(cx + 0.25, cy + ch - 0.25),
0.2,
lw=1,
edgecolor=LN,
facecolor=GRAY5,
)
ax.add_patch(circle)
ax.text(
cx + 0.25,
cy + ch - 0.25,
ml,
ha="center",
va="center",
fontsize=8,
fontweight="bold",
)
# Mnemonic bar at bottom
mnem_y = 2.2
ax.text(
6.0,
mnem_y,
"PGEP+C → Paweł Grał Efektownie Pod Chmurami",
ha="center",
va="center",
fontsize=10,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 1.5,
},
)
# Domain labels along x-axis
domains = [
(3.75, 1.7, "Architektura"),
(6.75, 1.7, "Integracja / Enterprise"),
(9.9, 1.7, "Chmura"),
]
for dx, dy, dlabel in domains:
ax.text(
dx,
dy,
dlabel,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_catalog_map.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)

View File

@ -0,0 +1,377 @@
"""BPMN and UML activity diagram generators."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import (
BG_COLOR,
DPI,
LINE_COLOR,
OUTPUT_DIR,
TITLE_SIZE,
draw_arrow,
draw_diamond,
draw_line,
draw_rounded_rect,
)
_logger = logging.getLogger(__name__)
# 1. BPMN 2.0 Diagram
# =========================================================================
def _draw_bpmn_pool_and_lanes(
ax: Axes,
) -> tuple[float, float, float, float]:
"""Draw BPMN pool outline and swim lanes, return lane positions."""
pool_x, pool_y, pool_w, pool_h = 3, 3, 104, 68
ax.add_patch(
plt.Rectangle(
(pool_x, pool_y),
pool_w,
pool_h,
lw=2,
edgecolor=LINE_COLOR,
facecolor="white",
)
)
label_strip = pool_x + 4
ax.plot(
[label_strip, label_strip],
[pool_y, pool_y + pool_h],
color=LINE_COLOR,
lw=1.5,
)
ax.text(
pool_x + 2,
pool_y + pool_h / 2,
"FIRMA",
fontsize=11,
fontweight="bold",
rotation=90,
ha="center",
va="center",
)
lane_top = pool_y + pool_h
lane_mid1 = pool_y + pool_h * 2 / 3
lane_mid2 = pool_y + pool_h * 1 / 3
ax.plot(
[label_strip, pool_x + pool_w],
[lane_mid1, lane_mid1],
color=LINE_COLOR,
lw=1,
)
ax.plot(
[label_strip, pool_x + pool_w],
[lane_mid2, lane_mid2],
color=LINE_COLOR,
lw=1,
)
y_bok = (lane_top + lane_mid1) / 2
y_jak = (lane_mid1 + lane_mid2) / 2
y_mag = (lane_mid2 + pool_y) / 2
ax.text(
label_strip + 2.5,
y_bok,
"BOK",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
ax.text(
label_strip + 2.5,
y_jak,
"Jako\u015b\u0107",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
ax.text(
label_strip + 2.5,
y_mag,
"Magazyn",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
content_left = label_strip + 5
return y_bok, y_jak, y_mag, content_left
def _draw_bpmn_elements(
ax: Axes,
y_bok: float,
y_jak: float,
y_mag: float,
content_left: float,
) -> None:
"""Draw all BPMN tasks, gateways, and events."""
sx = content_left + 4
ax.add_patch(
plt.Circle(
(sx, y_bok), 2, lw=2, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(
sx, y_bok - 3.5, "Reklamacja\nwp\u0142ywa", fontsize=6, ha="center",
)
t1x = sx + 14
draw_rounded_rect(ax, t1x, y_bok, 14, 6, "Przyjmij\nzg\u0142oszenie")
draw_arrow(ax, sx + 2, y_bok, t1x - 7, y_bok)
t2x = t1x + 18
draw_rounded_rect(
ax, t2x, y_jak, 14, 6, "Zweryfikuj\nzasadno\u015b\u0107",
)
elbow_x = t1x + 10
draw_line(ax, t1x + 7, y_bok, elbow_x, y_bok)
draw_line(ax, elbow_x, y_bok, elbow_x, y_jak)
draw_arrow(ax, elbow_x, y_jak, t2x - 7, y_jak)
gx = t2x + 14
draw_diamond(ax, gx, y_jak, 3.5, "X")
draw_arrow(ax, t2x + 7, y_jak, gx - 3.5, y_jak)
t3x = gx + 14
draw_rounded_rect(
ax, t3x, y_mag, 14, 6, "Przygotuj\nwymian\u0119/zwrot",
)
draw_line(ax, gx, y_jak - 3.5, gx, y_mag)
draw_arrow(ax, gx, y_mag, t3x - 7, y_mag)
ax.text(gx + 1.5, y_jak - 6, "Tak", fontsize=7, ha="left")
t4x = gx + 14
draw_rounded_rect(
ax, t4x, y_jak, 14, 6, "Odrzu\u0107\nreklamacj\u0119",
)
draw_arrow(ax, gx + 3.5, y_jak, t4x - 7, y_jak)
ax.text(gx + 4, y_jak + 2, "Nie", fontsize=7, ha="left")
mx = t4x + 14
draw_diamond(ax, mx, y_bok, 3.5, "X")
draw_line(ax, t4x + 7, y_jak, mx, y_jak)
draw_arrow(ax, mx, y_jak, mx, y_bok - 3.5)
draw_line(ax, t3x + 7, y_mag, mx - 4, y_mag)
draw_line(ax, mx - 4, y_mag, mx - 4, y_bok)
draw_arrow(ax, mx - 4, y_bok, mx - 3.5, y_bok)
t5x = mx + 13
draw_rounded_rect(ax, t5x, y_bok, 14, 6, "Powiadom\nklienta")
draw_arrow(ax, mx + 3.5, y_bok, t5x - 7, y_bok)
ex = t5x + 12
ax.add_patch(
plt.Circle(
(ex, y_bok), 2, lw=3, edgecolor=LINE_COLOR, facecolor="white",
)
)
draw_arrow(ax, t5x + 7, y_bok, ex - 2, y_bok)
ax.text(ex, y_bok - 3.5, "Koniec", fontsize=6, ha="center")
def _draw_bpmn_legend(ax: Axes) -> None:
"""Draw BPMN legend."""
ly = 1
ax.text(
12, ly, "Legenda:", fontsize=7, fontweight="bold", va="center",
)
ax.add_patch(
plt.Circle(
(22, ly), 1, lw=2, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(24, ly, "Start", fontsize=6, va="center")
ax.add_patch(
plt.Circle(
(30, ly), 1, lw=3, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(32, ly, "Koniec", fontsize=6, va="center")
draw_diamond(ax, 40, ly, 1.5, "X", fontsize=5)
ax.text(43, ly, "Bramka XOR", fontsize=6, va="center")
draw_rounded_rect(ax, 58, ly, 7, 2.5, "Zadanie", fontsize=6)
ax.text(65, ly, "Sequence Flow \u2192", fontsize=6, va="center")
def generate_bpmn() -> None:
"""Generate bpmn."""
fig, ax = plt.subplots(figsize=(11, 7.5))
ax.set_xlim(0, 110)
ax.set_ylim(0, 75)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"BPMN 2.0 \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
y_bok, y_jak, y_mag, content_left = _draw_bpmn_pool_and_lanes(ax)
_draw_bpmn_elements(ax, y_bok, y_jak, y_mag, content_left)
_draw_bpmn_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "bpmn_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK BPMN saved")
# =========================================================================
# 2. UML Activity Diagram
# =========================================================================
def _draw_uml_elements(ax: Axes) -> None:
"""Draw all UML activity diagram elements."""
cx = 50
y = 93
step = 11
ax.add_patch(
plt.Circle((cx, y), 1.8, facecolor="black", edgecolor="black"),
)
y -= step
draw_rounded_rect(
ax, cx, y, 28, 6, "Przyjmij zg\u0142oszenie reklamacji",
)
draw_arrow(ax, cx, y + step - 1.8, cx, y + 3)
y -= step
draw_rounded_rect(
ax, cx, y, 28, 6, "Zweryfikuj zasadno\u015b\u0107",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 3)
y -= step
draw_diamond(ax, cx, y, 4)
draw_arrow(ax, cx, y + step - 3, cx, y + 4)
ax.text(
cx + 6, y + 5, "[zasadna?]", fontsize=8, fontstyle="italic",
)
dec_y = y
branch_y = dec_y - step
left_x = cx - 24
draw_rounded_rect(
ax, left_x, branch_y, 22, 6, "Przygotuj\nwymian\u0119/zwrot",
)
draw_line(ax, cx - 4, dec_y, left_x, dec_y)
draw_arrow(ax, left_x, dec_y, left_x, branch_y + 3)
ax.text(
left_x + 2, dec_y + 1.5, "[tak]",
fontsize=8, fontstyle="italic",
)
right_x = cx + 24
draw_rounded_rect(
ax, right_x, branch_y, 22, 6, "Odrzu\u0107\nreklamacj\u0119",
)
draw_line(ax, cx + 4, dec_y, right_x, dec_y)
draw_arrow(ax, right_x, dec_y, right_x, branch_y + 3)
ax.text(
right_x - 12, dec_y + 1.5, "[nie]",
fontsize=8, fontstyle="italic",
)
merge_y = branch_y - step
draw_diamond(ax, cx, merge_y, 4)
draw_line(ax, left_x, branch_y - 3, left_x, merge_y)
draw_line(ax, left_x, merge_y, cx - 4, merge_y)
draw_line(ax, right_x, branch_y - 3, right_x, merge_y)
draw_line(ax, right_x, merge_y, cx + 4, merge_y)
y = merge_y - step
draw_rounded_rect(ax, cx, y, 28, 6, "Powiadom klienta")
draw_arrow(ax, cx, merge_y - 4, cx, y + 3)
ey = y - step
ax.add_patch(
plt.Circle(
(cx, ey), 2.5, lw=2, facecolor="white", edgecolor="black",
)
)
ax.add_patch(
plt.Circle((cx, ey), 1.5, facecolor="black", edgecolor="black"),
)
draw_arrow(ax, cx, y - 3, cx, ey + 2.5)
def _draw_uml_legend(ax: Axes) -> None:
"""Draw UML activity diagram legend."""
ly = 5
ax.add_patch(
plt.Circle((12, ly), 1.2, facecolor="black", edgecolor="black"),
)
ax.text(15, ly, "= Pocz\u0105tek", fontsize=7, va="center")
ax.add_patch(
plt.Circle(
(32, ly), 1.3, lw=2, facecolor="white", edgecolor="black",
)
)
ax.add_patch(
plt.Circle((32, ly), 0.8, facecolor="black", edgecolor="black"),
)
ax.text(35, ly, "= Koniec", fontsize=7, va="center")
draw_diamond(ax, 50, ly, 1.5)
ax.text(53, ly, "= Decyzja/Merge", fontsize=7, va="center")
draw_rounded_rect(ax, 78, ly, 9, 3, "Akcja", fontsize=7)
def generate_uml_activity() -> None:
"""Generate uml activity."""
fig, ax = plt.subplots(figsize=(8.27, 10))
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"UML Activity Diagram \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
_draw_uml_elements(ax)
_draw_uml_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "uml_activity_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK UML Activity saved")

View File

@ -0,0 +1,449 @@
"""EPC and flowchart diagram generators."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
from matplotlib.path import Path as MplPath
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import (
BG_COLOR,
DPI,
FONT_SIZE,
LINE_COLOR,
OUTPUT_DIR,
TITLE_SIZE,
draw_arrow,
draw_diamond,
draw_line,
)
_logger = logging.getLogger(__name__)
# =========================================================================
# 3. EPC (Event-driven Process Chain)
# =========================================================================
def _draw_epc_event(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC event shape (rounded grey box)."""
w, h = 26, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.5",
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="#D8D8D8",
)
ax.add_patch(rect)
ax.text(x, y, text, ha="center", va="center", fontsize=8)
def _draw_epc_function(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC function shape (rounded white box, bold)."""
w, h = 26, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.3",
lw=2,
edgecolor=LINE_COLOR,
facecolor="white",
)
ax.add_patch(rect)
ax.text(
x, y, text,
ha="center", va="center", fontsize=8, fontweight="bold",
)
def _draw_epc_connector(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC logical connector (circle)."""
circle = plt.Circle(
(x, y), 2.8, lw=1.5, edgecolor=LINE_COLOR, facecolor="white",
)
ax.add_patch(circle)
ax.text(
x, y, text,
ha="center", va="center", fontsize=9, fontweight="bold",
)
def _draw_epc_flow(
ax: Axes,
) -> tuple[float, float, float]:
"""Draw sequential EPC flow from E1 through XOR split."""
cx = 50
y = 114
step = 9.5
_draw_epc_event(ax, cx, y, "Reklamacja wp\u0142yn\u0119\u0142a")
y -= step
_draw_epc_function(ax, cx, y, "Przyjmij zg\u0142oszenie")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Zg\u0142oszenie przyj\u0119te")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_function(ax, cx, y, "Zweryfikuj zasadno\u015b\u0107")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Zasadno\u015b\u0107 oceniona")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_connector(ax, cx, y, "XOR")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
return cx, y, step
def _draw_epc_branches(
ax: Axes,
cx: float,
split_y: float,
step: float,
) -> None:
"""Draw EPC branches, merge, and post-merge elements."""
left_x = cx - 28
right_x = cx + 28
by = split_y - step
_draw_epc_event(ax, left_x, by, "Reklamacja zasadna")
draw_line(ax, cx - 2.8, split_y, left_x, split_y)
draw_arrow(ax, left_x, split_y, left_x, by + 2.8)
by2 = by - step
_draw_epc_function(
ax, left_x, by2, "Przygotuj wymian\u0119/zwrot",
)
draw_arrow(ax, left_x, by - 2.8, left_x, by2 + 2.8)
by3 = by2 - step
_draw_epc_event(ax, left_x, by3, "Wymiana przygotowana")
draw_arrow(ax, left_x, by2 - 2.8, left_x, by3 + 2.8)
_draw_epc_event(ax, right_x, by, "Reklamacja niezasadna")
draw_line(ax, cx + 2.8, split_y, right_x, split_y)
draw_arrow(ax, right_x, split_y, right_x, by + 2.8)
_draw_epc_function(ax, right_x, by2, "Odrzu\u0107 reklamacj\u0119")
draw_arrow(ax, right_x, by - 2.8, right_x, by2 + 2.8)
_draw_epc_event(ax, right_x, by3, "Reklamacja odrzucona")
draw_arrow(ax, right_x, by2 - 2.8, right_x, by3 + 2.8)
merge_y = by3 - step
_draw_epc_connector(ax, cx, merge_y, "XOR")
draw_line(ax, left_x, by3 - 2.8, left_x, merge_y)
draw_line(ax, left_x, merge_y, cx - 2.8, merge_y)
draw_line(ax, right_x, by3 - 2.8, right_x, merge_y)
draw_line(ax, right_x, merge_y, cx + 2.8, merge_y)
y = merge_y - step
_draw_epc_function(ax, cx, y, "Powiadom klienta")
draw_arrow(ax, cx, merge_y - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Klient powiadomiony")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
def _draw_epc_legend(ax: Axes) -> None:
"""Draw EPC legend."""
ly = 3
_draw_epc_event(ax, 16, ly, "Zdarzenie")
_draw_epc_function(ax, 46, ly, "Funkcja")
_draw_epc_connector(ax, 68, ly, "XOR")
ax.text(
72, ly, "= \u0141\u0105cznik logiczny", fontsize=7, va="center",
)
def generate_epc() -> None:
"""Generate epc."""
fig, ax = plt.subplots(figsize=(8.27, 11))
ax.set_xlim(0, 100)
ax.set_ylim(0, 120)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"EPC (Event-driven Process Chain)"
" \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
cx, split_y, step = _draw_epc_flow(ax)
_draw_epc_branches(ax, cx, split_y, step)
_draw_epc_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "epc_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK EPC saved")
# =========================================================================
# 4. Classic Flowchart
# =========================================================================
def _draw_fc_terminal(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart terminal (rounded) shape."""
w, h = 20, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=1.0",
lw=2,
edgecolor=LINE_COLOR,
facecolor="#E0E0E0",
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=FONT_SIZE,
fontweight="bold",
)
def _draw_fc_process_box(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart process box (rectangle)."""
w, h = 26, 6
rect = plt.Rectangle(
(x - w / 2, y - h / 2),
w,
h,
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="white",
)
ax.add_patch(rect)
ax.text(
x, y, text, ha="center", va="center", fontsize=FONT_SIZE,
)
def _draw_fc_io_shape(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart I/O parallelogram."""
w, h = 26, 5.5
skew = 3
verts = [
(x - w / 2 + skew, y + h / 2),
(x + w / 2 + skew, y + h / 2),
(x + w / 2 - skew, y - h / 2),
(x - w / 2 - skew, y - h / 2),
(x - w / 2 + skew, y + h / 2),
]
codes = [
MplPath.MOVETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.CLOSEPOLY,
]
patch = mpatches.PathPatch(
MplPath(verts, codes),
facecolor="white",
edgecolor=LINE_COLOR,
lw=1.5,
)
ax.add_patch(patch)
ax.text(
x, y, text, ha="center", va="center", fontsize=FONT_SIZE,
)
def _draw_fc_elements(ax: Axes) -> None:
"""Draw all flowchart elements."""
cx = 50
y = 103
step = 11
_draw_fc_terminal(ax, cx, y, "START")
y -= step
_draw_fc_io_shape(ax, cx, y, "Reklamacja od klienta")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_fc_process_box(ax, cx, y, "Przyjmij zg\u0142oszenie")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 3)
y -= step
_draw_fc_process_box(
ax, cx, y, "Zweryfikuj zasadno\u015b\u0107",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 3)
y -= step
draw_diamond(ax, cx, y, 4.5, "Zasadna?")
draw_arrow(ax, cx, y + step - 3, cx, y + 4.5)
dec_y = y
left_x = cx - 26
_draw_fc_process_box(
ax, left_x, dec_y, "Przygotuj wymian\u0119/zwrot",
)
draw_line(ax, cx - 4.5, dec_y, left_x + 13, dec_y)
ax.text(
cx - 7, dec_y + 2, "Tak",
fontsize=8, ha="center", fontweight="bold",
)
right_x = cx + 26
_draw_fc_process_box(
ax, right_x, dec_y, "Odrzu\u0107 reklamacj\u0119",
)
draw_line(ax, cx + 4.5, dec_y, right_x - 13, dec_y)
ax.text(
cx + 7, dec_y + 2, "Nie",
fontsize=8, ha="center", fontweight="bold",
)
merge_y = dec_y - step
draw_line(ax, left_x, dec_y - 3, left_x, merge_y)
draw_line(ax, right_x, dec_y - 3, right_x, merge_y)
draw_line(ax, left_x, merge_y, right_x, merge_y)
ax.plot(cx, merge_y, "ko", markersize=4)
y = merge_y - step + 3
_draw_fc_process_box(ax, cx, y, "Powiadom klienta")
draw_arrow(ax, cx, merge_y, cx, y + 3)
y -= step
_draw_fc_io_shape(
ax, cx, y, "Odpowied\u017a do klienta",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 2.8)
y -= step
_draw_fc_terminal(ax, cx, y, "KONIEC")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
def _draw_fc_legend(ax: Axes) -> None:
"""Draw flowchart legend."""
ly = 4
ax.text(
5, ly, "Legenda:", fontsize=7, fontweight="bold", va="center",
)
_draw_fc_terminal(ax, 18, ly, "")
ax.text(
18, ly, "Start/\nKoniec",
fontsize=5.5, ha="center", va="center",
)
w, h = 9, 3
ax.add_patch(
plt.Rectangle(
(32 - w / 2, ly - h / 2),
w,
h,
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="white",
)
)
ax.text(32, ly, "Proces", fontsize=6, ha="center", va="center")
draw_diamond(ax, 46, ly, 2)
ax.text(49.5, ly, "= Decyzja", fontsize=6, va="center")
skew = 1.5
w2, h2 = 9, 3
verts = [
(62 - w2 / 2 + skew, ly + h2 / 2),
(62 + w2 / 2 + skew, ly + h2 / 2),
(62 + w2 / 2 - skew, ly - h2 / 2),
(62 - w2 / 2 - skew, ly - h2 / 2),
(62 - w2 / 2 + skew, ly + h2 / 2),
]
codes = [
MplPath.MOVETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.CLOSEPOLY,
]
ax.add_patch(
mpatches.PathPatch(
MplPath(verts, codes),
facecolor="white",
edgecolor=LINE_COLOR,
lw=1.2,
)
)
ax.text(62, ly, "We/Wy", fontsize=6, ha="center", va="center")
def generate_flowchart() -> None:
"""Generate flowchart."""
fig, ax = plt.subplots(figsize=(8.27, 11))
ax.set_xlim(0, 100)
ax.set_ylim(0, 110)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"Schemat blokowy (Flowchart)"
" \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
_draw_fc_elements(ax)
_draw_fc_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "flowchart_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK Flowchart saved")
# =========================================================================

View File

@ -0,0 +1,200 @@
"""Common constants and drawing utilities for Q9/Q12 diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib as mpl
if TYPE_CHECKING:
from matplotlib.axes import Axes
from matplotlib.figure import Figure
mpl.use("Agg")
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
import numpy as np
_logger = logging.getLogger(__name__)
DPI = 300
BG = "white"
LN = "black"
FS = 8
FS_TITLE = 11
FS_SMALL = 6.5
FS_EDGE = 9
OUTPUT_DIR = str(Path(__file__).resolve().parent / "img")
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
GRAY1 = "#E8E8E8"
GRAY2 = "#D0D0D0"
GRAY3 = "#B8B8B8"
GRAY4 = "#F5F5F5"
GRAY5 = "#C0C0C0"
LIGHT_GREEN = "#D5E8D4"
LIGHT_RED = "#F8D7DA"
LIGHT_BLUE = "#D6EAF8"
LIGHT_YELLOW = "#FFF9C4"
LIGHT_ORANGE = "#FFE0B2"
_LAST_CONDITION_INDEX = 3
_CENTER_Y = 2.5
def draw_box(
ax: Axes,
x: float,
y: float,
w: float,
h: float,
text: str,
fill: str = "white",
lw: float = 1.2,
fontsize: float = FS,
fontweight: str = "normal",
ha: str = "center",
va: str = "center",
*,
rounded: bool = True,
edgecolor: str = LN,
) -> None:
"""Draw box."""
if rounded:
rect = FancyBboxPatch(
(x, y),
w,
h,
boxstyle="round,pad=0.05",
lw=lw,
edgecolor=edgecolor,
facecolor=fill,
)
else:
rect = mpatches.Rectangle(
(x, y), w, h, lw=lw, edgecolor=edgecolor, facecolor=fill
)
ax.add_patch(rect)
ax.text(
x + w / 2,
y + h / 2,
text,
ha=ha,
va=va,
fontsize=fontsize,
fontweight=fontweight,
wrap=True,
)
def draw_arrow(
ax: Axes,
x1: float,
y1: float,
x2: float,
y2: float,
lw: float = 1.2,
style: str = "->",
color: str = LN,
) -> None:
"""Draw arrow."""
ax.annotate(
"",
xy=(x2, y2),
xytext=(x1, y1),
arrowprops={"arrowstyle": style, "color": color, "lw": lw},
)
def save_fig(fig: Figure, name: str) -> None:
"""Save fig."""
path = str(Path(OUTPUT_DIR) / name)
fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15)
plt.close(fig)
_logger.info(" Saved: %s", path)
def draw_network_node(
ax: Axes,
name: str,
pos: tuple[float, float],
color: str = "white",
fontsize: float = 10,
r: float = 0.3,
) -> None:
"""Draw a network node (circle)."""
x, y = pos
circle = plt.Circle(
(x, y), r, fill=True, facecolor=color, edgecolor=LN, linewidth=1.5, zorder=5
)
ax.add_patch(circle)
ax.text(
x,
y,
name,
ha="center",
va="center",
fontsize=fontsize,
fontweight="bold",
zorder=6,
)
def draw_network_edge(
ax: Axes,
pos1: tuple[float, float],
pos2: tuple[float, float],
label: str = "",
color: str = LN,
lw: float = 1.5,
offset: float = 0.0,
*,
directed: bool = True,
r: float = 0.33,
label_bg: str = "white",
) -> None:
"""Draw a directed edge with label."""
x1, y1 = pos1
x2, y2 = pos2
dx, dy = x2 - x1, y2 - y1
length = np.sqrt(dx**2 + dy**2)
if length == 0:
return
sx = x1 + r * dx / length
sy = y1 + r * dy / length
ex = x2 - r * dx / length
ey = y2 - r * dy / length
if directed:
ax.annotate(
"",
xy=(ex, ey),
xytext=(sx, sy),
arrowprops={"arrowstyle": "->", "color": color, "lw": lw},
)
else:
ax.plot([sx, ex], [sy, ey], color=color, linewidth=lw, zorder=2)
if label:
mx = (x1 + x2) / 2
my = (y1 + y2) / 2
perp_x = -dy / length * (0.2 + offset)
perp_y = dx / length * (0.2 + offset)
ax.text(
mx + perp_x,
my + perp_y,
str(label),
ha="center",
va="center",
fontsize=FS_EDGE,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": label_bg,
"edgecolor": GRAY3,
"alpha": 0.95,
},
zorder=4,
)

View File

@ -0,0 +1,300 @@
"""PYTANIE 12 flow diagrams: Ford-Fulkerson, Hungarian, min-cost flow."""
from __future__ import annotations
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._q9q12_common import (
FS,
FS_SMALL,
FS_TITLE,
GRAY3,
GRAY4,
LIGHT_GREEN,
LIGHT_RED,
LIGHT_YELLOW,
LN,
draw_network_edge,
draw_network_node,
save_fig,
)
def gen_ford_fulkerson() -> None:
"""Ford-Fulkerson max flow step-by-step."""
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
fig.suptitle(
"Ford-Fulkerson — Maksymalny przepływ (krok po kroku)",
fontsize=FS_TITLE,
fontweight="bold",
)
pos = {"s": (0.5, 1.5), "A": (2.5, 2.5), "B": (2.5, 0.5), "t": (4.5, 1.5)}
steps = [
{
"title": "Krok 0: Sieć wejściowa\n(przepustowości)",
"edges": [
("s", "A", "10"),
("s", "B", "8"),
("A", "t", "6"),
("B", "t", "10"),
("B", "A", "2"),
],
"flows": {},
"path": [],
"note": "Szukamy ścieżki s→...→t",
},
{
"title": "Krok 1: Ścieżka s→A→t\nPrzepływ: +6 (min(10,6))",
"edges": [
("s", "A", "4/10"),
("s", "B", "0/8"),
("A", "t", "6/6"),
("B", "t", "0/10"),
("B", "A", "0/2"),
],
"flows": {},
"path": [("s", "A"), ("A", "t")],
"note": "Łączny przepływ: 6",
},
{
"title": "Krok 2: Ścieżka s→B→t\nPrzepływ: +8 (min(8,10))",
"edges": [
("s", "A", "4/10"),
("s", "B", "8/8"),
("A", "t", "6/6"),
("B", "t", "8/10"),
("B", "A", "0/2"),
],
"flows": {},
"path": [("s", "B"), ("B", "t")],
"note": "Łączny przepływ: 14",
},
{
"title": "Krok 3: Brak ścieżki powiększającej\nMAX FLOW = 14",
"edges": [
("s", "A", "4/10"),
("s", "B", "8/8"),
("A", "t", "6/6"),
("B", "t", "8/10"),
("B", "A", "0/2"),
],
"flows": {},
"path": [],
"note": "Min-cut: {s,A,B}|{t}\nA→t(6)+B→t(10)=16? Nie!\ns→B(8)+A→t(6)=14 ✓",
},
]
for _idx, (ax, step) in enumerate(zip(axes.flat, steps, strict=False)):
ax.set_xlim(-0.3, 5.3)
ax.set_ylim(-0.3, 3.3)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(step["title"], fontsize=FS, fontweight="bold", pad=5)
path_set = set(step["path"])
for e in step["edges"]:
u, v, label = e
is_path = (u, v) in path_set
c = "#C62828" if is_path else LN
w = 2.5 if is_path else 1.5
draw_network_edge(ax, pos[u], pos[v], label=label, color=c, lw=w)
for name, p in pos.items():
if name == "s":
c = LIGHT_GREEN
elif name == "t":
c = LIGHT_RED
else:
c = "white"
draw_network_node(ax, name, p, color=c)
ax.text(
2.5,
-0.15,
step["note"],
fontsize=FS_SMALL,
ha="center",
va="center",
style="italic",
bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY4, "edgecolor": GRAY3},
)
fig.tight_layout(rect=[0, 0, 1, 0.93])
save_fig(fig, "ford_fulkerson_example.png")
def gen_hungarian() -> None:
"""Hungarian algorithm step-by-step."""
fig, axes = plt.subplots(2, 2, figsize=(9, 7))
fig.suptitle(
"Algorytm węgierski — Problem przydziału (krok po kroku)",
fontsize=FS_TITLE,
fontweight="bold",
)
matrices = [
{
"title": "Macierz kosztów (wejściowa)",
"data": [[8, 4, 7], [5, 2, 3], [9, 4, 8]],
"highlight": [],
"note": "Minimalizuj łączny koszt przydziału",
},
{
"title": "Krok 1: Redukcja wierszy\n(odejmij min z wiersza)",
"data": [[4, 0, 3], [3, 0, 1], [5, 0, 4]],
"highlight": [(0, 1), (1, 1), (2, 1)],
"note": "min: A=4, B=2, C=4",
},
{
"title": "Krok 2: Redukcja kolumn\n(odejmij min z kolumny)",
"data": [[1, 0, 2], [0, 0, 0], [2, 0, 3]],
"highlight": [(1, 0), (0, 1), (1, 1), (2, 1), (1, 2)],
"note": "min: Z1=3, Z2=0, Z3=1",
},
{
"title": "Krok 3: Optymalne przypisanie\nA→Z2(4), B→Z1(5), C=?",
"data": [[0, 0, 1], [0, 1, 0], [1, 0, 2]],
"highlight": [(0, 1), (1, 0), (2, 1)],
"note": "Optymalne: A→Z1(8) + B→Z3(3) + C→Z2(4) = 15",
},
]
rows = ["A", "B", "C"]
cols = ["Z1", "Z2", "Z3"]
for ax, m in zip(axes.flat, matrices, strict=False):
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-1, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(m["title"], fontsize=FS, fontweight="bold", pad=5)
# Column headers
for j, col in enumerate(cols):
ax.text(
j + 1.5,
3.8,
col,
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
# Row headers and data
for i, row in enumerate(rows):
y = 2.8 - i
ax.text(
0.3, y, row, ha="center", va="center", fontsize=9, fontweight="bold"
)
for j in range(3):
val = m["data"][i][j]
is_zero = val == 0
is_hl = (i, j) in m["highlight"]
fc = (
LIGHT_GREEN if is_hl else ("white" if not is_zero else LIGHT_YELLOW)
)
rect = FancyBboxPatch(
(j + 1.0, y - 0.35),
1.0,
0.7,
boxstyle="round,pad=0.05",
lw=1.2,
edgecolor=LN if not is_hl else "#1B5E20",
facecolor=fc,
)
ax.add_patch(rect)
ax.text(
j + 1.5,
y,
str(val),
ha="center",
va="center",
fontsize=10,
fontweight="bold" if is_hl else "normal",
)
ax.text(
2.0,
-0.6,
m["note"],
fontsize=FS_SMALL,
ha="center",
va="center",
style="italic",
bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY4, "edgecolor": GRAY3},
)
fig.tight_layout(rect=[0, 0, 1, 0.93])
save_fig(fig, "hungarian_example.png")
def gen_min_cost_flow() -> None:
"""Min-cost flow example."""
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
fig.suptitle(
"Minimalny koszt przepływu — transport 10 ton",
fontsize=FS_TITLE,
fontweight="bold",
)
pos = {"s": (0.5, 1.5), "A": (2.5, 2.5), "B": (2.5, 0.5), "t": (4.5, 1.5)}
# Left: network with capacities and costs
ax = axes[0]
ax.set_xlim(-0.3, 5.3)
ax.set_ylim(-0.3, 3.3)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Sieć (przepustowość, koszt/t)", fontsize=FS, fontweight="bold")
edges_info = [
("s", "A", "(8, 2zł)"),
("s", "B", "(5, 4zł)"),
("A", "t", "(6, 3zł)"),
("B", "t", "(5, 1zł)"),
]
for u, v, label in edges_info:
draw_network_edge(ax, pos[u], pos[v], label=label, r=0.33)
for name, p in pos.items():
c = LIGHT_GREEN if name == "s" else (LIGHT_RED if name == "t" else "white")
draw_network_node(ax, name, p, color=c)
# Right: optimal flow
ax = axes[1]
ax.set_xlim(-0.3, 5.3)
ax.set_ylim(-0.3, 3.3)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Optymalny przepływ (koszt = 50 zł)", fontsize=FS, fontweight="bold")
opt_edges = [
("s", "A", "5/8", "#1B5E20"),
("s", "B", "5/5", "#C62828"),
("A", "t", "5/6", "#1B5E20"),
("B", "t", "5/5", "#C62828"),
]
for u, v, label, color in opt_edges:
draw_network_edge(ax, pos[u], pos[v], label=label, color=color, lw=2.0, r=0.33)
for name, p in pos.items():
c = LIGHT_GREEN if name == "s" else (LIGHT_RED if name == "t" else "white")
draw_network_node(ax, name, p, color=c)
ax.text(
2.5,
-0.15,
"5tx(2+3)=25zł + 5tx(4+1)=25zł = 50zł",
fontsize=FS,
ha="center",
style="italic",
bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY4, "edgecolor": GRAY3},
)
fig.tight_layout(rect=[0, 0, 1, 0.9])
save_fig(fig, "min_cost_flow_example.png")

View File

@ -0,0 +1,348 @@
"""PYTANIE 12 graph diagrams: CPM, Kruskal, TSP."""
from __future__ import annotations
import matplotlib.pyplot as plt
import numpy as np
from python_pkg.praca_magisterska_video.generate_images._q9q12_common import (
_CENTER_Y,
FS,
FS_SMALL,
FS_TITLE,
GRAY3,
GRAY4,
LIGHT_BLUE,
LIGHT_GREEN,
LIGHT_RED,
LN,
draw_network_edge,
draw_network_node,
save_fig,
)
def gen_cpm() -> None:
"""CPM critical path diagram."""
fig, ax = plt.subplots(1, 1, figsize=(10, 5))
ax.set_xlim(-0.5, 12)
ax.set_ylim(-0.5, 5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"CPM — Ścieżka krytyczna projektu IT",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Task positions: (x, y)
tasks = {
"START": (0.5, 2.5),
"A\n3 tyg": (2.5, 2.5),
"B\n4 tyg": (5.0, 3.8),
"C\n5 tyg": (5.0, 1.2),
"D\n6 tyg": (7.5, 3.8),
"E\n2 tyg": (9.5, 2.5),
"F\n1 tyg": (11.5, 2.5),
}
# Critical path: START→A→B→D→E→F
critical = {"START", "A\n3 tyg", "B\n4 tyg", "D\n6 tyg", "E\n2 tyg", "F\n1 tyg"}
critical_edges = {
("START", "A\n3 tyg"),
("A\n3 tyg", "B\n4 tyg"),
("B\n4 tyg", "D\n6 tyg"),
("D\n6 tyg", "E\n2 tyg"),
("E\n2 tyg", "F\n1 tyg"),
}
edges = [
("START", "A\n3 tyg"),
("A\n3 tyg", "B\n4 tyg"),
("A\n3 tyg", "C\n5 tyg"),
("B\n4 tyg", "D\n6 tyg"),
("C\n5 tyg", "E\n2 tyg"),
("D\n6 tyg", "E\n2 tyg"),
("E\n2 tyg", "F\n1 tyg"),
]
# Draw edges
for u, v in edges:
is_crit = (u, v) in critical_edges
c = "#C62828" if is_crit else GRAY3
w = 2.5 if is_crit else 1.2
draw_network_edge(ax, tasks[u], tasks[v], color=c, lw=w, r=0.5)
# Draw nodes
for name, p in tasks.items():
is_crit = name in critical
c = LIGHT_RED if is_crit else LIGHT_BLUE
r = 0.45
circle = plt.Circle(
p,
r,
fill=True,
facecolor=c,
edgecolor="#C62828" if is_crit else LN,
linewidth=2.0 if is_crit else 1.2,
zorder=5,
)
ax.add_patch(circle)
ax.text(
p[0],
p[1],
name,
ha="center",
va="center",
fontsize=7 if "\n" in name else 8,
fontweight="bold",
zorder=6,
)
# ES/EF labels
es_ef = [
("A\n3 tyg", "ES=0, EF=3"),
("B\n4 tyg", "ES=3, EF=7"),
("C\n5 tyg", "ES=3, EF=8\nzapas=5"),
("D\n6 tyg", "ES=7, EF=13"),
("E\n2 tyg", "ES=13, EF=15"),
("F\n1 tyg", "ES=15, EF=16"),
]
for name, label in es_ef:
x, y = tasks[name]
offset_y = 0.7 if y > _CENTER_Y else -0.7
ax.text(
x,
y + offset_y,
label,
ha="center",
va="center",
fontsize=FS_SMALL,
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": "white",
"edgecolor": GRAY3,
"alpha": 0.95,
},
)
# Legend
ax.text(
0.5,
-0.2,
"Ścieżka krytyczna: A→B→D→E→F (16 tyg)",
fontsize=9,
fontweight="bold",
color="#C62828",
)
ax.text(
0.5,
-0.6,
"C ma 5 tyg zapasu — może się opóźnić bez wpływu na projekt",
fontsize=FS,
style="italic",
)
save_fig(fig, "cpm_example.png")
def gen_kruskal() -> None:
"""Kruskal MST construction step-by-step."""
fig, axes = plt.subplots(2, 2, figsize=(9, 8))
fig.suptitle(
"Kruskal — budowa MST krok po kroku", fontsize=FS_TITLE, fontweight="bold"
)
pos = {"A": (0.5, 2.5), "B": (3.0, 2.5), "C": (3.0, 0.5), "D": (0.5, 0.5)}
all_edges = [
("C", "D", 1),
("A", "C", 2),
("A", "B", 4),
("B", "C", 6),
("B", "D", 7),
("A", "D", 8),
]
steps = [
{
"title": "Graf wejściowy\n(6 krawędzi)",
"mst": [],
"consider": None,
"note": "Posortowane: CD(1), AC(2), AB(4), BC(6), BD(7), AD(8)",
},
{
"title": "Krok 1: Dodaj C-D (waga 1)\nNajlżejsza krawędź",
"mst": [("C", "D", 1)],
"consider": ("C", "D"),
"note": "MST = {C-D}, koszt = 1",
},
{
"title": "Krok 2: Dodaj A-C (waga 2)\nA nie w {C,D}",
"mst": [("C", "D", 1), ("A", "C", 2)],
"consider": ("A", "C"),
"note": "MST = {C-D, A-C}, koszt = 3",
},
{
"title": "Krok 3: Dodaj A-B (waga 4)\nB nie w {A,C,D} → KONIEC",
"mst": [("C", "D", 1), ("A", "C", 2), ("A", "B", 4)],
"consider": ("A", "B"),
"note": "MST = {C-D, A-C, A-B}, koszt = 7 ✓",
},
]
for ax, step in zip(axes.flat, steps, strict=False):
ax.set_xlim(-0.5, 4.0)
ax.set_ylim(-0.5, 3.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(step["title"], fontsize=FS, fontweight="bold", pad=5)
mst_set = {(u, v) for u, v, _ in step["mst"]}
for u, v, w in all_edges:
in_mst = (u, v) in mst_set or (v, u) in mst_set
is_cur = step["consider"] and (
(u, v) == step["consider"] or (v, u) == step["consider"]
)
if is_cur:
c, lw = "#C62828", 3.0
elif in_mst:
c, lw = "#1B5E20", 2.5
else:
c, lw = GRAY3, 1.0
draw_network_edge(
ax,
pos[u],
pos[v],
label=str(w),
color=c,
lw=lw,
directed=False,
label_bg=LIGHT_GREEN if in_mst else "white",
)
for name, p in pos.items():
# Check if in current MST component
in_mst = any(name in (u, v) for u, v, _ in step["mst"])
c = LIGHT_GREEN if in_mst else "white"
draw_network_node(ax, name, p, color=c, r=0.3)
ax.text(
1.75,
-0.3,
step["note"],
fontsize=FS_SMALL,
ha="center",
va="center",
style="italic",
bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY4, "edgecolor": GRAY3},
)
fig.tight_layout(rect=[0, 0, 1, 0.93])
save_fig(fig, "kruskal_example.png")
def gen_tsp() -> None:
"""TSP nearest neighbor heuristic."""
fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
fig.suptitle(
"TSP — heurystyka Nearest Neighbor (5 miast)",
fontsize=FS_TITLE,
fontweight="bold",
)
pos = {
"A": (0.5, 3.0),
"B": (2.0, 4.0),
"C": (4.0, 3.5),
"D": (3.5, 1.0),
"E": (1.5, 1.5),
}
dist = {
("A", "B"): 20,
("A", "C"): 42,
("A", "D"): 35,
("A", "E"): 12,
("B", "C"): 30,
("B", "D"): 34,
("B", "E"): 10,
("C", "D"): 12,
("C", "E"): 40,
("D", "E"): 25,
}
# Left: full graph with all distances
ax = axes[0]
ax.set_xlim(-0.5, 5.0)
ax.set_ylim(0, 5.0)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Graf pełny (odległości)", fontsize=FS, fontweight="bold")
for (u, v), d in dist.items():
draw_network_edge(
ax, pos[u], pos[v], label=str(d), color=GRAY3, lw=0.8, directed=False, r=0.3
)
for name, p in pos.items():
draw_network_node(ax, name, p, color=LIGHT_BLUE, r=0.3)
# Right: NN solution
ax = axes[1]
ax.set_xlim(-0.5, 5.0)
ax.set_ylim(0, 5.0)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Nearest Neighbor (start A)\nTrasa: A→E→B→C→D→A = 99",
fontsize=FS,
fontweight="bold",
)
nn_path = [
("A", "E", 12),
("E", "B", 10),
("B", "C", 30),
("C", "D", 12),
("D", "A", 35),
]
colors = ["#C62828", "#1B5E20", "#1565C0", "#E65100", "#4A148C"]
for i, (u, v, d) in enumerate(nn_path):
draw_network_edge(
ax,
pos[u],
pos[v],
label=f"{d}",
color=colors[i],
lw=2.0,
directed=True,
r=0.3,
)
# Step number
mx = (pos[u][0] + pos[v][0]) / 2
my = (pos[u][1] + pos[v][1]) / 2
dx = pos[v][0] - pos[u][0]
dy = pos[v][1] - pos[u][1]
length = np.sqrt(dx**2 + dy**2)
ox = dy / length * 0.45
oy = -dx / length * 0.45
ax.text(
mx + ox,
my + oy,
f"#{i + 1}",
fontsize=FS_SMALL,
ha="center",
color=colors[i],
fontweight="bold",
)
for name, p in pos.items():
c = LIGHT_GREEN if name == "A" else LIGHT_BLUE
draw_network_node(ax, name, p, color=c, r=0.3)
fig.tight_layout(rect=[0, 0, 1, 0.9])
save_fig(fig, "tsp_nearest_neighbor.png")

View File

@ -0,0 +1,344 @@
"""PYTANIE 9 diagrams: IPC, deadlock, producer-consumer."""
from __future__ import annotations
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images._q9q12_common import (
_LAST_CONDITION_INDEX,
FS,
FS_SMALL,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LIGHT_BLUE,
LIGHT_GREEN,
LIGHT_ORANGE,
LIGHT_RED,
LIGHT_YELLOW,
LN,
draw_arrow,
draw_box,
save_fig,
)
def gen_ipc_mechanisms() -> None:
"""IPC mechanisms comparison diagram."""
fig, ax = plt.subplots(1, 1, figsize=(8, 5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Mechanizmy IPC — porównanie", fontsize=FS_TITLE, fontweight="bold", pad=10
)
mechanisms = [
(
"Pipe",
"→ jednokierunkowy\n→ bufor w jądrze\n→ spokrewnione procesy",
"ls | grep txt",
GRAY1,
),
(
"Shared\nMemory",
"→ wspólna ramka RAM\n→ zero kopiowania\n→ wymaga synchronizacji",
"mmap() / shm_open()",
LIGHT_GREEN,
),
(
"Message\nQueue",
"→ strukturalne wiad.\n→ asynchroniczna\n→ filtrowanie typów",
"msgsnd() / msgrcv()",
LIGHT_BLUE,
),
(
"Socket",
"→ dwukierunkowy\n→ lokalny lub sieciowy\n→ TCP/UDP",
"connect() / accept()",
LIGHT_YELLOW,
),
]
for i, (name, desc, example, color) in enumerate(mechanisms):
x = 0.3
y = 5.5 - i * 1.5
# Box for mechanism name
draw_box(ax, x, y, 1.5, 1.0, name, fill=color, fontsize=9, fontweight="bold")
# Description
ax.text(
x + 2.0,
y + 0.5,
desc,
fontsize=FS,
va="center",
ha="left",
family="monospace",
)
# Example
draw_box(ax, 6.5, y + 0.15, 3.0, 0.7, example, fill=GRAY4, fontsize=FS_SMALL)
# Draw process boxes for pipe illustration at top
y_top = 6.3
ax.text(
5.0,
y_top,
"Proces A ──bufor jądra──▶ Proces B",
fontsize=FS,
ha="center",
va="center",
family="monospace",
bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY1, "edgecolor": GRAY3},
)
# Legend
ax.text(
0.3,
0.3,
"Szybkość: Shared Memory > Pipe ≈ MsgQueue > Socket (sieciowy)",
fontsize=FS,
va="center",
style="italic",
)
save_fig(fig, "ipc_mechanisms.png")
def gen_deadlock_illustration() -> None:
"""Deadlock circular wait diagram."""
fig, ax = plt.subplots(1, 1, figsize=(6, 5))
ax.set_xlim(0, 8)
ax.set_ylim(0, 6.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Zakleszczenie (Deadlock) — cykliczne oczekiwanie",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Thread boxes
draw_box(
ax,
0.5,
3.5,
2.0,
1.2,
"Wątek A\n(trzyma Mutex 1)",
fill=LIGHT_BLUE,
fontsize=9,
fontweight="bold",
)
draw_box(
ax,
5.5,
3.5,
2.0,
1.2,
"Wątek B\n(trzyma Mutex 2)",
fill=LIGHT_ORANGE,
fontsize=9,
fontweight="bold",
)
# Resource boxes
draw_box(
ax,
0.5,
0.8,
2.0,
1.0,
"Mutex 1\nzablokowany",
fill=GRAY2,
fontsize=8,
fontweight="bold",
)
draw_box(
ax,
5.5,
0.8,
2.0,
1.0,
"Mutex 2\nzablokowany",
fill=GRAY2,
fontsize=8,
fontweight="bold",
)
# Hold arrows (downward)
draw_arrow(ax, 1.5, 3.5, 1.5, 1.8, lw=2.0, color="#333333")
ax.text(0.3, 2.65, "trzyma", fontsize=FS, ha="center", rotation=90, color="#333333")
draw_arrow(ax, 6.5, 3.5, 6.5, 1.8, lw=2.0, color="#333333")
ax.text(7.7, 2.65, "trzyma", fontsize=FS, ha="center", rotation=90, color="#333333")
# Arrows: "waits for" (across, with red)
draw_arrow(ax, 2.5, 4.3, 5.5, 4.3, lw=2.5, color="#C62828")
ax.text(
4.0,
4.6,
"czeka na Mutex 2",
fontsize=FS,
ha="center",
color="#C62828",
fontweight="bold",
)
draw_arrow(ax, 5.5, 3.7, 2.5, 3.7, lw=2.5, color="#C62828")
ax.text(
4.0,
3.2,
"czeka na Mutex 1",
fontsize=FS,
ha="center",
color="#C62828",
fontweight="bold",
)
# Coffman conditions
conditions = [
"1. Mutual Exclusion — zasoby wyłączne",
"2. Hold and Wait — trzymaj + czekaj",
"3. No Preemption — nie można zabrać siłą",
"4. Circular Wait — cykl oczekiwania ← złam ten!",
]
for i, cond in enumerate(conditions):
color_c = "#C62828" if i == _LAST_CONDITION_INDEX else LN
fw = "bold" if i == _LAST_CONDITION_INDEX else "normal"
ax.text(
0.5,
0.5 - i * 0.25 + 0.2,
cond,
fontsize=FS_SMALL,
color=color_c,
fontweight=fw,
va="center",
)
save_fig(fig, "deadlock_illustration.png")
def gen_producer_consumer() -> None:
"""Producer-consumer with bounded buffer diagram."""
fig, ax = plt.subplots(1, 1, figsize=(8, 4.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 5.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Producent-Konsument z buforem cyklicznym (N=4)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Producer
draw_box(
ax,
0.3,
2.0,
1.8,
1.5,
"Producent\n\nwstaw(elem)\nV(full)\nV(mutex)",
fill=LIGHT_GREEN,
fontsize=FS,
fontweight="bold",
)
# Buffer slots
buf_x = 3.0
buf_y = 2.5
slot_w = 1.0
slot_h = 0.8
items = ["A", "B", "", ""]
fills = [LIGHT_BLUE, LIGHT_BLUE, "white", "white"]
for i, (item, fc) in enumerate(zip(items, fills, strict=False)):
x = buf_x + i * slot_w
draw_box(
ax,
x,
buf_y,
slot_w,
slot_h,
item,
fill=fc,
fontsize=10,
fontweight="bold",
rounded=False,
)
ax.text(
buf_x + 2.0,
buf_y + slot_h + 0.3,
"Bufor (N=4)",
fontsize=9,
ha="center",
fontweight="bold",
)
ax.text(
buf_x + 2.0,
buf_y - 0.3,
"full=2, empty=2",
fontsize=FS,
ha="center",
family="monospace",
)
# Consumer
draw_box(
ax,
7.8,
2.0,
1.8,
1.5,
"Konsument\n\npobierz()\nV(empty)\nV(mutex)",
fill=LIGHT_YELLOW,
fontsize=FS,
fontweight="bold",
)
# Arrows
draw_arrow(ax, 2.1, 2.75, 3.0, 2.9, lw=1.5)
draw_arrow(ax, 7.0, 2.9, 7.8, 2.75, lw=1.5)
# Semaphores
sems = [
("mutex = 1", "wyłączny dostęp do bufora", GRAY2),
("empty = 2", "wolne sloty (P = czekaj, V = +1)", LIGHT_GREEN),
("full = 2", "pełne sloty (P = czekaj, V = +1)", LIGHT_BLUE),
]
for i, (name, desc, color) in enumerate(sems):
y = 1.2 - i * 0.45
draw_box(
ax,
3.0,
y,
1.5,
0.35,
name,
fill=color,
fontsize=FS_SMALL,
fontweight="bold",
)
ax.text(4.7, y + 0.17, desc, fontsize=FS_SMALL, va="center")
# Warning
ax.text(
0.3,
4.8,
"KOLEJNOŚĆ: P(empty/full) PRZED P(mutex)! Odwrotnie = DEADLOCK",
fontsize=FS,
fontweight="bold",
color="#C62828",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": LIGHT_RED,
"edgecolor": "#C62828",
},
)
save_fig(fig, "producer_consumer.png")

View File

@ -0,0 +1,326 @@
"""Robot movement types, online/offline, ROS, RAPID."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
from python_pkg.praca_magisterska_video.generate_images.generate_robot_lang_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY2,
GRAY4,
GRAY5,
OUTPUT_DIR,
WHITE,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 3. Robot Movement Types (PTP, LIN, CIRC)
# ============================================================
def _draw_ptp_subplot(ax: Axes) -> None:
"""Draw the PTP (Point-to-Point) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"PTP (Point-to-Point)\nMoveJ / PTP",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
start = (0.5, 0.5)
end = (3.5, 3.5)
ax.plot(*start, "ko", ms=10, zorder=5)
ax.plot(*end, "ks", ms=10, zorder=5)
ax.text(start[0] - 0.3, start[1] - 0.3, "Start", fontsize=7, ha="center")
ax.text(end[0] + 0.3, end[1] + 0.3, "Cel", fontsize=7, ha="center")
# Curved path (joint space = not necessarily straight in Cartesian)
t = np.linspace(0, 1, 50)
x_ptp = start[0] + (end[0] - start[0]) * t + 0.8 * np.sin(np.pi * t)
y_ptp = start[1] + (end[1] - start[1]) * t - 0.3 * np.sin(np.pi * t)
ax.plot(x_ptp, y_ptp, "k-", lw=2)
ax.annotate(
"",
xy=(x_ptp[-1], y_ptp[-1]),
xytext=(x_ptp[-3], y_ptp[-3]),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
2.8,
1.2,
"Ścieżka\nw kartezjańskiej\nnieokreślona!",
fontsize=6,
ha="center",
style="italic",
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5},
)
ax.text(
2.0,
-0.3,
"Najszybszy, ale\nścieżka nieprzewidywalna",
fontsize=6,
ha="center",
style="italic",
)
ax.set_xlabel("")
ax.set_ylabel("")
ax.tick_params(labelsize=6)
def _draw_lin_subplot(ax: Axes) -> None:
"""Draw the LIN (Linear) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"LIN (Linear)\nMoveL / LIN",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
start = (0.5, 1.0)
end = (3.5, 3.5)
ax.plot(*start, "ko", ms=10, zorder=5)
ax.plot(*end, "ks", ms=10, zorder=5)
ax.text(start[0] - 0.3, start[1] - 0.3, "Start", fontsize=7, ha="center")
ax.text(end[0] + 0.3, end[1] + 0.3, "Cel", fontsize=7, ha="center")
# Straight line
ax.plot([start[0], end[0]], [start[1], end[1]], "k-", lw=2)
ax.annotate(
"",
xy=end,
xytext=(
start[0] + 0.9 * (end[0] - start[0]),
start[1] + 0.9 * (end[1] - start[1]),
),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
# Show intermediate points
for frac in [0.25, 0.5, 0.75]:
px = start[0] + frac * (end[0] - start[0])
py = start[1] + frac * (end[1] - start[1])
ax.plot(px, py, "k.", ms=6)
ax.text(
2.0,
-0.3,
"Prosta linia TCP\nIK w każdym punkcie",
fontsize=6,
ha="center",
style="italic",
)
ax.tick_params(labelsize=6)
def _draw_circ_subplot(ax: Axes) -> None:
"""Draw the CIRC (Circular) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"CIRC (Circular)\nMoveC / CIRC",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
# Arc through 3 points
center = (2.0, 1.5)
radius = 2.0
theta_start = np.radians(20)
theta_end = np.radians(160)
theta = np.linspace(theta_start, theta_end, 50)
x_circ = center[0] + radius * np.cos(theta)
y_circ = center[1] + radius * np.sin(theta)
ax.plot(x_circ, y_circ, "k-", lw=2)
ax.annotate(
"",
xy=(x_circ[-1], y_circ[-1]),
xytext=(x_circ[-3], y_circ[-3]),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
# Start, auxiliary, end points
ax.plot(x_circ[0], y_circ[0], "ko", ms=10, zorder=5)
ax.plot(x_circ[24], y_circ[24], "k^", ms=8, zorder=5)
ax.plot(x_circ[-1], y_circ[-1], "ks", ms=10, zorder=5)
ax.text(x_circ[0] + 0.3, y_circ[0] - 0.3, "Start", fontsize=7)
ax.text(
x_circ[24] + 0.05,
y_circ[24] + 0.25,
"Pkt\npomocniczy",
fontsize=6,
ha="center",
)
ax.text(x_circ[-1] - 0.5, y_circ[-1] - 0.3, "Cel", fontsize=7)
# Center
ax.plot(*center, "k+", ms=8, mew=1.5)
ax.text(center[0], center[1] - 0.3, "środek", fontsize=6, ha="center")
ax.text(
2.0,
-0.3,
"Łuk wyznaczony\nprzez 3 punkty",
fontsize=6,
ha="center",
style="italic",
)
ax.tick_params(labelsize=6)
def draw_movement_types() -> None:
"""Draw movement types."""
fig, axes = plt.subplots(1, 3, figsize=(8.27, 3.2))
fig.suptitle(
"Typy ruchu robota: PTP, LIN, CIRC",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
_draw_ptp_subplot(axes[0])
_draw_lin_subplot(axes[1])
_draw_circ_subplot(axes[2])
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_movement_types.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_movement_types.png")
# ============================================================
# 4. Online vs Offline Programming
# ============================================================
def draw_online_offline() -> None:
"""Draw online offline."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 4.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 6.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Programowanie robotów: Online (teach-in) vs Offline",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# === ONLINE side (left) ===
# Title
draw_box(
ax,
0.3,
5.2,
4.2,
0.8,
"ONLINE\n(teach-in / pendant)",
fill=GRAY2,
fontsize=9,
fontweight="bold",
)
steps_online = [
(4.2, "Operator przy robocie\nz teach pendantem"),
(3.2, 'Prowadzi ramię\n„za rękę" do punktów'),
(2.2, "Robot zapamiętuje\npozycje (record)"),
(1.2, "Odtwarzanie\nzapisanej ścieżki"),
]
for y, txt in steps_online:
draw_box(ax, 0.5, y, 3.8, 0.8, txt, fill=WHITE, fontsize=7)
for i in range(len(steps_online) - 1):
draw_arrow(ax, 2.4, steps_online[i][0], 2.4, steps_online[i + 1][0] + 0.8)
# Pros/cons
ax.text(
2.4,
0.6,
"✓ Proste, intuicyjne\n✗ Wymaga zatrzymania produkcji\n✗ Niska precyzja",
ha="center",
va="center",
fontsize=6.5,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5, "lw": 0.8},
)
# Divider
ax.plot([4.9, 4.9], [0.3, 6.2], "k--", lw=1, alpha=0.5)
# === OFFLINE side (right) ===
draw_box(
ax,
5.3,
5.2,
4.2,
0.8,
"OFFLINE\n(symulacja / CAD/CAM)",
fill=GRAY2,
fontsize=9,
fontweight="bold",
)
steps_offline = [
(4.2, "Model 3D robota +\nśrodowisko w symulatorze"),
(3.2, "Programowanie ścieżek\nw środowisku wirtualnym"),
(2.2, "Weryfikacja kolizji\ni optymalizacja"),
(1.2, "Transfer na\nrzeczywistego robota"),
]
for y, txt in steps_offline:
draw_box(ax, 5.5, y, 3.8, 0.8, txt, fill=WHITE, fontsize=7)
for i in range(len(steps_offline) - 1):
draw_arrow(ax, 7.4, steps_offline[i][0], 7.4, steps_offline[i + 1][0] + 0.8)
ax.text(
7.4,
0.6,
"✓ Bez zatrzymania produkcji\n"
"✓ Wysoka precyzja, symulacja\n"
"✗ Wymaga kalibracji",
ha="center",
va="center",
fontsize=6.5,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5, "lw": 0.8},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_online_offline.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_online_offline.png")
# ============================================================
# 5. ROS Architecture (pub/sub)
# ============================================================

View File

@ -0,0 +1,355 @@
"""Robot language diagrams - TRMS pyramid and vendor comparison."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_robot_lang_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
WHITE,
draw_box,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 1. T-R-M-S Abstraction Pyramid
# ============================================================
def draw_trms_pyramid() -> None:
"""Draw trms pyramid."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Poziomy abstrakcji języków programowania robotów (T-R-M-S)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Pyramid layers (bottom to top)
layers = [
# Fields: y left_x right_x label sublabel fill examples timing
(
0.5,
1.0,
9.0,
"SERVO-LEVEL",
"Sterowanie silnikami",
GRAY3,
"C/C++, FPGA, VHDL\nPID, PWM",
"~1 ms",
),
(
2.0,
1.8,
8.2,
"MOTION-LEVEL",
"Planowanie trajektorii",
GRAY2,
"MoveIt, OMPL\nIK, collision avoidance",
"~20 ms",
),
(
3.5,
2.6,
7.4,
"ROBOT-LEVEL",
"Komendy ruchu",
GRAY1,
"RAPID, KRL, Karel\nPDL2, URScript, ROS",
"~100 ms",
),
(
5.0,
3.4,
6.6,
"TASK-LEVEL",
"Opis celu",
GRAY4,
"PDDL, BT, STRIPS\nplanowanie AI",
"~sekundy",
),
]
h = 1.3
for y, lx, rx, label, sublabel, fill, examples, timing in layers:
rx - lx
# Draw trapezoid
trap = plt.Polygon(
[(lx, y), (rx, y), (rx - 0.4, y + h), (lx + 0.4, y + h)],
closed=True,
facecolor=fill,
edgecolor=LN,
lw=1.5,
)
ax.add_patch(trap)
# Label
ax.text(
(lx + rx) / 2,
y + h * 0.65,
label,
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
ax.text(
(lx + rx) / 2,
y + h * 0.35,
sublabel,
ha="center",
va="center",
fontsize=7,
style="italic",
)
# Examples - right side
ax.text(
rx + 0.2,
y + h * 0.5,
examples,
ha="left",
va="center",
fontsize=6.5,
color="#333333",
)
# Timing - left side
ax.text(
lx - 0.2,
y + h * 0.5,
timing,
ha="right",
va="center",
fontsize=7,
fontweight="bold",
color="#333333",
)
# Arrow on left
ax.annotate(
"",
xy=(0.5, 6.2),
xytext=(0.5, 0.8),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
0.5,
3.5,
"Abstrakcja\nrośnie",
ha="center",
va="center",
fontsize=7,
rotation=90,
fontweight="bold",
)
# Arrow on right side for timing
ax.annotate(
"",
xy=(9.7, 0.8),
xytext=(9.7, 6.2),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
9.7,
3.5,
"Szybkość\nreakcji",
ha="center",
va="center",
fontsize=7,
rotation=270,
fontweight="bold",
)
# Mnemonic at bottom
ax.text(
5.0,
0.0,
'Mnemonik: „Tomek Robi Mechaniczne Serwa" (T→R→M→S, od góry do dołu)',
ha="center",
va="center",
fontsize=7,
style="italic",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_trms_pyramid.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_trms_pyramid.png")
# ============================================================
# 2. Vendor Languages Comparison
# ============================================================
def draw_vendor_comparison() -> None:
"""Draw vendor comparison."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7.5)
ax.axis("off")
ax.set_title(
"Języki producentów robotów — porównanie",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Table headers
headers = [
"Cecha",
"RAPID\n(ABB)",
"KRL\n(KUKA)",
"Karel\n(FANUC)",
"PDL2\n(Comau)",
"URScript\n(UR)",
]
col_widths = [1.8, 1.6, 1.6, 1.6, 1.6, 1.6]
col_x = [0.1]
for w in col_widths[:-1]:
col_x.append(col_x[-1] + w)
row_h = 0.7
header_y = 6.3
rows = [
[
"Składnia",
"typ własny\nstrukturalna",
"Pascal-like\nstrukturalna",
"Pascal-like\nstrukturalna",
"proceduralna\nC-like",
"Python-like\nskryptowy",
],
[
"Ruch liniowy",
"MoveL",
"LIN",
"MOVE TO\nw/LINEAR",
"MOVE\nLINEAR TO",
"movel()",
],
["Ruch joint", "MoveJ", "PTP", "MOVE TO", "MOVE TO", "movej()"],
[
"Ruch kołowy",
"MoveC",
"CIRC",
"(brak\nwbudow.)",
"MOVE\nCIRCULAR",
"movec()",
],
[
"I/O",
"SetDO/\nWaitDI",
"OUT/IN",
"DOUT/DIN",
"OUT/IN",
"set_digital\n_out()",
],
[
"Zmienne",
"num, robtarget\nstring, bool",
"INT, REAL\nPOS, E6POS",
"INTEGER\nPOSITION",
"INTEGER\nPOSITION",
"int, float\npose",
],
[
"Symulator",
"RobotStudio",
"KUKA.Sim",
"ROBOGUIDE",
"RoboSim",
"URSim\n(darmowy)",
],
]
# Draw header row
for j, (hdr, w) in enumerate(zip(headers, col_widths, strict=False)):
x = col_x[j]
fill = GRAY2 if j == 0 else GRAY1
draw_box(
ax,
x,
header_y,
w - 0.05,
row_h,
hdr,
fill=fill,
fontsize=7,
fontweight="bold",
rounded=False,
)
# Draw data rows
for i, row in enumerate(rows):
y = header_y - (i + 1) * row_h
for j, (cell, w) in enumerate(zip(row, col_widths, strict=False)):
x = col_x[j]
fill = GRAY4 if j == 0 else (WHITE if i % 2 == 0 else GRAY4)
fw = "bold" if j == 0 else "normal"
draw_box(
ax,
x,
y,
w - 0.05,
row_h - 0.02,
cell,
fill=fill,
fontsize=6,
fontweight=fw,
rounded=False,
)
# Note
ax.text(
5.0,
0.5,
"Vendor lock-in: program w RAPID ≠ działa na KUKA. "
"ROS/ROS 2 jako warstwa unifikująca.",
ha="center",
va="center",
fontsize=7,
style="italic",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_vendor_comparison.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_vendor_comparison.png")

View File

@ -0,0 +1,279 @@
"""ROS architecture and RAPID structure diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_robot_lang_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
GRAY5,
LN,
OUTPUT_DIR,
WHITE,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 5. ROS Architecture (pub/sub)
# ============================================================
def draw_ros_architecture() -> None:
"""Draw ros architecture."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 4.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 6.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"ROS — architektura publish/subscribe",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Nodes
nodes = [
(1.0, 4.5, "Czujnik\n(LiDAR)", GRAY1),
(1.0, 2.5, "Kamera\n(RGB-D)", GRAY1),
(4.0, 4.5, "Lokalizacja\n(SLAM)", GRAY4),
(4.0, 2.5, "Percepcja\n(detekcja)", GRAY4),
(7.0, 3.5, "Planowanie\nruchu (MoveIt)", GRAY2),
(7.0, 1.0, "Sterownik\nsilników", GRAY3),
]
for x, y, txt, fill in nodes:
draw_box(ax, x, y, 2.2, 1.0, txt, fill=fill, fontsize=7, fontweight="bold")
# Topics (arrows with labels)
topics = [
# Fields: from_x from_y to_x to_y label
(3.2, 5.0, 4.0, 5.0, "/scan"),
(3.2, 3.0, 4.0, 3.0, "/image"),
(6.2, 5.0, 7.0, 4.3, "/pose"),
(6.2, 3.0, 7.0, 3.8, "/objects"),
(8.0, 3.5, 8.0, 2.0, "/cmd_vel"),
]
for x1, y1, x2, y2, label in topics:
draw_arrow(ax, x1, y1, x2, y2, lw=1.5)
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
ax.text(
mx,
my + 0.2,
label,
ha="center",
va="bottom",
fontsize=6,
fontweight="bold",
style="italic",
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": WHITE,
"edgecolor": GRAY5,
"lw": 0.5,
},
)
# ROS Master / roscore
draw_box(
ax,
3.5,
0.3,
3.0,
0.8,
"ROS Master (roscore)\nRejestr węzłów i tematów",
fill=GRAY2,
fontsize=7,
fontweight="bold",
)
# Dashed lines to master
for x, y, _, _ in nodes[:4]:
ax.plot([x + 1.1, 5.0], [y, 1.1], "k:", lw=0.5, alpha=0.4)
# Legend
ax.text(
0.3,
0.8,
"Węzeł (Node) = proces\n"
"Temat (Topic) = kanał pub/sub\n"
"Wiadomość = typowany komunikat",
ha="left",
va="center",
fontsize=6,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": LN, "lw": 0.8},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_ros_architecture.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_ros_architecture.png")
# ============================================================
# 6. RAPID program structure example
# ============================================================
def draw_rapid_structure() -> None:
"""Draw rapid structure."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.axis("off")
ax.set_title(
"Struktura programu RAPID (ABB) — przykład pick & place",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Program structure blocks
# Simplified: just draw code blocks
code_sections = [
(
"Deklaracje danych (stałe, zmienne)",
GRAY4,
[
"CONST robtarget pHome := [[500,0,600],[1,0,0,0],...];",
"CONST robtarget pPick := [[400,200,100],[1,0,0,0],...];",
"CONST robtarget pPlace := [[400,-200,100],[1,0,0,0],...];",
"VAR num nCycles := 0;",
"PERS tooldata tGripper := [...];",
],
),
(
"Procedura główna: main()",
GRAY1,
[
"PROC main()",
" MoveJ pHome, v1000, z50, tGripper;",
" WHILE TRUE DO",
" PickPart;",
" PlacePart;",
" Incr nCycles;",
" ENDWHILE",
"ENDPROC",
],
),
(
"Podprocedura: PickPart()",
GRAY1,
[
"PROC PickPart()",
" MoveL Offs(pPick,0,0,50), v500, z10, tGripper;",
" MoveL pPick, v100, fine, tGripper;",
" SetDO doGripper, 1; ! zamknij chwytak",
" WaitTime 0.5;",
" MoveL Offs(pPick,0,0,50), v500, z10, tGripper;",
"ENDPROC",
],
),
]
y_cur = 7.2
for title, fill, lines in code_sections:
0.25 * len(lines) + 0.5
# Title bar
draw_box(
ax,
0.5,
y_cur - 0.35,
9.0,
0.35,
title,
fill=fill,
fontsize=7,
fontweight="bold",
rounded=False,
)
y_cur -= 0.35
# Code lines
for _i, line in enumerate(lines):
y_cur -= 0.25
ax.text(
0.7,
y_cur + 0.12,
line,
fontsize=5.5,
fontfamily="monospace",
va="center",
)
# Border around code
code_h = 0.25 * len(lines)
rect = mpatches.Rectangle(
(0.5, y_cur - 0.05),
9.0,
code_h + 0.15,
lw=0.8,
edgecolor=GRAY5,
facecolor=WHITE,
zorder=-1,
)
ax.add_patch(rect)
y_cur -= 0.3
# Annotations on right
annotations = [
(
6.5,
"robtarget = pozycja\nkartezjańska + orientacja\n+ konfiguracja ramienia",
),
(
4.5,
"v500 = prędkość 500 mm/s\n"
"z10 = strefa zbliżenia 10mm\n"
"fine = dokładne dojście",
),
(2.5, "SetDO = Digital Output\nSterowanie I/O\n(chwytak, zawory)"),
]
for yy, txt in annotations:
ax.text(
9.8,
yy,
txt,
fontsize=5.5,
ha="left",
va="center",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": GRAY5,
"lw": 0.5,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_rapid_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_rapid_example.png")
# ============================================================
# Main
# ============================================================

View File

@ -0,0 +1,466 @@
"""Shortest path traversal diagram generators."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_shortest_path_diagrams import (
BG,
DPI,
EDGES,
FS,
FS_TITLE,
GRAY3,
GRAY4,
LIGHT_BLUE,
LIGHT_GREEN,
LN,
NODE_POS,
OUTPUT_DIR,
draw_full_graph,
draw_graph_edge,
draw_graph_node,
)
_logger = logging.getLogger(__name__)
# ============================================================
# 1. Graph structure diagram
# ============================================================
def draw_graph_structure() -> None:
"""Draw the shared example graph used across all algorithms."""
_fig, ax = plt.subplots(1, 1, figsize=(5, 4))
ax.set_xlim(-0.5, 5.0)
ax.set_ylim(-1.2, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Przykładowy graf — wspólny dla wszystkich algorytmów\n"
"Wierzchołki: {A, B, C, D}, Start = A",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Draw edges
for u, v, w in EDGES:
draw_graph_edge(ax, NODE_POS[u], NODE_POS[v], w)
# Draw nodes
for node_name, pos in NODE_POS.items():
draw_graph_node(ax, node_name, pos)
# Start arrow
ax.annotate(
"START",
xy=(NODE_POS["A"][0] - 0.35, NODE_POS["A"][1]),
xytext=(NODE_POS["A"][0] - 1.2, NODE_POS["A"][1]),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
arrowprops={"arrowstyle": "->", "color": "#D32F2F", "lw": 2},
va="center",
)
# Edge list
ax.text(
2.3,
-0.8,
"Krawędzie: A→B(2), A→C(4), B→D(3), C→D(5)\n|V|=4, |E|=4, wagi ≥ 0",
ha="center",
va="center",
fontsize=FS,
bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "graph_example_structure.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("graph_example_structure.png")
# ============================================================
# 2. Dijkstra traversal
# ============================================================
def draw_dijkstra_traversal() -> None:
"""Draw step-by-step Dijkstra on the shared graph."""
steps = [
{
"title": "Krok 0: Inicjalizacja\nd = {A:0, B:∞, C:∞, D:∞}",
"dist": {"A": "0", "B": "", "C": "", "D": ""},
"current": "A",
"visited": set(),
"highlighted": set(),
"relaxed": set(),
},
{
"title": (
"Krok 1: Przetwarzam A (d=0)\n"
"Relaksacja: A→B: 0+2=2<∞ ✓"
" A→C: 0+4=4<∞ ✓"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": ""},
"current": "A",
"visited": {"A"},
"highlighted": set(),
"relaxed": {("A", "B"), ("A", "C")},
},
{
"title": (
"Krok 2: Przetwarzam B (d=2)"
" — minimum\n"
"Relaksacja: B→D: 2+3=5<∞ ✓"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "B",
"visited": {"A", "B"},
"highlighted": set(),
"relaxed": {("B", "D")},
},
{
"title": (
"Krok 3: Przetwarzam C (d=4)\n"
"Relaksacja: C→D: 4+5=9 > 5"
" ✗ (nie poprawia)"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "C",
"visited": {"A", "B", "C"},
"highlighted": {("C", "D")},
"relaxed": set(),
},
{
"title": (
"Krok 4: WYNIK"
" — wszystkie przetworzone\n"
"d = {A:0, B:2, C:4, D:5}"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": None,
"visited": {"A", "B", "C", "D"},
"highlighted": {("A", "B"), ("B", "D"), ("A", "C")},
"relaxed": set(),
},
]
fig, axes = plt.subplots(1, 5, figsize=(14, 3.5))
fig.suptitle(
"Dijkstra — przejście grafu krok po kroku"
" (zachłannie: zawsze bierz min d)",
fontsize=FS_TITLE,
fontweight="bold",
y=1.02,
)
for _i, (ax, step) in enumerate(zip(axes, steps, strict=False)):
draw_full_graph(
ax,
title=step["title"],
dist=step["dist"],
current=step["current"],
visited=step["visited"],
highlighted_edges=step["highlighted"],
relaxed_edges=step["relaxed"],
)
# Legend
fig.text(
0.5,
-0.04,
"[zolty] = aktualnie przetwarzany"
" [zielony] = odwiedzony (zamkniety)"
" czerwona krawedz = relaksacja OK"
" szara krawedz = nie poprawia",
ha="center",
fontsize=FS,
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "dijkstra_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("dijkstra_traversal.png")
# ============================================================
# 3. Bellman-Ford traversal
# ============================================================
def draw_bellman_ford_traversal() -> None:
"""Draw step-by-step Bellman-Ford on the shared graph."""
fig = plt.figure(figsize=(14, 7))
fig.suptitle(
"Bellman-Ford — przejście grafu krok po kroku\n"
"(V-1 = 3 iteracje, w każdej relaksuj"
" WSZYSTKIE krawędzie)",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
# Data for each iteration
iterations = [
{
"title": "Inicjalizacja",
"edges_detail": "",
"dist": {"A": "0", "B": "", "C": "", "D": ""},
"relaxed": set(),
},
{
"title": "Iteracja 1 (V-1=3)",
"edges_detail": (
"A→B: 0+2=2<∞ ✓\nA→C: 0+4=4<∞ ✓\nB→D: 2+3=5<∞ ✓\nC→D: 4+5=9>5 ✗"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": {("A", "B"), ("A", "C"), ("B", "D")},
},
{
"title": "Iteracja 2",
"edges_detail": (
"A→B: 0+2=2=2 ✗\nA→C: 0+4=4=4 ✗\nB→D: 2+3=5=5 ✗\nC→D: 4+5=9>5 ✗"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": set(),
},
{
"title": "Iteracja 3",
"edges_detail": (
"Brak zmian → stabilne!\n(wczesne zakończenie\n optymalizacja)"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": set(),
},
]
for i, it in enumerate(iterations):
# Graph subplot
ax_g = fig.add_subplot(2, 4, i + 1)
draw_full_graph(
ax_g,
title=it["title"],
dist=it["dist"],
current=None,
visited=set() if i == 0 else {"A", "B", "C", "D"},
relaxed_edges=it["relaxed"],
)
# Detail subplot below
ax_d = fig.add_subplot(2, 4, i + 5)
ax_d.axis("off")
ax_d.text(
0.5,
0.5,
it["edges_detail"],
ha="center",
va="center",
fontsize=FS,
family="monospace",
bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3},
)
# Negative cycle check note
neg_cycle_msg = (
"Po 3 iteracjach: sprawdz raz jeszcze"
" — nic sie nie zmienia"
" → BRAK cyklu ujemnego → wynik poprawny"
)
fig.text(
0.5,
0.01,
neg_cycle_msg,
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.05, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("bellman_ford_traversal.png")
# ============================================================
# 4. A* traversal
# ============================================================
def draw_astar_traversal() -> None:
"""Draw step-by-step A* on the shared graph with heuristics."""
# Heuristic values (straight-line distance to D)
h_vals = {"A": 4, "B": 2, "C": 3, "D": 0}
fig = plt.figure(figsize=(14, 7.5))
fig.suptitle(
"A* — przejście grafu krok po kroku (cel = D)\n"
"f(n) = g(n) + h(n), heurystyka h"
" = oszacowana odległość do D",
fontsize=FS_TITLE,
fontweight="bold",
y=0.99,
)
steps = [
{
"title": "Krok 0: Inicjalizacja\nh(A)=4, h(B)=2, h(C)=3, h(D)=0",
"detail": (
"g(A)=0, f(A)=0+4=4\npq = [(4, A)]\nh = oszacowanie\n odl. do celu D"
),
"dist": {"A": "0"},
"f_vals": {"A": "f=4"},
"current": "A",
"visited": set(),
"relaxed": set(),
},
{
"title": "Krok 1: pop A (f=4)\nA→B: g=2, f=2+2=4\nA→C: g=4, f=4+3=7",
"detail": (
"Relaksacja:\n"
" A→B: g=0+2=2\n"
" f=2+h(B)=2+2=4\n"
" A→C: g=0+4=4\n"
" f=4+h(C)=4+3=7\n"
"pq = [(4,B), (7,C)]"
),
"dist": {"A": "0", "B": "2", "C": "4"},
"current": "A",
"visited": {"A"},
"relaxed": {("A", "B"), ("A", "C")},
},
{
"title": "Krok 2: pop B (f=4) — min!\nB→D: g=5, f=5+0=5",
"detail": (
"B ma f=4 < C(f=7)\n"
"→ A* kieruje się\n"
" W STRONĘ celu!\n"
"Relaksacja:\n"
" B→D: g=2+3=5\n"
" f=5+h(D)=5+0=5\n"
"pq = [(5,D), (7,C)]"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "B",
"visited": {"A", "B"},
"relaxed": {("B", "D")},
},
{
"title": "Krok 3: pop D (f=5)\nu == goal → STOP!",
"detail": (
"D to CEL → KONIEC!\n"
"Nie przetwarzamy C\n"
" (f(C)=7 > f(D)=5)\n\n"
"Ścieżka: A→B→D\n"
"Koszt: 5\n\n"
"Dijkstra odwi-\n"
"edziłby też C!"
),
"dist": {"A": "0", "B": "2", "D": "5"},
"current": "D",
"visited": {"A", "B", "D"},
"relaxed": set(),
},
]
for i, step in enumerate(steps):
# Graph
ax_g = fig.add_subplot(2, 4, i + 1)
draw_full_graph(
ax_g,
title=step["title"],
dist=step["dist"],
current=step["current"],
visited=step["visited"],
relaxed_edges=step["relaxed"],
)
# Add h values as small labels
for node_name, pos in NODE_POS.items():
ax_g.text(
pos[0] + 0.35,
pos[1] + 0.35,
f"h={h_vals[node_name]}",
ha="center",
va="center",
fontsize=5.5,
color="#1565C0",
fontweight="bold",
zorder=7,
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": LIGHT_BLUE,
"edgecolor": "#1565C0",
"alpha": 0.9,
"lw": 0.5,
},
)
# Detail
ax_d = fig.add_subplot(2, 4, i + 5)
ax_d.axis("off")
ax_d.text(
0.5,
0.5,
step["detail"],
ha="center",
va="center",
fontsize=FS,
family="monospace",
bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3},
)
# Comparison note
fig.text(
0.5,
0.01,
"A* odwiedził 3 wierzchołki (A, B, D)"
" — POMINĄŁ C!\n"
"Dijkstra odwiedziłby wszystkie 4."
" Heurystyka h kieruje przeszukiwanie"
" w stronę celu.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_BLUE,
"edgecolor": "#1565C0",
},
)
plt.tight_layout(rect=[0, 0.06, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "astar_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("astar_traversal.png")
# ============================================================
# Main
# ============================================================

View File

@ -0,0 +1,245 @@
"""Consensus and distributed systems diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_study_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
GRAY5,
LN,
OUTPUT_DIR,
draw_arrow,
draw_box,
)
_logger = logging.getLogger(__name__)
def draw_linearizability_vs_sequential() -> None:
"""Draw linearizability vs sequential."""
_fig, axes = plt.subplots(2, 1, figsize=(8.27, 5.5))
for _i, (ax, title, subtitle, operations, result_text) in enumerate(
zip(
axes,
["Linearizability", "Sequential Consistency"],
[
'Operacja „wygląda" atomowo w czasie rzeczywistym',
"Globalny porządek zgodny z programem, ale NIE z czasem rzeczywistym",
],
[
# Linearizability
[
("Klient A", 1, 3, "write(x,1)", GRAY1),
("Klient B", 2, 4, "read(x)→1 ✓", GRAY2),
("Klient A", 5, 7, "write(x,2)", GRAY1),
],
# Sequential consistency
[
("Klient A", 1, 3, "write(x,1)", GRAY1),
("Klient B", 2, 4, "read(x)→0 ✓", GRAY2),
("Klient A", 5, 7, "write(x,2)", GRAY1),
],
],
[
"read MUSI zwrócić 1 (write zakończony w czasie rzeczywistym)",
"read MOŻE zwrócić 0 (globalny porządek: read, write(1), write(2))",
],
strict=False,
)
):
ax.set_xlim(0, 9)
ax.set_ylim(-0.5, 3.5)
ax.axis("off")
ax.set_title(f"{title}", fontsize=10, fontweight="bold")
ax.text(
4.5, 3.2, subtitle, ha="center", fontsize=7, style="italic", color="#555555"
)
# Time axis
ax.plot([0.5, 8.5], [0, 0], color=GRAY3, lw=0.8)
for t in range(1, 9):
ax.plot([t, t], [-0.05, 0.05], color=GRAY3, lw=0.8)
ax.text(t, -0.2, f"t{t}", ha="center", fontsize=6, color="#999999")
# Client labels
clients = list(dict.fromkeys([op[0] for op in operations]))
client_y = {c: 1.0 + idx * 1.2 for idx, c in enumerate(clients)}
for client_name, y_pos in client_y.items():
ax.text(
0.3,
y_pos,
client_name,
ha="right",
va="center",
fontsize=7,
fontweight="bold",
)
ax.plot([0.5, 8.5], [y_pos, y_pos], color=GRAY5, lw=0.5, linestyle=":")
for client, t_start, t_end, label, fill in operations:
y = client_y[client]
rect = FancyBboxPatch(
(t_start, y - 0.2),
t_end - t_start,
0.4,
boxstyle="round,pad=0.05",
lw=1.2,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
(t_start + t_end) / 2, y, label, ha="center", va="center", fontsize=7
)
# Result annotation
ax.text(
4.5,
-0.45,
result_text,
ha="center",
fontsize=7,
bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY5},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "linearizability_vs_sequential.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ linearizability_vs_sequential.png")
def draw_paxos_flow() -> None:
"""Draw paxos flow."""
_fig, ax = plt.subplots(1, 1, figsize=(8.27, 4))
ax.set_xlim(-0.5, 10.5)
ax.set_ylim(-0.5, 5)
ax.axis("off")
ax.set_title(
"Paxos — uproszczony przebieg (zapis x=5)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Actors
actors = [
("Proposer", 1.5, 4.0, GRAY1),
("A₁", 4.5, 4.0, GRAY2),
("A₂", 6.5, 4.0, GRAY2),
("A₃", 8.5, 4.0, GRAY2),
]
for name, x, y, fill in actors:
draw_box(
ax, x - 0.6, y, 1.2, 0.6, name, fill=fill, fontsize=8, fontweight="bold"
)
# Phase 1: Prepare
ax.text(
-0.3,
3.5,
"FAZA 1\nPrepare",
ha="center",
fontsize=7,
fontweight="bold",
bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5},
)
y_prep = 3.3
for target_x in [4.5, 6.5, 8.5]:
draw_arrow(ax, 2.1, y_prep + 0.15, target_x - 0.6, y_prep + 0.15, lw=1.0)
ax.text(3.3, y_prep + 0.35, "Prepare(n=1)", fontsize=6, ha="center")
# Promises back
y_prom = 2.7
for target_x in [4.5, 6.5]:
draw_arrow(
ax,
target_x - 0.6,
y_prom + 0.15,
2.1,
y_prom + 0.15,
lw=1.0,
color="#555555",
)
ax.text(
3.3, y_prom + 0.35, "Promise(n=1) ✓", fontsize=6, ha="center", color="#555555"
)
ax.text(8.5, y_prom + 0.15, "(slow)", fontsize=6, ha="center", color="#999999")
ax.text(
1.5,
y_prom - 0.15,
"majority\n(2/3) ✓",
fontsize=6,
ha="center",
bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY1, "edgecolor": GRAY3},
)
# Phase 2: Accept
ax.text(
-0.3,
1.8,
"FAZA 2\nAccept",
ha="center",
fontsize=7,
fontweight="bold",
bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5},
)
y_acc = 1.6
for target_x in [4.5, 6.5, 8.5]:
draw_arrow(ax, 2.1, y_acc + 0.15, target_x - 0.6, y_acc + 0.15, lw=1.0)
ax.text(3.3, y_acc + 0.35, "Accept(n=1, x=5)", fontsize=6, ha="center")
# Accepted back
y_accd = 1.0
for target_x in [4.5, 6.5]:
draw_arrow(
ax,
target_x - 0.6,
y_accd + 0.15,
2.1,
y_accd + 0.15,
lw=1.0,
color="#555555",
)
ax.text(3.3, y_accd + 0.35, "Accepted ✓", fontsize=6, ha="center", color="#555555")
# Result
ax.text(
5.0,
0.1,
"x=5 UZGODNIONE (majority zaakceptowała) → Linearizable!",
fontsize=8,
ha="center",
fontweight="bold",
bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY1, "edgecolor": LN},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "paxos_flow.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ paxos_flow.png")

View File

@ -0,0 +1,329 @@
"""Network models and vector clock diagrams."""
from __future__ import annotations
import logging
from pathlib import Path
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from python_pkg.praca_magisterska_video.generate_images.generate_study_diagrams import (
BG,
DPI,
FS,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
GRAY5,
OUTPUT_DIR,
draw_box,
)
_logger = logging.getLogger(__name__)
def draw_network_models() -> None:
"""Draw network models."""
_fig, ax = plt.subplots(1, 1, figsize=(8.27, 5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Sieciowe modele optymalizacji"
"\u201eNasz Ma\u0142y Miko\u0142aj Przydzieli\u0142"
" Trasy Ci\u0119\u017car\u00f3wkom Mapuj\u0105c\u201d",
fontsize=10,
fontweight="bold",
pad=10,
)
models = [
(
1,
"Najkrótsza\nścieżka",
"GPS, routing\nDijkstra, A*",
"A→B najszybciej?",
GRAY1,
),
(
2,
"Maksymalny\nprzepływ",
"Przepustowość\nFord-Fulkerson",
"Ile max przesłać?",
GRAY4,
),
(
3,
"Min koszt\nprzepływu",
"Najtańszy transport\nSieciowy simpleks",
"X jednostek najtaniej?",
GRAY4,
),
(
4,
"Przydział\n(assignment)",
"n→n, min koszt\nAlg. Węgierski O(n³)",
"Kto robi co?",
GRAY2,
),
(
5,
"TSP\n(komiwojażer)",
"Objazd miast\nNP-trudny, heurystyki",
"Objazd wszystkiego?",
GRAY3,
),
(6, "CPM/PERT", "Harmonogram\nŚcieżka krytyczna", "Ile trwa projekt?", GRAY2),
(
7,
"MST\n(drzewo rozp.)",
"Min połączenie\nKruskal, Prim",
"Połącz najtaniej?",
GRAY1,
),
]
# Layout: 3 pairs + 1, arranged in labeled groups
group_positions = [
("DROGI", [(0, 0.3, 4.0), (6, 0.3, 1.5)]),
("PRZEPŁYW", [(1, 3.3, 4.0), (2, 3.3, 1.5)]),
("ZARZĄDZANIE", [(3, 6.3, 4.0), (5, 6.3, 1.5)]),
]
box_w = 2.6
box_h = 1.8
for group_label, items in group_positions:
xs = [x for _, x, y in items]
ys = [y for _, x, y in items]
gx = min(xs) - 0.15
gy = min(ys) - 0.3
gw = box_w + 0.3
gh = max(ys) - min(ys) + box_h + 0.6
rect = mpatches.FancyBboxPatch(
(gx, gy),
gw,
gh,
boxstyle="round,pad=0.1",
lw=0.8,
edgecolor=GRAY3,
facecolor="white",
linestyle="--",
)
ax.add_patch(rect)
ax.text(
gx + gw / 2,
gy + gh + 0.12,
group_label,
ha="center",
fontsize=8,
fontweight="bold",
color="#555555",
)
for idx, x, y in items:
num, name, detail, question, fill = models[idx]
draw_box(ax, x, y, box_w, box_h, "", fill=fill, fontsize=FS)
ax.text(
x + box_w / 2,
y + box_h - 0.25,
f"{num}. {name}",
ha="center",
va="top",
fontsize=8,
fontweight="bold",
)
ax.text(
x + box_w / 2,
y + box_h / 2 - 0.1,
detail,
ha="center",
va="center",
fontsize=7,
)
ax.text(
x + box_w / 2,
y + 0.2,
f'→ „{question}"',
ha="center",
va="bottom",
fontsize=6.5,
style="italic",
)
# TSP alone at bottom center
idx = 4
x, y = 4.5, -0.1
num, name, detail, question, fill = models[idx]
rect = mpatches.FancyBboxPatch(
(x - 0.15, y - 0.15),
box_w + 0.3,
box_h + 0.3,
boxstyle="round,pad=0.1",
lw=0.8,
edgecolor=GRAY3,
facecolor="white",
linestyle="--",
)
ax.add_patch(rect)
ax.text(
x + box_w / 2,
y + box_h + 0.3,
"SAM (NP-trudny)",
ha="center",
fontsize=8,
fontweight="bold",
color="#555555",
)
draw_box(ax, x, y, box_w, box_h, "", fill=fill, fontsize=FS)
ax.text(
x + box_w / 2,
y + box_h - 0.25,
f"{num}. {name}",
ha="center",
va="top",
fontsize=8,
fontweight="bold",
)
ax.text(
x + box_w / 2, y + box_h / 2 - 0.1, detail, ha="center", va="center", fontsize=7
)
ax.text(
x + box_w / 2,
y + 0.2,
f'→ „{question}"',
ha="center",
va="bottom",
fontsize=6.5,
style="italic",
)
ax.set_ylim(-0.5, 7.2)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "network_models_mnemonic.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ network_models_mnemonic.png")
def draw_vector_clock_timeline() -> None:
"""Draw vector clock timeline."""
_fig, ax = plt.subplots(1, 1, figsize=(8.27, 4.5))
ax.set_xlim(-0.5, 11)
ax.set_ylim(-0.5, 4.5)
ax.axis("off")
ax.set_title(
"Zegary wektorowe — przykład z 3 procesami",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Process lines
procs = [("P₁", 3.5), ("P₂", 2.0), ("P₃", 0.5)]
for name, y in procs:
ax.plot([0.5, 10.5], [y, y], color="black", lw=1.5)
ax.text(0.1, y, name, ha="right", va="center", fontsize=10, fontweight="bold")
# Events
events = [
("A", 3.5, 1.5, "[1,0,0]", GRAY1),
("B", 2.0, 2.5, "[0,1,0]", GRAY2),
("C", 2.0, 5.0, "[1,2,0]", GRAY2),
("D", 0.5, 4.0, "[0,0,1]", GRAY3),
("E", 3.5, 6.5, "[2,0,0]", GRAY1),
("F", 2.0, 8.0, "[2,3,0]", GRAY2),
]
for name, y, x, vec, fill in events:
circle = plt.Circle((x, y), 0.25, facecolor=fill, edgecolor="black", lw=1.5)
ax.add_patch(circle)
ax.text(x, y, name, ha="center", va="center", fontsize=9, fontweight="bold")
ax.text(
x,
y + 0.45,
vec,
ha="center",
va="bottom",
fontsize=7,
fontfamily="monospace",
color="#333333",
)
# Messages (arrows between processes)
ax.annotate(
"",
xy=(4.75, 2.0),
xytext=(1.75, 3.5),
arrowprops={
"arrowstyle": "->",
"color": "#444444",
"lw": 1.5,
"connectionstyle": "arc3,rad=0.05",
},
)
ax.text(3.0, 3.0, "msg₁", ha="center", fontsize=7, color="#444444", style="italic")
ax.annotate(
"",
xy=(7.75, 2.0),
xytext=(6.75, 3.5),
arrowprops={
"arrowstyle": "->",
"color": "#444444",
"lw": 1.5,
"connectionstyle": "arc3,rad=0.05",
},
)
ax.text(7.0, 3.0, "msg₂", ha="center", fontsize=7, color="#444444", style="italic")
# Concurrency annotations
ax.annotate(
"A ∥ B\n(współbieżne)",
xy=(2.0, 1.2),
fontsize=7,
ha="center",
bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5},
)
ax.annotate(
"C ∥ D\n(współbieżne)",
xy=(4.5, 0.9),
fontsize=7,
ha="center",
bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5},
)
ax.annotate(
"A → C\n(przyczynowe)",
xy=(3.3, 4.2),
fontsize=7,
ha="center",
bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY1, "edgecolor": GRAY3},
)
# Time arrow
ax.annotate(
"",
xy=(10.5, -0.3),
xytext=(0.5, -0.3),
arrowprops={"arrowstyle": "->", "color": GRAY3, "lw": 1.0},
)
ax.text(5.5, -0.45, "czas →", ha="center", fontsize=8, color="#777777")
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "vector_clock_timeline.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ vector_clock_timeline.png")

View File

@ -0,0 +1,448 @@
"""Vision and statistics diagrams (HOG, R-CNN, segmentation, FSD/SSD)."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import norm
from python_pkg.praca_magisterska_video.generate_images.generate_study_diagrams import (
BG,
DPI,
FS_TITLE,
GRAY1,
GRAY2,
GRAY3,
GRAY4,
LN,
OUTPUT_DIR,
draw_arrow,
draw_box,
)
if TYPE_CHECKING:
from matplotlib.axes import Axes
from pathlib import Path
_logger = logging.getLogger(__name__)
def draw_hog_pipeline() -> None:
"""Draw hog pipeline."""
_fig, ax = plt.subplots(1, 1, figsize=(8.27, 3.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 4)
ax.axis("off")
ax.set_title(
"HOG + SVM — pipeline detekcji pieszych",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
steps = [
(0.3, "Obraz\nwejściowy", GRAY4),
(2.1, "Oblicz\ngradienty\n(Gx, Gy)", GRAY1),
(3.9, "Podziel na\nkomórki 8x8\nhistogramy", GRAY2),
(5.7, "Normalizuj\nw blokach\n2x2", GRAY2),
(7.5, "Wektor\ncech\n(3780-dim)", GRAY3),
(9.0, "SVM\n→ pieszy\n/ nie", GRAY1),
]
box_w = 1.5
box_h = 1.8
y = 1.2
for i, (x, text, fill) in enumerate(steps):
draw_box(ax, x, y, box_w, box_h, "", fill=fill)
ax.text(
x + box_w / 2, y + box_h / 2, text, ha="center", va="center", fontsize=7
)
if i < len(steps) - 1:
next_x = steps[i + 1][0]
draw_arrow(
ax, x + box_w + 0.02, y + box_h / 2, next_x - 0.02, y + box_h / 2
)
# Annotations below
annotations = [
(0.3 + box_w / 2, "pixel[x+1]-pixel[x-1]"),
(2.1 + box_w / 2, "magnitude + direction"),
(3.9 + box_w / 2, "9 binów (0°-180°)"),
(5.7 + box_w / 2, "L2-normalizacja"),
(7.5 + box_w / 2, "wejście do SVM"),
(9.0 + box_w / 2, "hiperpłaszczyzna"),
]
for x, text in annotations:
ax.text(
x,
y - 0.15,
text,
ha="center",
fontsize=5.5,
color="#666666",
style="italic",
)
# Title annotations
ax.text(
1.05, y + box_h + 0.15, "① Gradient", ha="center", fontsize=7, fontweight="bold"
)
ax.text(
2.85,
y + box_h + 0.15,
"② Histogram",
ha="center",
fontsize=7,
fontweight="bold",
)
ax.text(
4.65,
y + box_h + 0.15,
"③ Normalize",
ha="center",
fontsize=7,
fontweight="bold",
)
ax.text(
6.45,
y + box_h + 0.15,
"④ Feature vec",
ha="center",
fontsize=7,
fontweight="bold",
)
ax.text(
8.1, y + box_h + 0.15, "⑤ Classify", ha="center", fontsize=7, fontweight="bold"
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "hog_svm_pipeline.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ hog_svm_pipeline.png")
def draw_rcnn_evolution() -> None:
"""Draw rcnn evolution."""
_fig, ax = plt.subplots(1, 1, figsize=(8.27, 5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7)
ax.axis("off")
ax.set_title(
"Ewolucja detektorów: R-CNN → Fast R-CNN → Faster R-CNN → YOLO",
fontsize=10,
fontweight="bold",
pad=10,
)
models = [
{
"name": "R-CNN (2014)",
"y": 5.3,
"steps": ["Selective\nSearch", "2000x\nCNN", "2000x\nSVM", "NMS"],
"speed": "~50 sec/img",
"fill": GRAY4,
},
{
"name": "Fast R-CNN (2015)",
"y": 3.7,
"steps": [
"Selective\nSearch",
"CNN\n(1x cały)",
"ROI\nPooling",
"FC + NMS",
],
"speed": "~2 sec/img",
"fill": GRAY2,
},
{
"name": "Faster R-CNN (2015)",
"y": 2.1,
"steps": ["CNN\nbackbone", "RPN\n(proposals)", "ROI\nPooling", "FC + NMS"],
"speed": "~0.2 sec (5 fps)",
"fill": GRAY1,
},
{
"name": "YOLO (2016)",
"y": 0.5,
"steps": ["CNN\nbackbone", "Siatka\nSxS", "bbox+klasa\nper komórka", "NMS"],
"speed": "~7-22 ms (45-155 fps)",
"fill": GRAY3,
},
]
for model in models:
y = model["y"]
ax.text(0.2, y + 0.4, model["name"], fontsize=8, fontweight="bold", va="center")
ax.text(0.2, y + 0.05, model["speed"], fontsize=6, va="center", color="#666666")
bw = 1.5
bh = 0.8
for i, step in enumerate(model["steps"]):
x = 2.5 + i * 1.9
draw_box(ax, x, y, bw, bh, step, fill=model["fill"], fontsize=6.5)
if i < len(model["steps"]) - 1:
draw_arrow(
ax, x + bw + 0.02, y + bh / 2, x + 1.9 - 0.02, y + bh / 2, lw=0.8
)
# Speed improvement arrow on right
ax.annotate(
"",
xy=(9.5, 5.7),
xytext=(9.5, 0.9),
arrowprops={"arrowstyle": "<->", "color": "#555555", "lw": 1.5},
)
ax.text(
9.7,
3.3,
"250x\nszybciej!",
fontsize=8,
fontweight="bold",
ha="center",
va="center",
rotation=90,
color="#555555",
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "rcnn_evolution.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ rcnn_evolution.png")
def _draw_instance_panel(ax: Axes) -> None:
"""Draw instance segmentation panel."""
ax.add_patch(
mpatches.Rectangle((0, 4), 6, 2, facecolor="#E8E8E8", edgecolor=LN, lw=0.5)
)
ax.text(3, 5, "\u2014", ha="center", va="center", fontsize=7, color="#999999")
ax.add_patch(
mpatches.Rectangle((0, 0), 6, 2.5, facecolor="#E8E8E8", edgecolor=LN, lw=0.5)
)
ax.text(3, 1, "\u2014", ha="center", va="center", fontsize=7, color="#999999")
ax.add_patch(
mpatches.Rectangle((0.5, 2), 2, 1.5, facecolor="#888888", edgecolor=LN, lw=0.8)
)
ax.text(1.5, 2.75, "auto#1", ha="center", va="center", fontsize=6, color="white")
ax.add_patch(
mpatches.Rectangle((3.5, 2), 2, 1.5, facecolor="#555555", edgecolor=LN, lw=0.8)
)
ax.text(4.5, 2.75, "auto#2", ha="center", va="center", fontsize=6, color="white")
ax.text(
3,
-0.3,
"RÓŻNE instancje!",
ha="center",
fontsize=6,
color="#555555",
style="italic",
)
def _draw_panoptic_panel(ax: Axes) -> None:
"""Draw panoptic segmentation panel."""
ax.add_patch(
mpatches.Rectangle((0, 4), 6, 2, facecolor="#E8E8E8", edgecolor=LN, lw=0.5)
)
ax.text(3, 5, "niebo (stuff)", ha="center", va="center", fontsize=6)
ax.add_patch(
mpatches.Rectangle((0, 0), 6, 2.5, facecolor="#C8C8C8", edgecolor=LN, lw=0.5)
)
ax.text(3, 1, "droga (stuff)", ha="center", va="center", fontsize=6)
ax.add_patch(
mpatches.Rectangle((0.5, 2), 2, 1.5, facecolor="#888888", edgecolor=LN, lw=0.8)
)
ax.text(
1.5,
2.75,
"auto#1\n(thing)",
ha="center",
va="center",
fontsize=5.5,
color="white",
)
ax.add_patch(
mpatches.Rectangle((3.5, 2), 2, 1.5, facecolor="#555555", edgecolor=LN, lw=0.8)
)
ax.text(
4.5,
2.75,
"auto#2\n(thing)",
ha="center",
va="center",
fontsize=5.5,
color="white",
)
ax.text(
3,
-0.3,
"klasy + instancje!",
ha="center",
fontsize=6,
color="#555555",
style="italic",
)
def draw_segmentation_types() -> None:
"""Draw segmentation types."""
fig, axes = plt.subplots(1, 4, figsize=(8.27, 2.5))
fig.suptitle(
"Typy segmentacji obrazu", fontsize=FS_TITLE, fontweight="bold", y=1.02
)
titles = [
"Obraz wejściowy",
"Semantic\nSegmentation",
"Instance\nSegmentation",
"Panoptic\nSegmentation",
]
for ax, title in zip(axes, titles, strict=False):
ax.set_xlim(0, 6)
ax.set_ylim(0, 6)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(title, fontsize=8, fontweight="bold", pad=5)
# Original image (stylized)
ax = axes[0]
ax.add_patch(
mpatches.Rectangle((0, 4), 6, 2, facecolor="#DDDDDD", edgecolor=LN, lw=0.5)
)
ax.text(3, 5, "niebo", ha="center", va="center", fontsize=7)
ax.add_patch(
mpatches.Rectangle((0, 0), 6, 2.5, facecolor="#AAAAAA", edgecolor=LN, lw=0.5)
)
ax.text(3, 1, "droga", ha="center", va="center", fontsize=7)
ax.add_patch(
mpatches.Rectangle((0.5, 2), 2, 1.5, facecolor="#888888", edgecolor=LN, lw=0.8)
)
ax.text(1.5, 2.75, "auto", ha="center", va="center", fontsize=7, color="white")
ax.add_patch(
mpatches.Rectangle((3.5, 2), 2, 1.5, facecolor="#666666", edgecolor=LN, lw=0.8)
)
ax.text(4.5, 2.75, "auto", ha="center", va="center", fontsize=7, color="white")
# Semantic: same label for both cars
ax = axes[1]
ax.add_patch(
mpatches.Rectangle((0, 4), 6, 2, facecolor="#E8E8E8", edgecolor=LN, lw=0.5)
)
ax.text(3, 5, "niebo", ha="center", va="center", fontsize=7)
ax.add_patch(
mpatches.Rectangle((0, 0), 6, 2.5, facecolor="#C8C8C8", edgecolor=LN, lw=0.5)
)
ax.text(3, 1, "droga", ha="center", va="center", fontsize=7)
ax.add_patch(
mpatches.Rectangle((0.5, 2), 2, 1.5, facecolor="#888888", edgecolor=LN, lw=0.8)
)
ax.text(1.5, 2.75, "auto", ha="center", va="center", fontsize=6, color="white")
ax.add_patch(
mpatches.Rectangle((3.5, 2), 2, 1.5, facecolor="#888888", edgecolor=LN, lw=0.8)
)
ax.text(4.5, 2.75, "auto", ha="center", va="center", fontsize=6, color="white")
ax.text(
3,
-0.3,
"te same etykiety!",
ha="center",
fontsize=6,
color="#555555",
style="italic",
)
_draw_instance_panel(axes[2])
_draw_panoptic_panel(axes[3])
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "segmentation_types.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ segmentation_types.png")
def draw_fsd_ssd() -> None:
"""Draw fsd ssd."""
fig, axes = plt.subplots(1, 2, figsize=(8.27, 3.5))
fig.suptitle(
"Dominacja stochastyczna — FSD i SSD",
fontsize=FS_TITLE,
fontweight="bold",
y=1.02,
)
# FSD: CDF comparison
ax = axes[0]
ax.set_title("FSD: F_A(x) ≤ F_B(x) ∀x", fontsize=9, fontweight="bold")
x = np.linspace(-2, 6, 200)
cdf_a = norm.cdf(x, loc=2.5, scale=1.0)
cdf_b = norm.cdf(x, loc=1.5, scale=1.0)
ax.plot(x, cdf_a, "k-", lw=2, label="F_A (lepsza — niżej)")
ax.plot(x, cdf_b, "k--", lw=2, label="F_B (gorsza — wyżej)")
ax.fill_between(x, cdf_a, cdf_b, alpha=0.15, color="gray")
ax.set_xlabel("x (wynik)", fontsize=8)
ax.set_ylabel("F(x) = P(X ≤ x)", fontsize=8)
ax.legend(fontsize=7, loc="lower right")
ax.text(
0,
0.8,
"A ≥_FSD B\nF_A zawsze pod F_B\n→ KAŻDY racjonalny\n wybierze A",
fontsize=7,
bbox={"boxstyle": "round", "facecolor": GRAY4},
)
ax.grid(visible=True, alpha=0.3)
ax.tick_params(labelsize=7)
# SSD
ax = axes[1]
ax.set_title(
"SSD: ∫F_A ≤ ∫F_B ∀x (CDFs mogą się krzyżować)", fontsize=9, fontweight="bold"
)
cdf_a2 = norm.cdf(x, loc=2.0, scale=0.8)
cdf_b2 = norm.cdf(x, loc=2.0, scale=1.5)
ax.plot(x, cdf_a2, "k-", lw=2, label="F_A (mniej ryzyka)")
ax.plot(x, cdf_b2, "k--", lw=2, label="F_B (więcej ryzyka)")
ax.fill_between(x, cdf_a2, cdf_b2, where=cdf_a2 < cdf_b2, alpha=0.15, color="gray")
ax.fill_between(
x, cdf_a2, cdf_b2, where=cdf_a2 >= cdf_b2, alpha=0.08, color="gray", hatch="///"
)
ax.set_xlabel("x (wynik)", fontsize=8)
ax.set_ylabel("F(x)", fontsize=8)
ax.legend(fontsize=7, loc="lower right")
ax.text(
-1.5,
0.75,
"A ≥_SSD B\nCDFs się krzyżują,\nale ∫F_A ≤ ∫F_B\n→ risk-averse\n wybierze A",
fontsize=7,
bbox={"boxstyle": "round", "facecolor": GRAY4},
)
ax.grid(visible=True, alpha=0.3)
ax.tick_params(labelsize=7)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "fsd_ssd_comparison.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ fsd_ssd_comparison.png")

View File

@ -109,9 +109,7 @@ def main() -> None:
all_cards.extend(extract_cards(md_file))
# APPROACH 1: Strict filtering - only cards with answer > threshold
filtered_cards = [
c for c in all_cards if len(c["back"]) > MIN_ANSWER_LENGTH
]
filtered_cards = [c for c in all_cards if len(c["back"]) > MIN_ANSWER_LENGTH]
# Remove duplicates
seen = set()

View File

@ -256,9 +256,7 @@ def extract_cards_basic(filepath: str) -> list[dict[str, str]]:
def _extract_key_point(body: str) -> str | None:
"""Extract a key point from a section body."""
# Try to get a definition or first bullet
def_match = re.search(
r"Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", body
)
def_match = re.search(r"Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", body)
if def_match:
return def_match.group(1).strip()
@ -304,11 +302,7 @@ def extract_main_only(filepath: str) -> list[dict[str, str]]:
for raw_header, body in headers[:5]:
header = raw_header.strip()
if (
"Przykład" in header
or "Mnemonic" in header
or '"' in header
):
if "Przykład" in header or "Mnemonic" in header or '"' in header:
continue
key_point = _extract_key_point(body)
@ -351,13 +345,9 @@ def _log_statistics(unique: list[dict[str, str]], output_file: Path) -> None:
lengths = [len(c["back"]) for c in unique]
short = sum(1 for length in lengths if length < SHORT_THRESHOLD)
medium = sum(
1
for length in lengths
if SHORT_THRESHOLD <= length < MEDIUM_THRESHOLD
)
good = sum(
1 for length in lengths if length >= MEDIUM_THRESHOLD
1 for length in lengths if SHORT_THRESHOLD <= length < MEDIUM_THRESHOLD
)
good = sum(1 for length in lengths if length >= MEDIUM_THRESHOLD)
logger.info("Generated: %s", output_file.name)
logger.info(" Cards: %d", len(unique))
@ -376,9 +366,7 @@ def generate_anki(
main_only: bool = False,
) -> Path:
"""Generate Anki deck with specified approaches."""
odpowiedzi_dir = Path(
"/home/kuchy/praca_magisterska/pytania/odpowiedzi"
)
odpowiedzi_dir = Path("/home/kuchy/praca_magisterska/pytania/odpowiedzi")
# Determine output filename based on options
suffix_parts = []
@ -390,9 +378,7 @@ def generate_anki(
suffix_parts.append("main")
suffix = "_".join(suffix_parts) if suffix_parts else "basic"
output_file = Path(
f"/home/kuchy/praca_magisterska/pytania/anki_{suffix}.txt"
)
output_file = Path(f"/home/kuchy/praca_magisterska/pytania/anki_{suffix}.txt")
deck_name = f"Egzamin_{suffix.replace('_', '+')}"
all_cards = _collect_cards(
@ -403,9 +389,7 @@ def generate_anki(
# Approach 1: Apply filtering if requested
if use_filter:
all_cards = apply_strict_filter(
all_cards, min_length=DEFAULT_MIN_ANSWER_LENGTH
)
all_cards = apply_strict_filter(all_cards, min_length=DEFAULT_MIN_ANSWER_LENGTH)
# Remove duplicates
seen: set[str] = set()
@ -419,8 +403,7 @@ def generate_anki(
# Write output
with Path(output_file).open("w", encoding="utf-8") as f:
f.write(
"#separator:Tab\n#html:true\n"
f"#notetype:Basic\n#deck:{deck_name}\n\n"
"#separator:Tab\n#html:true\n" f"#notetype:Basic\n#deck:{deck_name}\n\n"
)
for c in unique:
f.write(f"{c['front']}\t{c['back']}\t{c['tags']}\n")
@ -468,12 +451,9 @@ def main() -> None:
(True, True, True), # 7: All three
]
for i, (f_flag, e_flag, m_flag) in enumerate(
combinations, 1
):
for i, (f_flag, e_flag, m_flag) in enumerate(combinations, 1):
logger.info(
"--- Combination %d (filter=%s, extract=%s,"
" main=%s) ---",
"--- Combination %d (filter=%s, extract=%s," " main=%s) ---",
i,
f_flag,
e_flag,

View File

@ -23,7 +23,6 @@ mpl.use("Agg")
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
@ -188,838 +187,22 @@ def draw_dashed_arrow(
# --- DIAGRAM 1: See-Think-Act Cycle ---
def draw_see_think_act() -> None:
"""Draw see think act."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 4.5), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 4.5)
ax.axis("off")
ax.set_title(
"Cykl agenta upostaciowionego:"
" Percepcja \u2192 Deliberacja \u2192 Akcja",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Environment box (large background)
env_rect = FancyBboxPatch(
(0.2, 0.2),
6.6,
1.0,
boxstyle="round,pad=0.08",
lw=1.5,
edgecolor=LN,
facecolor=GRAY1,
linestyle="--",
)
ax.add_patch(env_rect)
ax.text(
3.5,
0.7,
"\u015aRODOWISKO FIZYCZNE\n"
"(przeszkody, obiekty, ludzie)",
ha="center",
va="center",
fontsize=FS,
fontstyle="italic",
)
# Agent body (large rounded box)
agent_rect = FancyBboxPatch(
(0.5, 1.5),
6.0,
2.6,
boxstyle="round,pad=0.1",
lw=2.0,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(agent_rect)
ax.text(
3.5,
3.85,
"AGENT UPOSTACIOWIONY (robot)",
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
# Three main phases
bw = 1.4
bh = 0.7
by = 2.2
bold_fs8 = BoxStyle(
fill=GRAY2, fontsize=8, fontweight="bold"
)
# SEE
draw_box(
ax,
(0.8, by),
(bw, bh),
"SEE\n(Percepcja)",
bold_fs8,
)
ax.text(
1.5,
by - 0.2,
"kamery, LIDAR\nczujniki dotyku",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# THINK
draw_box(
ax,
(2.8, by),
(bw, bh),
"THINK\n(Deliberacja)",
BoxStyle(
fill=GRAY3, fontsize=8, fontweight="bold"
),
)
ax.text(
3.5,
by - 0.2,
"planowanie trasy\nmodel BDI",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# ACT
draw_box(
ax,
(4.8, by),
(bw, bh),
"ACT\n(Akcja)",
bold_fs8,
)
ax.text(
5.5,
by - 0.2,
"silniki, chwytaki\nkomendy PWM",
ha="center",
va="top",
fontsize=6,
fontstyle="italic",
)
# Arrows between phases
draw_arrow(
ax,
(0.8 + bw, by + bh / 2),
(2.8, by + bh / 2),
ArrowCfg(lw=1.5, label="dane sensoryczne"),
)
draw_arrow(
ax,
(2.8 + bw, by + bh / 2),
(4.8, by + bh / 2),
ArrowCfg(
lw=1.5, label="komendy steruj\u0105ce"
),
)
# Arrows to/from environment
draw_arrow(
ax,
(1.5, 1.2),
(1.5, by),
ArrowCfg(
lw=1.3,
label="odczyt",
label_offset=0.08,
),
)
draw_arrow(
ax,
(5.5, by),
(5.5, 1.2),
ArrowCfg(
lw=1.3,
label="dzia\u0142anie",
label_offset=0.08,
),
)
# Feedback loop arrow
ax.annotate(
"",
xy=(1.5, 1.15),
xytext=(5.5, 1.15),
arrowprops={
"arrowstyle": "->",
"color": "#555555",
"lw": 1.0,
"linestyle": "dashed",
"connectionstyle": "arc3,rad=-0.15",
},
)
ax.text(
3.5,
0.35,
"\u2190 sprz\u0119\u017cenie zwrotne"
" (efekt akcji zmienia \u015brodowisko) \u2192",
ha="center",
va="center",
fontsize=6,
color="#555555",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_see_think_act.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
logger.info(" \u2713 %s", path)
# --- DIAGRAM 2: 3T Architecture ---
def draw_3t_architecture() -> None:
"""Draw 3t architecture."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 5.5), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 5.5)
ax.axis("off")
ax.set_title(
"Architektura 3T sterownika robota"
" (3-Layer Architecture)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
layers = [
{
"y": 4.0,
"name": "WARSTWA 3: PLANNER\n(Deliberacja)",
"time": "sekundy \u2013 minuty",
"fill": GRAY1,
"example": (
'Cel: "Jed\u017a do kuchni po kubek"\n'
"Planowanie trasy A \u2192 B \u2192 C"
),
},
{
"y": 2.6,
"name": "WARSTWA 2: SEQUENCER\n(Wykonawca)",
"time": "100 ms \u2013 sekundy",
"fill": GRAY2,
"example": (
"Sekwencja: Jed\u017a do drzwi \u2192\n"
"Otw\u00f3rz \u2192 Jed\u017a do blatu"
" \u2192 Chwy\u0107"
),
},
{
"y": 1.2,
"name": "WARSTWA 1: CONTROLLER\n(Reaktywny)",
"time": "milisekundy",
"fill": GRAY3,
"example": (
"PID: utrzymaj pr\u0119dko\u015b\u0107"
" 0.5 m/s\n"
"Unikaj kolizji (emergency stop)"
),
},
]
bw = 4.0
bh = 0.85
for layer in layers:
y = layer["y"]
draw_box(
ax,
(0.3, y),
(bw, bh),
layer["name"],
BoxStyle(
fill=layer["fill"],
fontsize=8,
fontweight="bold",
),
)
ax.text(
0.15,
y + bh / 2,
layer["time"],
ha="right",
va="center",
fontsize=6,
fontstyle="italic",
rotation=0,
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": "white",
"edgecolor": LN,
"lw": 0.5,
},
)
draw_box(
ax,
(4.5, y),
(2.3, bh),
layer["example"],
BoxStyle(fontsize=6.5),
)
# Arrows between layers
for i in range(len(layers) - 1):
y_top = layers[i]["y"]
y_bot = layers[i + 1]["y"] + 0.85
draw_arrow(
ax,
(1.8, y_top),
(1.8, y_bot),
ArrowCfg(
lw=1.3,
label="polecenia \u2193",
label_offset=0.02,
),
)
draw_arrow(
ax,
(2.8, y_bot),
(2.8, y_top),
ArrowCfg(
lw=1.0,
color="#666666",
label="\u2191 status",
label_offset=0.02,
),
)
# Environment at bottom
env_rect = FancyBboxPatch(
(0.3, 0.3),
bw,
0.6,
boxstyle="round,pad=0.05",
lw=1.5,
edgecolor=LN,
facecolor=GRAY4,
linestyle="--",
)
ax.add_patch(env_rect)
ax.text(
0.3 + bw / 2,
0.6,
"SPRZ\u0118T: silniki, czujniki, efektory",
ha="center",
va="center",
fontsize=7,
fontstyle="italic",
)
draw_arrow(
ax, (2.3, 1.2), (2.3, 0.9), ArrowCfg(lw=1.3)
)
# Abstraction label on the right
ax.annotate(
"",
xy=(6.9, 4.8),
xytext=(6.9, 0.5),
arrowprops={
"arrowstyle": "<->",
"color": "#888888",
"lw": 1.0,
},
)
ax.text(
6.95,
2.65,
"abstrakcja",
ha="left",
va="center",
fontsize=7,
rotation=90,
color="#888888",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_3t_architecture.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
logger.info(" \u2713 %s", path)
# --- DIAGRAM 3: Behavior Tree Example ---
def draw_behavior_tree() -> None:
"""Draw behavior tree."""
fig, ax = plt.subplots(
1, 1, figsize=(7.5, 4.5), facecolor=BG
)
ax.set_xlim(0, 7.5)
ax.set_ylim(0, 4.5)
ax.axis("off")
ax.set_title(
"Behavior Tree: robot przenosz\u0105cy"
" obiekt (pick-and-place)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
def draw_bt_node(
pos: tuple[float, float],
text: str,
ntype: str = "act",
size: tuple[float, float] = (1.0, 0.45),
) -> tuple[float, float]:
"""Draw a behavior tree node."""
x, y = pos
w, h = size
if ntype == "seq":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(rect)
ax.text(
x,
y,
f"\u2192 {text}",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
elif ntype == "sel":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.5,
edgecolor=LN,
facecolor=GRAY3,
)
ax.add_patch(rect)
ax.text(
x,
y,
f"? {text}",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
elif ntype == "cond":
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.0,
edgecolor=LN,
facecolor="white",
linestyle="--",
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=6.5,
fontstyle="italic",
)
else: # action
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.06",
lw=1.0,
edgecolor=LN,
facecolor=GRAY1,
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=6.5,
)
return x, y
# Root: Sequence "Przenies obiekt"
root = draw_bt_node(
(3.75, 3.8), "Przenie\u015b obiekt", "seq",
(1.6, 0.45),
)
# Level 2 children
find = draw_bt_node(
(1.2, 2.8), "Znajd\u017a obiekt", "sel",
(1.3, 0.45),
)
nav = draw_bt_node(
(3.75, 2.8), "Jed\u017a do obiektu", "act",
(1.3, 0.45),
)
pick = draw_bt_node(
(6.3, 2.8), "Chwy\u0107 i dostarcz", "seq",
(1.4, 0.45),
)
# Arrows from root
arrow_thin = ArrowCfg(lw=1.0)
for child in (find, nav, pick):
draw_arrow(
ax,
(root[0], root[1] - 0.225),
(child[0], child[1] + 0.225),
arrow_thin,
)
# Level 3: children of "Znajdz obiekt"
arrow_08 = ArrowCfg(lw=0.8)
vis = draw_bt_node(
(0.55, 1.7), "Widz\u0119\nobiekt?", "cond",
(0.85, 0.5),
)
scan = draw_bt_node(
(1.85, 1.7), "Skanuj\notoczenie", "act",
(0.85, 0.5),
)
for child in (vis, scan):
draw_arrow(
ax,
(find[0], find[1] - 0.225),
(child[0], child[1] + 0.25),
arrow_08,
)
# Level 3: children of "Chwyt i dostarcz"
pick_children = [
draw_bt_node(
(5.4, 1.7), "Chwy\u0107\nobject", "act",
(0.85, 0.5),
),
draw_bt_node(
(6.5, 1.7), "Jed\u017a do\ncelu", "act",
(0.85, 0.5),
),
draw_bt_node(
(7.2, 1.7), "Pu\u015b\u0107", "act",
(0.55, 0.5),
),
]
for child in pick_children:
draw_arrow(
ax,
(pick[0], pick[1] - 0.225),
(child[0], child[1] + 0.25),
arrow_08,
)
# Legend
leg_y = 0.5
draw_bt_node(
(0.8, leg_y), "\u2192 Sequence", "seq",
(1.1, 0.35),
)
draw_bt_node(
(2.3, leg_y), "? Selector", "sel",
(1.0, 0.35),
)
draw_bt_node(
(3.6, leg_y), "Akcja", "act", (0.8, 0.35)
)
draw_bt_node(
(4.8, leg_y), "Warunek", "cond", (0.8, 0.35)
)
ax.text(
0.3,
leg_y,
"Legenda:",
ha="left",
va="center",
fontsize=6.5,
fontweight="bold",
)
# Execution note
ax.text(
3.75,
0.05,
"Wykonanie: od lewej do prawej."
" Sequence (\u2192) = wszystkie po kolei."
" Selector (?) = pierwszy sukces.",
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#555555",
)
fig.tight_layout()
path = str(
Path(OUTPUT_DIR) / "agent_behavior_tree.png"
)
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
logger.info(" \u2713 %s", path)
# --- DIAGRAM 4: BDI Model ---
def draw_bdi_model() -> None:
"""Draw bdi model."""
fig, ax = plt.subplots(
1, 1, figsize=(7, 4), facecolor=BG
)
ax.set_xlim(0, 7)
ax.set_ylim(0, 4)
ax.axis("off")
ax.set_title(
"Model BDI agenta"
" (Beliefs-Desires-Intentions)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
bw = 1.6
bh = 1.4
bold8 = BoxStyle(
fill=GRAY1, fontsize=8, fontweight="bold"
)
# BELIEFS box
draw_box(ax, (0.3, 1.3), (bw, bh), "", bold8)
ax.text(
0.3 + bw / 2,
1.3 + bh - 0.15,
"BELIEFS",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
0.3 + bw / 2,
1.3 + bh / 2 - 0.1,
"(wiedza o \u015bwiecie)\n\n"
"\u2022 mapa pokoju\n"
"\u2022 pozycja robota\n"
"\u2022 drzwi zamkni\u0119te\n"
"\u2022 bateria: 45%",
ha="center",
va="center",
fontsize=6.5,
)
# DESIRES box
draw_box(
ax,
(2.7, 1.3),
(bw, bh),
"",
BoxStyle(
fill=GRAY2, fontsize=8, fontweight="bold"
),
)
ax.text(
2.7 + bw / 2,
1.3 + bh - 0.15,
"DESIRES",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
2.7 + bw / 2,
1.3 + bh / 2 - 0.1,
"(cele agenta)\n\n"
"\u2022 dostarczy\u0107 paczk\u0119\n"
" do pokoju 5\n"
"\u2022 na\u0142adowa\u0107 bateri\u0119\n"
"\u2022 unika\u0107 kolizji",
ha="center",
va="center",
fontsize=6.5,
)
# INTENTIONS box
draw_box(
ax,
(5.1, 1.3),
(bw, bh),
"",
BoxStyle(
fill=GRAY3, fontsize=8, fontweight="bold"
),
)
ax.text(
5.1 + bw / 2,
1.3 + bh - 0.15,
"INTENTIONS",
ha="center",
va="top",
fontsize=9,
fontweight="bold",
)
ax.text(
5.1 + bw / 2,
1.3 + bh / 2 - 0.1,
"(aktualny plan)\n\n"
"\u2192 jed\u017a do drzwi\n"
" bocznych\n"
"\u2192 otw\u00f3rz drzwi\n"
"\u2192 wjed\u017a do pokoju 5",
ha="center",
va="center",
fontsize=6.5,
)
# Arrows
draw_arrow(
ax,
(0.3 + bw, 1.3 + bh / 2 + 0.15),
(2.7, 1.3 + bh / 2 + 0.15),
ArrowCfg(
lw=1.3,
label="informuje",
label_offset=0.08,
),
)
draw_arrow(
ax,
(2.7 + bw, 1.3 + bh / 2 + 0.15),
(5.1, 1.3 + bh / 2 + 0.15),
ArrowCfg(
lw=1.3,
label="filtruje \u2192 wybiera",
label_offset=0.08,
),
)
# Feedback: intentions back to beliefs
ax.annotate(
"",
xy=(0.3 + bw / 2, 1.3),
xytext=(5.1 + bw / 2, 1.3),
arrowprops={
"arrowstyle": "->",
"color": "#666666",
"lw": 1.0,
"linestyle": "dashed",
"connectionstyle": "arc3,rad=0.3",
},
)
ax.text(
3.5,
0.75,
"aktualizacja wiedzy po wykonaniu akcji",
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#666666",
)
# Sensor input arrow
draw_arrow(
ax,
(0.3 + bw / 2, 3.5),
(0.3 + bw / 2, 1.3 + bh),
ArrowCfg(
lw=1.3,
label="percepcja (sensory)",
label_offset=0.05,
),
)
ax.text(
0.3 + bw / 2,
3.55,
"\u015aRODOWISKO",
ha="center",
va="bottom",
fontsize=7,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
# Action output arrow
draw_arrow(
ax,
(5.1 + bw / 2, 1.3 + bh),
(5.1 + bw / 2, 3.5),
ArrowCfg(
lw=1.3,
label="akcja (efektory)",
label_offset=0.05,
),
)
ax.text(
5.1 + bw / 2,
3.55,
"EFEKTORY",
ha="center",
va="bottom",
fontsize=7,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
path = str(Path(OUTPUT_DIR) / "agent_bdi_model.png")
fig.savefig(
path, dpi=DPI, bbox_inches="tight", facecolor=BG
)
plt.close(fig)
logger.info(" \u2713 %s", path)
# --- MAIN ---
if __name__ == "__main__":
logger.info("Generating PYTANIE 15 diagrams...")
from python_pkg.praca_magisterska_video.generate_images._agent_cognitive import (
draw_bdi_model,
draw_behavior_tree,
)
from python_pkg.praca_magisterska_video.generate_images._agent_reactive import (
draw_3t_architecture,
draw_see_think_act,
)
logging.basicConfig(level=logging.INFO)
logger.info("Generating agent diagrams...")
draw_see_think_act()
draw_3t_architecture()
draw_behavior_tree()
draw_bdi_model()
logger.info("Done! All diagrams saved to %s", OUTPUT_DIR)
logger.info("All agent diagrams saved to %s/", OUTPUT_DIR)

View File

@ -64,8 +64,7 @@ def _extract_main_card(
answer_parts: list[str] = []
main_answer = re.search(
r"## 📚 Odpowiedź główna\s*\n(.+?)"
r"(?=\n## |\n---\s*\n## |\Z)",
r"## 📚 Odpowiedź główna\s*\n(.+?)" r"(?=\n## |\n---\s*\n## |\Z)",
content,
re.DOTALL,
)
@ -74,17 +73,13 @@ def _extract_main_card(
headers = re.findall(r"### (.+)", answer_text)
answer_parts.extend(f"{h}" for h in headers[:5])
definitions = re.findall(
r"\*\*([^*]+)\*\*\s*[--:]\s*([^*\n]+)", content
)
definitions = re.findall(r"\*\*([^*]+)\*\*\s*[--:]\s*([^*\n]+)", content)
for term, definition in definitions[:3]:
if (
len(definition) > MIN_DEFINITION_LENGTH
and len(definition) < MAX_DEFINITION_LENGTH
):
answer_parts.append(
f"{term}: {definition.strip()}"
)
answer_parts.append(f"{term}: {definition.strip()}")
if not answer_parts:
return []
@ -94,19 +89,14 @@ def _extract_main_card(
{
"question": main_question,
"answer": answer_html,
"tags": (
f"egzamin_magisterski pytanie_{num}"
f" {subject} {topic}"
),
"tags": (f"egzamin_magisterski pytanie_{num}" f" {subject} {topic}"),
}
]
def _extract_subsection_answer(body_clean: str) -> str | None:
"""Extract answer text from a subsection body."""
bullets = re.findall(
r"[-•]\s*\*\*(.+?)\*\*[:\s]*([^\n]+)?", body_clean
)
bullets = re.findall(r"[-•]\s*\*\*(.+?)\*\*[:\s]*([^\n]+)?", body_clean)
if bullets:
return "<br>".join(
f"{b[0]}: {b[1].strip()}" if b[1] else f"{b[0]}"
@ -116,9 +106,7 @@ def _extract_subsection_answer(body_clean: str) -> str | None:
paragraphs = [
p.strip()
for p in body_clean.split("\n\n")
if p.strip()
and not p.startswith("```")
and not p.startswith("|")
if p.strip() and not p.startswith("```") and not p.startswith("|")
]
if paragraphs:
first_para = paragraphs[0]
@ -139,17 +127,13 @@ def _extract_sub_cards(
"""Extract sub-concept cards."""
cards: list[dict[str, str]] = []
subsections = re.findall(
r"### (\d+\.\s+)?(.+?)\n\n(.+?)"
r"(?=\n### |\n## |\n---|\Z)",
r"### (\d+\.\s+)?(.+?)\n\n(.+?)" r"(?=\n### |\n## |\n---|\Z)",
content,
re.DOTALL,
)
for _, header, body in subsections:
if (
len(header) < MIN_SUBSECTION_LENGTH
or header.startswith("Przykład")
):
if len(header) < MIN_SUBSECTION_LENGTH or header.startswith("Przykład"):
continue
body_clean = body.strip()
@ -160,19 +144,10 @@ def _extract_sub_cards(
if not answer_text:
continue
sub_question = (
f"Co to jest {header}?"
if not header.endswith("?")
else header
)
sub_question = f"Co to jest {header}?" if not header.endswith("?") else header
if any(
kw in header
for kw in ("Charakterystyka", "Definicja", "Właściwości")
):
parent = title.replace("Pytanie", "").strip(
": 0123456789"
)
if any(kw in header for kw in ("Charakterystyka", "Definicja", "Właściwości")):
parent = title.replace("Pytanie", "").strip(": 0123456789")
sub_question = f"{header} - {parent}"
cards.append(
@ -180,8 +155,7 @@ def _extract_sub_cards(
"question": sub_question,
"answer": answer_text,
"tags": (
f"egzamin_magisterski pytanie_{num}"
f" {subject} {topic} szczegoly"
f"egzamin_magisterski pytanie_{num}" f" {subject} {topic} szczegoly"
),
}
)
@ -210,8 +184,7 @@ def _extract_formula_cards(
"question": f"Podaj {formula_name.strip()}",
"answer": formula_content.strip()[:300],
"tags": (
f"egzamin_magisterski pytanie_{num}"
f" {subject} formuly"
f"egzamin_magisterski pytanie_{num}" f" {subject} formuly"
),
}
)
@ -223,29 +196,15 @@ def extract_question_and_answer(
filepath: str,
) -> list[dict[str, str]]:
"""Extract main question and key answer points from a markdown file."""
num, topic, title, main_question, content = _get_metadata(
filepath
)
num, topic, title, main_question, content = _get_metadata(filepath)
subject_match = re.search(r"Przedmiot:\s*(\w+)", content)
subject = (
subject_match.group(1) if subject_match else "Ogólne"
)
subject = subject_match.group(1) if subject_match else "Ogólne"
cards: list[dict[str, str]] = []
cards.extend(
_extract_main_card(
content, main_question, subject, num, topic
)
)
cards.extend(
_extract_sub_cards(
content, title, subject, num, topic
)
)
cards.extend(
_extract_formula_cards(content, subject, num)
)
cards.extend(_extract_main_card(content, main_question, subject, num, topic))
cards.extend(_extract_sub_cards(content, title, subject, num, topic))
cards.extend(_extract_formula_cards(content, subject, num))
return cards

View File

@ -72,7 +72,8 @@ def _get_file_metadata(
def _extract_main_question_card(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Extract the main exam question card."""
q_match = re.search(
@ -85,8 +86,7 @@ def _extract_main_question_card(
main_q = re.sub(r"\s+", " ", q_match.group(1).strip())
answer_match = re.search(
r"## 📚 Odpowiedź główna\s*\n(.+?)"
r"(?=\n## [📚🎯]|\n---\s*\n## |\Z)",
r"## 📚 Odpowiedź główna\s*\n(.+?)" r"(?=\n## [📚🎯]|\n---\s*\n## |\Z)",
content,
re.DOTALL,
)
@ -99,18 +99,12 @@ def _extract_main_question_card(
answer_section,
re.MULTILINE,
)
headers = [
h.strip()
for h in headers
if len(h.strip()) > MIN_HEADER_LENGTH
][:6]
headers = [h.strip() for h in headers if len(h.strip()) > MIN_HEADER_LENGTH][:6]
if not headers:
return []
answer_html = (
"<b>Kluczowe zagadnienia:</b>" + format_list(headers)
)
answer_html = "<b>Kluczowe zagadnienia:</b>" + format_list(headers)
return [
{
"front": clean_text(main_q),
@ -123,10 +117,7 @@ def _extract_main_question_card(
def _make_question_text(header: str) -> str:
"""Generate a question from a section header."""
if "Definicja" in header or "Co to" in header:
return (
f"Co to jest:"
f" {header.replace('Definicja', '').strip()}?"
)
return f"Co to jest:" f" {header.replace('Definicja', '').strip()}?"
if "Charakterystyka" in header:
stripped = header.replace("Charakterystyka", "").strip()
return f"Scharakteryzuj: {stripped}"
@ -143,14 +134,10 @@ def _extract_body_parts(body: str) -> list[str]:
if subheaders:
answer_parts.extend(subheaders[:4])
bullets = re.findall(
r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body
)
bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body)
for term, desc in bullets[:5]:
if desc:
answer_parts.append(
f"<b>{term}</b>: {desc.strip()}"
)
answer_parts.append(f"<b>{term}</b>: {desc.strip()}")
else:
answer_parts.append(f"<b>{term}</b>")
@ -172,7 +159,8 @@ def _extract_body_parts(body: str) -> list[str]:
def _extract_subsection_cards(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Extract subsection detail cards."""
cards: list[dict[str, str]] = []
@ -186,10 +174,7 @@ def _extract_subsection_cards(
header = raw_header.strip()
body = raw_body.strip()
if (
len(body) < MIN_BODY_LENGTH
or header.lower().startswith("przykład")
):
if len(body) < MIN_BODY_LENGTH or header.lower().startswith("przykład"):
continue
answer_parts = _extract_body_parts(body)
@ -213,7 +198,8 @@ def _extract_subsection_cards(
def _extract_algo_cards(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Extract algorithm/formula cards."""
cards: list[dict[str, str]] = []
@ -235,12 +221,9 @@ def _extract_algo_cards(
cards.append(
{
"front": (
"Jaka jest złożoność"
f" algorytmu/metody: {algo_name}?"
),
"back": clean_text(
algo_match.strip()[:200]
"Jaka jest złożoność" f" algorytmu/metody: {algo_name}?"
),
"back": clean_text(algo_match.strip()[:200]),
"tags": f"{base_tags} zlozonosc",
}
)
@ -250,7 +233,9 @@ def _extract_algo_cards(
def _extract_comparison_cards(
content: str, base_tags: str, num: str,
content: str,
base_tags: str,
num: str,
) -> list[dict[str, str]]:
"""Extract comparison cards."""
compare_match = re.search(
@ -269,19 +254,15 @@ def _extract_comparison_cards(
if not items:
return []
comparison_html = (
"<table><tr><th>Aspekt</th><th>Wartość</th></tr>"
)
comparison_html = "<table><tr><th>Aspekt</th><th>Wartość</th></tr>"
for aspect, value in items[:MAX_COMPARISON_ITEMS]:
comparison_html += (
f"<tr><td>{clean_text(aspect)}</td>"
f"<td>{clean_text(value)}</td></tr>"
f"<tr><td>{clean_text(aspect)}</td>" f"<td>{clean_text(value)}</td></tr>"
)
comparison_html += "</table>"
title_match = re.search(
r"## .*(Porównanie|Zestawienie)"
r".*?(\w+.*?(?:vs|i|oraz).*?\w+)",
r"## .*(Porównanie|Zestawienie)" r".*?(\w+.*?(?:vs|i|oraz).*?\w+)",
compare_match.group(0),
re.IGNORECASE,
)
@ -290,10 +271,7 @@ def _extract_comparison_cards(
return [
{
"front": (
"Porównaj kluczowe różnice"
f" w temacie: pytanie {num}"
),
"front": ("Porównaj kluczowe różnice" f" w temacie: pytanie {num}"),
"back": comparison_html,
"tags": f"{base_tags} porownanie",
}
@ -301,7 +279,8 @@ def _extract_comparison_cards(
def _extract_qa_cards(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Extract Q&A practice cards."""
cards: list[dict[str, str]] = []
@ -315,8 +294,7 @@ def _extract_qa_cards(
qa_content = qa_section.group(1)
qas = re.findall(
r"### Q\d+:?\s*[\"']?(.+?)[\"']?\s*\n"
r".*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)",
r"### Q\d+:?\s*[\"']?(.+?)[\"']?\s*\n" r".*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)",
qa_content,
re.DOTALL,
)
@ -332,9 +310,7 @@ def _extract_qa_cards(
cards.append(
{
"front": clean_text(question),
"back": clean_text(a_short).replace(
"\n", "<br>"
),
"back": clean_text(a_short).replace("\n", "<br>"),
"tags": f"{base_tags} egzamin_praktyka",
}
)
@ -351,9 +327,7 @@ def extract_from_file(filepath: str) -> list[dict[str, str]]:
cards.extend(_extract_main_question_card(content, base_tags))
cards.extend(_extract_subsection_cards(content, base_tags))
cards.extend(_extract_algo_cards(content, base_tags))
cards.extend(
_extract_comparison_cards(content, base_tags, num)
)
cards.extend(_extract_comparison_cards(content, base_tags, num))
cards.extend(_extract_qa_cards(content, base_tags))
return cards
@ -404,9 +378,7 @@ def main() -> None:
f.write(f"{front}\t{back}\t{tags}\n")
logger.info("=" * 50)
logger.info(
"Generated %d unique flashcards", len(unique_cards)
)
logger.info("Generated %d unique flashcards", len(unique_cards))
logger.info("Saved to: %s", output_file)
logger.info("=" * 50)
logger.info("IMPORT INSTRUCTIONS:")

View File

@ -192,15 +192,9 @@ def main() -> None:
logger.info("2. Select: anki_egzamin_magisterski.txt")
logger.info("3. Set 'Fields separated by: Tab'")
logger.info("4. Check 'Allow HTML in fields'")
logger.info(
"5. Map: Field 1 -> Front, Field 2 -> Back,"
" Field 3 -> Tags"
)
logger.info("5. Map: Field 1 -> Front, Field 2 -> Back," " Field 3 -> Tags")
logger.info("6. Click Import")
logger.info(
"For AnkiWeb/AnkiDroid:"
" Sync after importing on desktop"
)
logger.info("For AnkiWeb/AnkiDroid:" " Sync after importing on desktop")
if __name__ == "__main__":

View File

@ -60,7 +60,8 @@ def extract_real_answer(content: str, section_name: str) -> str | None:
if p.strip() and not p.startswith("```") and not p.startswith("|")
]
lines.extend(
p for p in paras[:2]
p
for p in paras[:2]
if len(p) > MIN_PARA_LENGTH and len(p) < MAX_PARA_LENGTH
)
@ -87,9 +88,7 @@ def _read_file_metadata(
content,
re.DOTALL,
)
main_question = (
re.sub(r"\s+", " ", q_match.group(1).strip()) if q_match else None
)
main_question = re.sub(r"\s+", " ", q_match.group(1).strip()) if q_match else None
return content, base_tags, main_question
@ -103,16 +102,10 @@ def _extract_automata_facts(content: str) -> list[str]:
("Maszyna Turinga", "TM"),
]
for name, abbrev in automata:
pattern = (
rf"{name}.*?Rozpoznawana klasa języków"
r"\s*\n\s*\*\*([^*]+)\*\*"
)
pattern = rf"{name}.*?Rozpoznawana klasa języków" r"\s*\n\s*\*\*([^*]+)\*\*"
match = re.search(pattern, content, re.DOTALL)
if match:
parts.append(
f"<b>{name} ({abbrev})</b>: "
f"{match.group(1).strip()}"
)
parts.append(f"<b>{name} ({abbrev})</b>: " f"{match.group(1).strip()}")
return parts
@ -157,10 +150,7 @@ def _build_main_card(
return None
answer_parts: list[str] = []
if (
"automat" in main_question.lower()
or "maszyn" in main_question.lower()
):
if "automat" in main_question.lower() or "maszyn" in main_question.lower():
answer_parts = _extract_automata_facts(content)
if not answer_parts:
@ -172,9 +162,7 @@ def _build_main_card(
if not answer_parts:
return None
answer = "<br><br>".join(
clean_text(p) for p in answer_parts
)
answer = "<br><br>".join(clean_text(p) for p in answer_parts)
return {
"front": clean_text(main_question),
"back": answer,
@ -187,13 +175,15 @@ def _extract_section_content(body: str) -> list[str]:
answer_lines: list[str] = []
def_match = re.search(
r"#### Definicja[^\n]*\n([^\n#]+(?:\n[^\n#]+)?)", body,
r"#### Definicja[^\n]*\n([^\n#]+(?:\n[^\n#]+)?)",
body,
)
if def_match:
answer_lines.append(def_match.group(1).strip())
char_match = re.search(
r"#### Charakterystyka\s*\n((?:[-•][^\n]+\n?)+)", body,
r"#### Charakterystyka\s*\n((?:[-•][^\n]+\n?)+)",
body,
)
if char_match:
bullets = re.findall(
@ -202,25 +192,24 @@ def _extract_section_content(body: str) -> list[str]:
)
for term, desc in bullets[:4]:
answer_lines.append(
f"• <b>{term}</b>: {desc.strip()}"
if desc
else f"• <b>{term}</b>"
f"• <b>{term}</b>: {desc.strip()}" if desc else f"• <b>{term}</b>"
)
if not answer_lines:
bullets = re.findall(
r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", body,
r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)",
body,
)
for term, desc in bullets[:5]:
answer_lines.append(
f"• <b>{term}</b>: {desc.strip()}"
if desc
else f"• <b>{term}</b>"
f"• <b>{term}</b>: {desc.strip()}" if desc else f"• <b>{term}</b>"
)
if not answer_lines:
first_para = re.search(
r"^([^#\n\-•|`][^\n]{30,250})", body, re.MULTILINE,
r"^([^#\n\-•|`][^\n]{30,250})",
body,
re.MULTILINE,
)
if first_para:
answer_lines.append(first_para.group(1))
@ -229,7 +218,8 @@ def _extract_section_content(body: str) -> list[str]:
def _build_concept_cards(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Build concept cards from ### sections."""
cards: list[dict[str, str]] = []
@ -255,12 +245,8 @@ def _build_concept_cards(
if not answer_lines:
continue
question = (
header if header.endswith("?") else f"Wyjaśnij: {header}"
)
answer = "<br>".join(
clean_text(line) for line in answer_lines
)
question = header if header.endswith("?") else f"Wyjaśnij: {header}"
answer = "<br>".join(clean_text(line) for line in answer_lines)
cards.append(
{
"front": clean_text(question),
@ -273,7 +259,8 @@ def _build_concept_cards(
def _build_qa_cards(
content: str, base_tags: str,
content: str,
base_tags: str,
) -> list[dict[str, str]]:
"""Build Q&A practice cards."""
cards: list[dict[str, str]] = []
@ -301,9 +288,7 @@ def _build_qa_cards(
cards.append(
{
"front": clean_text(question + "?"),
"back": "<br>".join(
clean_text(line) for line in clean_answer
),
"back": "<br>".join(clean_text(line) for line in clean_answer),
"tags": f"{base_tags} qa",
}
)

View File

@ -27,6 +27,12 @@ import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
from python_pkg.praca_magisterska_video.generate_images._arch_c4 import generate_c4
from python_pkg.praca_magisterska_video.generate_images._arch_layers import (
generate_archimate,
generate_zachman,
)
_logger = logging.getLogger(__name__)
DPI = 300
@ -127,8 +133,12 @@ def _draw_class(
h_total = h_name + h_attr + h_meth
ax.add_patch(
plt.Rectangle(
(x, y), w, h_total, lw=1.5,
edgecolor=LN, facecolor=fill,
(x, y),
w,
h_total,
lw=1.5,
edgecolor=LN,
facecolor=fill,
)
)
ax.plot(
@ -147,8 +157,10 @@ def _draw_class(
fontweight="bold",
)
ax.plot(
[x, x + w], [y + h_meth, y + h_meth],
color=LN, lw=1,
[x, x + w],
[y + h_meth, y + h_meth],
color=LN,
lw=1,
)
for i, a in enumerate(attrs):
ax.text(
@ -344,8 +356,7 @@ def generate_4plus1() -> None:
"Programista",
),
(
"Process View\n(Współbieżność,"
"\nprzepływ danych)",
"Process View\n(Współbieżność," "\nprzepływ danych)",
cx + 28,
cy,
"Integrator",
@ -455,609 +466,6 @@ def generate_4plus1() -> None:
_logger.info(" OK 4+1 View Model")
# =========================================================================
# 3. C4 Model — 4 Zoom Levels
# =========================================================================
def _draw_c4_system_context(ax1: Axes) -> None:
"""Draw C4 Level 1: System Context."""
# Person
ax1.add_patch(
plt.Circle(
(20, 55), 4, lw=1.5,
edgecolor=LN, facecolor=GRAY1,
)
)
# Head
ax1.add_patch(
plt.Circle(
(20, 57.5), 1.5, lw=1.2,
edgecolor=LN, facecolor="white",
)
)
# Body
draw_line(ax1, 20, 56, 20, 52.5, lw=1.2)
draw_line(ax1, 17, 55, 23, 55, lw=1.2)
ax1.text(
20, 48, "Klient",
ha="center", fontsize=8, fontweight="bold",
)
draw_box(
ax1, 38, 43, 24, 18,
"System\nE-commerce",
fill=GRAY2, lw=2, fontsize=9,
fontweight="bold", rounded=True,
)
draw_box(
ax1, 72, 48, 20, 12,
"System\nP\u0142atno\u015bci\n(zewn.)",
fill=GRAY4, lw=1.5, fontsize=7,
rounded=True,
)
ax1.add_patch(
plt.Rectangle(
(72, 48), 20, 12, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
draw_arrow(ax1, 24, 54, 38, 54)
ax1.text(
31, 56, "sk\u0142ada\nzam\u00f3wienia",
fontsize=6, ha="center",
)
draw_arrow(ax1, 62, 54, 72, 54)
ax1.text(67, 56, "API", fontsize=6, ha="center")
ax1.text(
50, 20,
"Kto u\u017cywa systemu?\nZ czym si\u0119 integruje?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_container(ax2: Axes) -> None:
"""Draw C4 Level 2: Container."""
ax2.add_patch(
plt.Rectangle(
(5, 15), 90, 58, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
ax2.text(
50, 75, "System E-commerce",
ha="center", fontsize=8,
fontweight="bold", fontstyle="italic",
)
containers = [
("SPA\n(React)", 15, 50, 18, 12, GRAY1),
("API\nServer\n(Node.js)", 42, 50, 18, 12, GRAY2),
("Database\n(PostgreSQL)", 70, 50, 18, 12, GRAY3),
("Worker\n(Python)", 42, 25, 18, 12, GRAY1),
]
for label, x, y, w, h, fill in containers:
draw_box(
ax2, x, y, w, h, label,
fill=fill, lw=1.5, fontsize=7,
fontweight="bold", rounded=True,
)
draw_arrow(ax2, 33, 56, 42, 56)
ax2.text(37.5, 58, "REST", fontsize=6, ha="center")
draw_arrow(ax2, 60, 56, 70, 56)
ax2.text(65, 58, "SQL", fontsize=6, ha="center")
draw_arrow(ax2, 51, 50, 51, 37)
ax2.text(53, 44, "async", fontsize=6)
ax2.text(
50, 8,
"Jakie kontenery techniczne\n"
"sk\u0142adaj\u0105 si\u0119 na system?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_component(ax3: Axes) -> None:
"""Draw C4 Level 3: Component."""
ax3.add_patch(
plt.Rectangle(
(5, 15), 90, 58, lw=1.5,
edgecolor=LN, facecolor="none",
linestyle="--",
)
)
ax3.text(
50, 75, "API Server (Node.js)",
ha="center", fontsize=8,
fontweight="bold", fontstyle="italic",
)
components = [
("OrderController", 10, 50, 22, 10, GRAY1),
("AuthService", 40, 50, 22, 10, GRAY2),
("PaymentGateway\n(adapter)", 70, 50, 22, 10, GRAY1),
("OrderRepository", 25, 25, 22, 10, GRAY2),
("NotificationService", 57, 25, 22, 10, GRAY1),
]
for label, x, y, w, h, fill in components:
draw_box(
ax3, x, y, w, h, label,
fill=fill, lw=1.5, fontsize=6.5,
fontweight="bold", rounded=True,
)
draw_arrow(ax3, 32, 55, 40, 55)
draw_arrow(ax3, 62, 55, 70, 55)
draw_arrow(ax3, 21, 50, 30, 35)
draw_arrow(ax3, 51, 50, 62, 35)
ax3.text(
50, 8,
"Jakie modu\u0142y/komponenty\n"
"wewn\u0105trz kontenera?",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def _draw_c4_code(ax4: Axes) -> None:
"""Draw C4 Level 4: Code (UML)."""
_draw_class(
ax4, 5, 40,
"\u00abinterface\u00bb\nIOrderRepository",
[],
["+save(order)", "+findById(id)"],
w=32, fill=GRAY4,
)
_draw_class(
ax4, 55, 40,
"OrderRepository",
["-db: Database"],
["+save(order)", "+findById(id)"],
w=32, fill=GRAY1,
)
_draw_class(
ax4, 30, 10,
"Order",
["-id: UUID", "-items: List", "-total: Money"],
["+addItem(item)", "+calculateTotal()"],
w=32, fill=GRAY2,
)
ax4.annotate(
"",
xy=(37, 46),
xytext=(55, 50),
arrowprops={
"arrowstyle": "-|>",
"color": LN,
"lw": 1.2,
"linestyle": "--",
},
)
ax4.text(
46, 52, "\u00abimplements\u00bb",
fontsize=6, ha="center", fontstyle="italic",
)
draw_arrow(ax4, 71, 40, 50, 24)
ax4.text(64, 32, "uses", fontsize=6, fontstyle="italic")
ax4.text(
50, 3,
"Diagramy klas UML\n"
"(opcjonalny poziom szczeg\u00f3\u0142owo\u015bci)",
ha="center", fontsize=7, fontstyle="italic",
bbox={
"boxstyle": "round",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.5,
},
)
def generate_c4() -> None:
"""Generate c4."""
fig, axes = plt.subplots(2, 2, figsize=(8.27, 10))
fig.patch.set_facecolor(BG)
fig.suptitle(
"C4 Model (Simon Brown) \u2014 4 poziomy zoomu",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
titles = [
"Level 1: System Context",
"Level 2: Container",
"Level 3: Component",
"Level 4: Code (UML)",
]
for idx, ax_item in enumerate(axes.flat):
ax_item.set_xlim(0, 100)
ax_item.set_ylim(0, 80)
ax_item.set_aspect("equal")
ax_item.axis("off")
ax_item.set_title(
titles[idx], fontsize=10,
fontweight="bold", pad=8,
)
_draw_c4_system_context(axes[0, 0])
_draw_c4_container(axes[0, 1])
_draw_c4_component(axes[1, 0])
_draw_c4_code(axes[1, 1])
fig.tight_layout(rect=[0, 0, 1, 0.96])
fig.savefig(
str(Path(OUTPUT_DIR) / "c4_model.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK C4 Model")
# =========================================================================
# 4. Zachman Framework Grid
# =========================================================================
def generate_zachman() -> None:
"""Generate zachman."""
fig, ax = plt.subplots(figsize=(8.27, 6))
ax.set_xlim(0, 100)
ax.set_ylim(0, 65)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Zachman Framework \u2014 taksonomia architektury",
fontsize=FS_TITLE,
fontweight="bold",
pad=12,
)
rows = [
"Kontekst\n(Planner)",
"Konceptualny\n(Owner)",
"Logiczny\n(Designer)",
"Fizyczny\n(Builder)",
"Szczeg\u00f3\u0142owy\n(Subcontractor)",
]
cols = [
"Co?\n(dane)",
"Jak?\n(funkcje)",
"Gdzie?\n(sie\u0107)",
"Kto?\n(ludzie)",
"Kiedy?\n(czas)",
"Dlaczego?\n(cel)",
]
n_rows = len(rows)
n_cols = len(cols)
x0 = 18
y0 = 5
cw = 12.5 # cell width
ch = 9 # cell height
rh_label = 14 # row label width
# Column headers
for j, col in enumerate(cols):
x = x0 + j * cw
draw_box(
ax,
x,
y0 + n_rows * ch,
cw,
7,
col,
fill=GRAY2,
lw=1.5,
fontsize=6.5,
fontweight="bold",
)
# Row headers
for i, row in enumerate(rows):
y = y0 + (n_rows - 1 - i) * ch
draw_box(
ax,
x0 - rh_label,
y,
rh_label,
ch,
row,
fill=GRAY2,
lw=1.5,
fontsize=6.5,
fontweight="bold",
)
# Cells
fills = [GRAY4, "white"]
for i in range(n_rows):
for j in range(n_cols):
x = x0 + j * cw
y = y0 + (n_rows - 1 - i) * ch
fill = fills[(i + j) % 2]
ax.add_patch(
plt.Rectangle((x, y), cw, ch, lw=0.8, edgecolor=LN, facecolor=fill)
)
# Sample content in a few cells
examples = {
(0, 0): "Lista\nencji",
(0, 1): "Lista\nproces\u00f3w",
(0, 2): "Lokalizacje",
(1, 0): "Model\npoj\u0119ciowy",
(1, 1): "Model\nproces\u00f3w",
(2, 0): "ERD",
(2, 1): "Data Flow",
(3, 0): "Schemat\nDB",
(3, 1): "Kod\nprogramu",
(0, 3): "Role",
(1, 3): "Org chart",
(0, 4): "Harmonogram",
(0, 5): "Cele\nbiznesowe",
}
for (i, j), text in examples.items():
x = x0 + j * cw
y = y0 + (n_rows - 1 - i) * ch
ax.text(
x + cw / 2,
y + ch / 2,
text,
ha="center",
va="center",
fontsize=5.5,
fontstyle="italic",
color="#444444",
)
# Note
ax.text(
50,
1,
"Każda komórka = artefakt opisujący system"
" z danej perspektywy i aspektu.\n"
"Zachman nie mówi JAK modelować"
" — mówi CO należy udokumentować.",
ha="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "zachman_framework.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK Zachman Framework")
# =========================================================================
# 5. ArchiMate Layers
# =========================================================================
def generate_archimate() -> None:
"""Generate archimate."""
fig, ax = plt.subplots(figsize=(8.27, 9))
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"ArchiMate \u2014 3 warstwy \u00d7 3 aspekty",
fontsize=FS_TITLE,
fontweight="bold",
pad=12,
)
# Column headers (aspects)
headers = [
("Active Structure\n(KTO?)", 0),
("Behavior\n(CO robi?)", 1),
("Passive Structure\n(NA CZYM?)", 2),
]
x0 = 10
y0 = 10
cw = 26
ch = 20
gap = 1
header_h = 8
row_label_w = 14
# Column headers
for label, j in headers:
x = x0 + row_label_w + j * (cw + gap)
draw_box(
ax,
x,
y0 + 3 * (ch + gap),
cw,
header_h,
label,
fill=GRAY3,
lw=1.5,
fontsize=8,
fontweight="bold",
)
# Layer rows
layers = [
(
"Business\nLayer",
GRAY1,
[
("Business\nActor", "Business\nProcess", "Business\nObject"),
("(Kto wykonuje?)", "(Co si\u0119 dzieje?)", "(Na czym dzia\u0142a?)"),
(
"np. Klient,\nHandlowiec",
"np. Obs\u0142uga\nzam\u00f3wienia",
"np. Zam\u00f3wienie,\nFaktura",
),
],
),
(
"Application\nLayer",
GRAY4,
[
("Application\nComponent", "Application\nService", "Data\nObject"),
("(Jaki modu\u0142?)", "(Jaka us\u0142uga?)", "(Jakie dane?)"),
("np. CRM,\nERP", "np. API\nzam\u00f3wie\u0144", "np. tabela\nOrders"),
],
),
(
"Technology\nLayer",
"white",
[
("Node /\nDevice", "Infrastructure\nService", "Artifact"),
("(Jaki sprz\u0119t?)", "(Jaka infra?)", "(Jaki plik?)"),
(
"np. Serwer\nLinux, K8s",
"np. Load\nBalancer",
"np. .jar,\n.war, image",
),
],
),
]
for i, (layer_name, fill, cells) in enumerate(layers):
y = y0 + (2 - i) * (ch + gap)
# Row label
draw_box(
ax,
x0,
y,
row_label_w,
ch,
layer_name,
fill=GRAY2,
lw=1.5,
fontsize=8,
fontweight="bold",
)
for j in range(3):
x = x0 + row_label_w + j * (cw + gap)
ax.add_patch(
plt.Rectangle((x, y), cw, ch, lw=1.5, edgecolor=LN, facecolor=fill)
)
# Element name (bold)
ax.text(
x + cw / 2,
y + ch - 3,
cells[0][j],
ha="center",
va="top",
fontsize=7,
fontweight="bold",
)
# Role description
ax.text(
x + cw / 2,
y + ch / 2,
cells[1][j],
ha="center",
va="center",
fontsize=6,
fontstyle="italic",
color="#555555",
)
# Example
ax.text(
x + cw / 2,
y + 3,
cells[2][j],
ha="center",
va="bottom",
fontsize=6,
color="#333333",
)
# Vertical arrows between layers
for j in range(3):
x = x0 + row_label_w + j * (cw + gap) + cw / 2
for i in range(2):
y_top = y0 + (2 - i) * (ch + gap)
y_bot = y0 + (2 - i - 1) * (ch + gap) + ch
draw_arrow(ax, x, y_top, x, y_bot + 0.3, lw=1)
# Arrow labels
mid_x = x0 + row_label_w - 3
ax.text(
mid_x,
y0 + 2 * (ch + gap) - gap / 2,
"realizacja \u2193",
fontsize=6,
ha="right",
va="center",
fontstyle="italic",
rotation=90,
)
ax.text(
mid_x,
y0 + 1 * (ch + gap) - gap / 2,
"realizacja \u2193",
fontsize=6,
ha="right",
va="center",
fontstyle="italic",
rotation=90,
)
# Note
ax.text(
50,
4,
"Warstwy czytamy z g\u00f3ry (biznes) na d\u00f3\u0142 (technologia).\n"
"Ni\u017csze warstwy REALIZUJ\u0104 wy\u017csze. "
"ArchiMate jest komplementarny z TOGAF.",
ha="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "archimate_layers.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK ArchiMate")
# =========================================================================
if __name__ == "__main__":
_logger.info(
@ -1070,9 +478,7 @@ if __name__ == "__main__":
generate_zachman()
generate_archimate()
_logger.info("All diagrams saved to %s/", OUTPUT_DIR)
for diagram_file in sorted(
p.name for p in Path(OUTPUT_DIR).iterdir()
):
for diagram_file in sorted(p.name for p in Path(OUTPUT_DIR).iterdir()):
if (
"togaf" in diagram_file
or "4plus1" in diagram_file
@ -1080,15 +486,9 @@ if __name__ == "__main__":
or "zachman" in diagram_file
or "archimate" in diagram_file
):
size_kb = (
Path(
str(
Path(OUTPUT_DIR).stat().st_size
/ diagram_file
)
)
/ 1024
)
size_kb = Path(str(Path(OUTPUT_DIR).stat().st_size / diagram_file)) / 1024
_logger.info(
" %s (%.0f KB)", diagram_file, size_kb,
" %s (%.0f KB)",
diagram_file,
size_kb,
)

View File

@ -77,9 +77,7 @@ def draw_node(
r = 0.35
lw = 2.5 if current else 1.5
ec = "#D32F2F" if current else ("#D32F2F" if error else LN)
fc = LIGHT_YELLOW if current else (
LIGHT_GREEN if visited else color
)
fc = LIGHT_YELLOW if current else (LIGHT_GREEN if visited else color)
if error:
fc = LIGHT_RED
@ -294,403 +292,14 @@ def draw_neg_graph(
)
def _add_annotation_box(
ax: Axes,
x: float,
y: float,
text: str,
*,
color: str,
bg_color: str,
) -> None:
"""Add a small annotation box near a node."""
ax.text(
x,
y,
text,
fontsize=FS_SMALL,
color=color,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": bg_color,
"edgecolor": color,
"alpha": 0.9,
"lw": 0.5,
},
)
def generate_bf_negative_weights() -> None:
"""Generate two-row figure.
Row 1: Graph structure + Dijkstra WRONG + Bellman-Ford CORRECT
Row 2: B-F iterations 1-3 step by step.
"""
fig = plt.figure(figsize=(14, 10))
fig.suptitle(
"Bellman-Ford \u2014 ujemne wagi vs Dijkstra\n"
"Graf: S\u2192A(2), A\u2192C(3),"
" S\u2192B(5), B\u2192A(-4). Start = S",
fontsize=FS_TITLE + 1,
fontweight="bold",
y=0.99,
)
# Row 1: Graph + Dijkstra wrong + BF correct
# Panel 1: The graph structure
ax1 = fig.add_subplot(2, 3, 1)
draw_neg_graph(
ax1,
NEG_EDGES,
title=(
"Graf z ujemną wagą\n"
"(B→A = -4, zaznaczona na czerwono)"
),
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
)
ax1.annotate(
"START",
xy=(NEG_POS["S"][0] - 0.35, NEG_POS["S"][1]),
xytext=(NEG_POS["S"][0] - 1.2, NEG_POS["S"][1]),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
arrowprops={
"arrowstyle": "->",
"color": "#D32F2F",
"lw": 2,
},
va="center",
)
# Panel 2: Dijkstra — WRONG
ax2 = fig.add_subplot(2, 3, 2)
draw_neg_graph(
ax2,
NEG_EDGES,
title=(
"Dijkstra \u2014 BŁĘDNY wynik\n"
"A zamknięty z d=2, nie poprawia przy B→A"
),
dist={"S": "0", "A": "2", "B": "5", "C": "5"},
visited={"S", "A", "B", "C"},
error_nodes={"A", "C"},
)
_add_annotation_box(
ax2,
NEG_POS["A"][0] + 0.6,
NEG_POS["A"][1] + 0.3,
"✗ powinno 1",
color="#D32F2F",
bg_color=LIGHT_RED,
)
_add_annotation_box(
ax2,
NEG_POS["C"][0] + 0.05,
NEG_POS["C"][1] + 0.55,
"✗ powinno 4",
color="#D32F2F",
bg_color=LIGHT_RED,
)
# Panel 3: Bellman-Ford — CORRECT
ax3 = fig.add_subplot(2, 3, 3)
draw_neg_graph(
ax3,
NEG_EDGES,
title=(
"Bellman-Ford \u2014 POPRAWNY wynik\n"
"Ujemna waga B→A poprawnie propagowana"
),
dist={"S": "0", "A": "1", "B": "5", "C": "4"},
visited={"S", "A", "B", "C"},
relaxed_edges={("B", "A")},
)
_add_annotation_box(
ax3,
NEG_POS["A"][0] + 0.6,
NEG_POS["A"][1] + 0.3,
"✓ poprawne!",
color="#006400",
bg_color=LIGHT_GREEN,
)
_add_annotation_box(
ax3,
NEG_POS["C"][0] + 0.05,
NEG_POS["C"][1] + 0.55,
"✓ poprawne!",
color="#006400",
bg_color=LIGHT_GREEN,
)
# Row 2: B-F iterations step by step
iterations = [
{
"title": (
"B-F Iteracja 1\n"
"Relaksuj WSZYSTKIE krawędzie"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "5",
},
"relaxed": {
("S", "A"), ("A", "C"),
("S", "B"), ("B", "A"),
},
"detail": (
"S→A: 0+2=2<∞ → A=2\n"
"A→C: 2+3=5<∞ → C=5\n"
"S→B: 0+5=5<∞ → B=5\n"
"B→A: 5-4=1<2 → A=1 ✓"
),
},
{
"title": (
"B-F Iteracja 2\n"
"Propagacja poprawionego A"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "4",
},
"relaxed": {("A", "C")},
"detail": (
"S→A: 0+2=2>1 ✗\n"
"A→C: 1+3=4<5 → C=4 ✓\n"
"S→B: 0+5=5=5 ✗\n"
"B→A: 5-4=1=1 ✗"
),
},
{
"title": (
"B-F Iteracja 3\n"
"Brak zmian → stabilne!"
),
"dist": {
"S": "0", "A": "1", "B": "5", "C": "4",
},
"relaxed": set(),
"detail": (
"Wszystkie krawędzie:\n"
"brak poprawy ✗\n"
"→ wynik stabilny\n"
"→ BRAK cyklu ujemnego"
),
},
]
for i, it in enumerate(iterations):
ax = fig.add_subplot(2, 3, i + 4)
draw_neg_graph(
ax,
NEG_EDGES,
title=it["title"],
dist=it["dist"],
visited={"S", "A", "B", "C"},
relaxed_edges=it["relaxed"],
)
ax.text(
3.2,
-0.5,
it["detail"],
ha="center",
va="top",
fontsize=FS_SMALL,
family="monospace",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
# Bottom note
fig.text(
0.5,
0.01,
"Dijkstra zamyka wierzchołki na stałe"
" (zachłanność) → ujemna waga B→A(-4)"
" nie może poprawić zamkniętego A.\n"
"Bellman-Ford relaksuje WSZYSTKIE krawędzie"
" w każdej iteracji → ujemne wagi"
" propagują się poprawnie.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_YELLOW,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.05, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_negative_weights.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ bellman_ford_negative_weights.png")
def generate_bf_negative_cycle() -> None:
"""Generate figure showing negative cycle detection.
Graph: S->A(2), A->C(3), S->B(5), B->A(-4), C->B(-3)
Cycle: B->A->C->B = -4+3+(-3) = -4 < 0.
"""
fig = plt.figure(figsize=(14, 5.5))
fig.suptitle(
"Bellman-Ford \u2014 wykrywanie cyklu ujemnego\n"
"Dodano krawędź C→B(-3)."
" Cykl: B→A→C→B = -4+3+(-3) = -4 < 0",
fontsize=FS_TITLE + 1,
fontweight="bold",
y=0.99,
)
# Panel 1: Graph with cycle highlighted
ax1 = fig.add_subplot(1, 3, 1)
draw_neg_graph(
ax1,
NEG_EDGES,
title=(
"Graf z cyklem ujemnym\n"
"Dodana krawędź C→B(-3) \u2014 przerywana"
),
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
extra_edges=[("C", "B", -3)],
)
ax1.annotate(
"CYKL\n-4+3+(-3)=-4<0",
xy=(3.3, 2.0),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
ha="center",
va="center",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
"alpha": 0.9,
},
)
# Panel 2: After V-1 iterations — still changing
ax2 = fig.add_subplot(1, 3, 2)
draw_neg_graph(
ax2,
NEG_EDGES,
title=(
"Po V-1=3 iteracjach\n"
"dist wciąż maleje (niestabilne!)"
),
dist={"S": "0", "A": "-7", "B": "-4", "C": "-4"},
visited={"S", "A", "B", "C"},
error_nodes={"A", "B", "C"},
extra_edges=[("C", "B", -3)],
)
ax2.text(
3.2,
-0.4,
"Każde okrążenie cyklu\n"
"zmniejsza dist o 4.\n"
"Dist → -∞ (brak minimum!)",
ha="center",
va="top",
fontsize=FS_SMALL,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
},
)
# Panel 3: V-th iteration detects
ax3 = fig.add_subplot(1, 3, 3)
ax3.axis("off")
ax3.set_xlim(0, 10)
ax3.set_ylim(0, 10)
detection_text = (
"V-ta iteracja (sprawdzenie):\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"for (src, dst, w) in edges:\n"
" if dist[src]+w < dist[dst]:\n"
" return None # CYKL!\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"Sprawdzamy np. krawędź B→A:\n"
" dist[B] + (-4) = -4 + (-4) = -8\n"
" -8 < dist[A] = -7\n"
" → NADAL SIĘ POPRAWIA!\n"
" → CYKL UJEMNY WYKRYTY!\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"Wynik: return None\n"
"(najkrótsza ścieżka nie istnieje)"
)
ax3.text(
5,
5,
detection_text,
ha="center",
va="center",
fontsize=FS + 0.5,
family="monospace",
bbox={
"boxstyle": "round,pad=0.6",
"facecolor": LIGHT_RED,
"edgecolor": "#D32F2F",
"lw": 2,
},
)
ax3.set_title(
"Wykrywanie \u2014 V-ta iteracja\n"
"Jeśli cokolwiek się poprawia → cykl ujemny!",
fontsize=FS,
fontweight="bold",
pad=5,
)
# Bottom note
fig.text(
0.5,
0.01,
"Bez cyklu ujemnego: po V-1 iteracjach"
" dist jest stabilne. "
"Z cyklem ujemnym: dist maleje"
" w nieskończoność"
" → V-ta iteracja to wykrywa.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_YELLOW,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.06, 1, 0.94])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_negative_cycle.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info(" ✓ bellman_ford_negative_cycle.png")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
_logger.info(
"Generating Bellman-Ford negative weight diagrams..."
from python_pkg.praca_magisterska_video.generate_images._bf_negative_diagrams import (
generate_bf_negative_cycle,
generate_bf_negative_weights,
)
logging.basicConfig(level=logging.INFO)
_logger.info("Generating B-F negative diagrams...")
generate_bf_negative_weights()
generate_bf_negative_cycle()
_logger.info("All diagrams saved to %s/", OUTPUT_DIR)
_logger.info("All B-F negative diagrams saved to %s/", OUTPUT_DIR)

View File

@ -253,964 +253,28 @@ def add_label(
)
# ============================================================
# DIAGRAM 1: 0NF Table
# ============================================================
def draw_0nf() -> None:
"""Draw 0nf."""
fig, ax = create_figure(11.69, 5.5)
headers = [
"StID",
"Imie",
"Telefony",
"KursID",
"NazwaKursu",
"Prowadzacy",
"WydzialID",
"NazwaWydzialu",
]
rows = [
[
"1",
"Anna",
"111-222, 333-444",
"K10",
"Bazy danych",
"Kowalski",
"W4",
"EiTI",
],
["1", "Anna", "111-222, 333-444", "K20", "Algorytmy", "Nowak", "W4", "EiTI"],
["2", "Jan", "555-666", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["3", "Ewa", "777-888", "K30", "Optyka", "Wisniewski", "W2", "Fizyka"],
]
col_widths = [0.5, 0.55, 1.55, 0.65, 1.1, 1.05, 0.85, 1.2]
# Highlight the non-atomic column
draw_table(
ax,
0.8,
4.5,
"0NF: Rejestr (forma nienormalna)",
headers,
rows,
col_widths,
highlight_cols={2}, # Telefony column
title_fontsize=11,
)
# Annotations
add_label(
ax,
0.8,
1.9,
'PROBLEM: Kolumna "Telefony" zawiera LISTY wartosci (nieatomowe).',
fontsize=9,
color="black",
)
add_label(
ax,
0.8,
1.55,
'Redundancja: "Anna", "W4", "EiTI", "Bazy danych" powtorzone wielokrotnie.',
fontsize=9,
color="black",
)
add_label(
ax,
0.8,
1.2,
(
"Zaleznosci funkcyjne: StID -> Imie, WydzialID"
" | WydzialID -> NazwaWydzialu"
),
fontsize=8,
color="#333333",
)
add_label(
ax,
0.8,
0.9,
(
" KursID -> NazwaKursu | (StID,KursID)"
" -> Prowadzacy | Prowadzacy -> KursID"
),
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_0nf_table.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_0nf_table.png")
# ============================================================
# DIAGRAM 2: 1NF — atomic values
# ============================================================
def draw_1nf() -> None:
"""Draw 1nf."""
fig, ax = create_figure(11.69, 6.0)
# Main table after removing Telefony
headers1 = [
"StID*",
"Imie",
"KursID*",
"NazwaKursu",
"Prowadzacy",
"WydzialID",
"NazwaWydzialu",
]
rows1 = [
["1", "Anna", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["1", "Anna", "K20", "Algorytmy", "Nowak", "W4", "EiTI"],
["2", "Jan", "K10", "Bazy danych", "Kowalski", "W4", "EiTI"],
["3", "Ewa", "K30", "Optyka", "Wisniewski", "W2", "Fizyka"],
]
cw1 = [0.55, 0.55, 0.7, 1.1, 1.05, 0.85, 1.2]
draw_table(
ax,
0.5,
5.2,
"1NF: Rejestr (klucz: StID, KursID)",
headers1,
rows1,
cw1,
title_fontsize=10,
)
# Telefony table
headers2 = ["StID*", "Telefon*"]
rows2 = [
["1", "111-222"],
["1", "333-444"],
["2", "555-666"],
["3", "777-888"],
]
cw2 = [0.55, 0.85]
draw_table(
ax,
7.5,
5.2,
"Telefony (klucz: StID, Telefon)",
headers2,
rows2,
cw2,
title_fontsize=10,
)
# Arrow
add_arrow(ax, 6.6, 4.3, 7.4, 4.3, "wydzielono", "#333333")
# Annotations
add_label(
ax,
0.5,
2.6,
'KROK: Nieatomowa kolumna "Telefony" wydzielona do osobnej tabeli.',
fontsize=9,
)
add_label(
ax,
0.5,
2.25,
"Kazda komorka zawiera JEDNA wartosc. Klucz glowny wyznaczony.",
fontsize=9,
)
add_label(
ax,
0.5,
1.85,
"PROBLEM 2NF: NazwaKursu zalezy TYLKO od KursID (czesc klucza).",
fontsize=9,
color="black",
)
add_label(
ax,
0.5,
1.5,
(
" Imie, WydzialID, NazwaWydzialu"
" zaleza TYLKO od StID (czesc klucza)."
),
fontsize=9,
color="black",
)
add_label(
ax,
0.5,
1.15,
" --> Czesciowe zaleznosci od klucza zlozonego = NARUSZENIE 2NF.",
fontsize=9,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_1nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_1nf_tables.png")
# ============================================================
# DIAGRAM 3: 2NF — no partial dependencies
# ============================================================
def draw_2nf() -> None:
"""Draw 2nf."""
fig, ax = create_figure(11.69, 6.5)
# Studenci
h1 = ["StID*", "Imie", "WydzialID", "NazwaWydzialu"]
r1 = [
["1", "Anna", "W4", "EiTI"],
["2", "Jan", "W4", "EiTI"],
["3", "Ewa", "W2", "Fizyka"],
]
cw1 = [0.55, 0.55, 0.85, 1.2]
draw_table(
ax,
0.3,
5.8,
"Studenci (kl: StID)",
h1,
r1,
cw1,
highlight_cols={2, 3},
title_fontsize=9,
)
# Kursy
h2 = ["KursID*", "NazwaKursu"]
r2 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw2 = [0.7, 1.1]
draw_table(ax, 4.0, 5.8, "Kursy (kl: KursID)", h2, r2, cw2, title_fontsize=9)
# Zapisy
h3 = ["StID*", "KursID*", "Prowadzacy"]
r3 = [
["1", "K10", "Kowalski"],
["1", "K20", "Nowak"],
["2", "K10", "Kowalski"],
["3", "K30", "Wisniewski"],
]
cw3 = [0.55, 0.7, 1.05]
draw_table(ax, 6.8, 5.8, "Zapisy (kl: StID, KursID)", h3, r3, cw3, title_fontsize=9)
# Telefony
h4 = ["StID*", "Telefon*"]
r4 = [["1", "111-222"], ["1", "333-444"], ["2", "555-666"], ["3", "777-888"]]
cw4 = [0.55, 0.85]
draw_table(ax, 9.5, 5.8, "Telefony", h4, r4, cw4, title_fontsize=9)
# Annotations
add_label(
ax,
0.3,
3.3,
(
"KROK: Rozbito czesc. zaleznosci"
" — atrybuty zalezne od czesci klucza wydzielone."
),
fontsize=9,
)
add_label(
ax,
0.3,
2.95,
" StID -> Imie, WydzialID, NazwaWydzialu ==> tabela Studenci",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.65,
" KursID -> NazwaKursu ==> tabela Kursy",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.3,
'PROBLEM 3NF w "Studenci": StID -> WydzialID -> NazwaWydzialu',
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.95,
" NazwaWydzialu zalezy od WydzialID (nie-klucz), nie bezposrednio od StID.",
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.6,
" --> Zaleznosc PRZECHODNIA = NARUSZENIE 3NF.",
fontsize=9,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_2nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_2nf_tables.png")
# ============================================================
# DIAGRAM 4: 3NF — no transitive dependencies
# ============================================================
def draw_3nf() -> None:
"""Draw 3nf."""
fig, ax = create_figure(11.69, 6.5)
# Student table after removing transitive dependency
h1 = ["StID*", "Imie", "WydzialID"]
r1 = [["1", "Anna", "W4"], ["2", "Jan", "W4"], ["3", "Ewa", "W2"]]
cw1 = [0.55, 0.55, 0.85]
draw_table(ax, 0.3, 5.8, "Studenci (kl: StID)", h1, r1, cw1, title_fontsize=9)
# Wydzialy (new!)
h2 = ["WydzialID*", "NazwaWydzialu"]
r2 = [["W4", "EiTI"], ["W2", "Fizyka"]]
cw2 = [0.85, 1.2]
draw_table(ax, 2.6, 5.8, "Wydzialy (kl: WydzialID)", h2, r2, cw2, title_fontsize=9)
# Kursy
h3 = ["KursID*", "NazwaKursu"]
r3 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw3 = [0.7, 1.1]
draw_table(ax, 5.2, 5.8, "Kursy (kl: KursID)", h3, r3, cw3, title_fontsize=9)
# Zapisy (highlight BCNF violation)
h4 = ["StID*", "KursID*", "Prowadzacy"]
r4 = [
["1", "K10", "Kowalski"],
["1", "K20", "Nowak"],
["2", "K10", "Kowalski"],
["3", "K30", "Wisniewski"],
]
cw4 = [0.55, 0.7, 1.05]
draw_table(
ax,
7.8,
5.8,
"Zapisy (kl: StID, KursID)",
h4,
r4,
cw4,
highlight_cols={1, 2},
title_fontsize=9,
)
# Annotations
add_label(
ax,
0.3,
3.3,
"KROK: Rozdzielono Studenci -> Studenci + Wydzialy (usun. zal. przechodnia).",
fontsize=9,
)
add_label(
ax,
0.3,
2.95,
" StID -> WydzialID -> NazwaWydzialu"
" rozbito: NazwaWydzialu w osobnej tabeli.",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.55,
'PROBLEM BCNF w "Zapisy": FD: Prowadzacy -> KursID (1 prowadzacy = 1 kurs)',
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
2.2,
" Prowadzacy NIE jest nadkluczem tabeli Zapisy -> NARUSZENIE BCNF.",
fontsize=9,
color="black",
)
add_label(
ax,
0.3,
1.85,
" 3NF OK, bo KursID jest atrybutem pierwszym (prime) -> wyjatek 3NF.",
fontsize=9,
color="#333333",
)
add_label(
ax,
0.3,
1.5,
" BCNF nie ma takiego wyjatku"
" -> kazda nietrywialna FD wymaga nadklucza po lewej.",
fontsize=9,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_3nf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_3nf_tables.png")
# ============================================================
# DIAGRAM 5: BCNF — every determinant is a superkey
# ============================================================
def draw_bcnf() -> None:
"""Draw bcnf."""
fig, ax = create_figure(11.69, 7.5)
# Studenci
h1 = ["StID*", "Imie", "WydzialID"]
r1 = [["1", "Anna", "W4"], ["2", "Jan", "W4"], ["3", "Ewa", "W2"]]
cw1 = [0.55, 0.55, 0.85]
draw_table(ax, 0.3, 6.8, "Studenci", h1, r1, cw1, title_fontsize=9)
# Wydzialy
h2 = ["WydzialID*", "NazwaWydz."]
r2 = [["W4", "EiTI"], ["W2", "Fizyka"]]
cw2 = [0.85, 1.0]
draw_table(ax, 2.5, 6.8, "Wydzialy", h2, r2, cw2, title_fontsize=9)
# Kursy
h3 = ["KursID*", "NazwaKursu"]
r3 = [["K10", "Bazy danych"], ["K20", "Algorytmy"], ["K30", "Optyka"]]
cw3 = [0.7, 1.1]
draw_table(ax, 4.8, 6.8, "Kursy", h3, r3, cw3, title_fontsize=9)
# ProwadzacyKurs (NEW - from BCNF decomposition)
h4 = ["Prowadzacy*", "KursID"]
r4 = [["Kowalski", "K10"], ["Nowak", "K20"], ["Wisniewski", "K30"]]
cw4 = [1.05, 0.7]
draw_table(
ax, 7.2, 6.8, "ProwadzacyKurs (kl: Prow.)", h4, r4, cw4, title_fontsize=9
)
# New student-advisor junction table
h5 = ["StID*", "Prowadzacy*"]
r5 = [["1", "Kowalski"], ["1", "Nowak"], ["2", "Kowalski"], ["3", "Wisniewski"]]
cw5 = [0.55, 1.05]
draw_table(ax, 9.5, 6.8, "StudentProw. (kl: oba)", h5, r5, cw5, title_fontsize=9)
# Telefony
h6 = ["StID*", "Telefon*"]
r6 = [["1", "111-222"], ["1", "333-444"], ["2", "555-666"], ["3", "777-888"]]
cw6 = [0.55, 0.85]
draw_table(ax, 0.3, 4.6, "Telefony", h6, r6, cw6, title_fontsize=9)
# Annotations
add_label(
ax, 0.3, 2.9, "KROK: Zapisy(StID, KursID, Prowadzacy) rozbite na:", fontsize=9
)
add_label(
ax,
0.3,
2.55,
" ProwadzacyKurs(Prowadzacy, KursID)"
" — FD: Prowadzacy -> KursID, klucz: Prowadzacy",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
2.25,
" StudentProwadzacy(StID, Prowadzacy) — ktory student u ktorego prowadzacego",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
1.85,
"Teraz KAZDA nietrywialna FD ma nadklucz po lewej stronie -> BCNF spelnione.",
fontsize=9,
)
add_label(
ax,
0.3,
1.45,
"Rekonstrukcja: StudentProw. JOIN ProwadzacyKurs"
" ON Prowadzacy -> odtworzenie Zapisy.",
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_bcnf_tables.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_bcnf_tables.png")
# ============================================================
# DIAGRAM 6: 4NF example — multi-valued dependencies
# ============================================================
def draw_4nf() -> None:
"""Draw 4nf."""
fig, ax = create_figure(11.69, 7.5)
# Before: table with MVD violation
h_before = ["StID*", "Hobby*", "Umiejetnosc*"]
r_before = [
["1", "Szachy", "Python"],
["1", "Szachy", "SQL"],
["1", "Bieganie", "Python"],
["1", "Bieganie", "SQL"],
["2", "Plywanie", "Java"],
]
cw_before = [0.55, 0.9, 1.0]
draw_table(
ax,
0.5,
6.8,
"PRZED: StudentAktywnosci (klucz: StID, Hobby, Umiejetnosc)",
h_before,
r_before,
cw_before,
highlight_cols={1, 2},
title_fontsize=10,
)
# Arrows
add_label(ax, 3.5, 6.3, "StID ->> Hobby", fontsize=9, color="black")
add_label(ax, 3.5, 6.0, "StID ->> Umiejetnosc", fontsize=9, color="black")
add_label(ax, 3.5, 5.6, "NIEZALEZNE MVD w jednej tabeli", fontsize=9, color="black")
add_label(
ax,
3.5,
5.2,
"= iloczyn kartezjanski = NARUSZENIE 4NF",
fontsize=9,
color="black",
)
# After: two tables
add_arrow(ax, 3.0, 4.2, 3.0, 3.7, "", "#333333")
add_label(ax, 3.2, 3.95, "dekompozycja", fontsize=8, color="#333333")
h_hobby = ["StID*", "Hobby*"]
r_hobby = [["1", "Szachy"], ["1", "Bieganie"], ["2", "Plywanie"]]
cw_hobby = [0.55, 0.9]
draw_table(
ax, 0.5, 3.5, "PO: StudentHobby", h_hobby, r_hobby, cw_hobby, title_fontsize=10
)
h_skill = ["StID*", "Umiejetnosc*"]
r_skill = [["1", "Python"], ["1", "SQL"], ["2", "Java"]]
cw_skill = [0.55, 1.0]
draw_table(
ax,
3.5,
3.5,
"PO: StudentUmiejetnosc",
h_skill,
r_skill,
cw_skill,
title_fontsize=10,
)
# Summary on the right side
add_label(ax, 6.5, 6.5, "4NF: BCNF + brak nietrywialnych MVD", fontsize=10)
add_label(
ax, 6.5, 6.1, "MVD X ->> Y: jeden X = ZBIOR Y-ow,", fontsize=8, color="#333333"
)
add_label(
ax, 6.5, 5.8, "niezaleznie od reszty kolumn.", fontsize=8, color="#333333"
)
add_label(
ax, 6.5, 5.35, "Naruszenie: Student 1 ma 2 hobby i 2 umiejetnosci", fontsize=8
)
add_label(
ax, 6.5, 5.05, " -> 2 x 2 = 4 wiersze (iloczyn kartezjanski!)", fontsize=8
)
add_label(
ax, 6.5, 4.65, "Naprawa: rozdziel niezalezne MVD do osobnych tabel.", fontsize=8
)
add_label(
ax,
6.5,
4.25,
"Po dekompozycji: 3 + 3 = 6 wierszy zamiast 5 z ilocz.",
fontsize=8,
color="#333333",
)
add_label(
ax, 6.5, 3.85, " (ale BEZ sztucznych kombinacji!)", fontsize=8, color="#333333"
)
# Key insight box
rect = mpatches.FancyBboxPatch(
(6.3, 2.5),
5.0,
1.0,
boxstyle="round,pad=0.1",
facecolor="#F0F0F0",
edgecolor="black",
linewidth=1.0,
)
ax.add_patch(rect)
add_label(ax, 6.5, 3.2, "ROZNICA 4NF vs BCNF:", fontsize=9)
add_label(
ax,
6.5,
2.85,
"BCNF dotyczy FD (X -> Y, jedna wartosc)",
fontsize=8,
color="#333333",
)
add_label(
ax,
6.5,
2.55,
"4NF dotyczy MVD (X ->> Y, zbior wartosci)",
fontsize=8,
color="#333333",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_4nf_example.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_4nf_example.png")
# ============================================================
# DIAGRAM 7: 5NF example — join dependencies
# ============================================================
def draw_5nf() -> None:
"""Draw 5nf."""
fig, ax = create_figure(11.69, 8.5)
# Before: ternary table
h_before = ["Dostawca*", "Czesc*", "Projekt*"]
r_before = [
["Alfa", "Sruba", "Most"],
["Alfa", "Sruba", "Wiezowiec"],
["Alfa", "Nakretka", "Most"],
["Beta", "Sruba", "Wiezowiec"],
["Beta", "Nakretka", "Wiezowiec"],
]
cw_before = [0.9, 0.9, 1.0]
draw_table(
ax,
0.5,
7.8,
"PRZED: Dostawy (klucz: Dostawca, Czesc, Projekt)",
h_before,
r_before,
cw_before,
title_fontsize=10,
)
add_label(ax, 3.8, 7.3, "Tabela w 4NF (brak nietrywialnych MVD),", fontsize=8)
add_label(
ax, 3.8, 7.0, "ale NIE w 5NF jesli zachodzi regula cykliczna:", fontsize=8
)
add_label(
ax, 3.8, 6.55, "Jesli Dostawca dostarcza Czesc", fontsize=8, color="#333333"
)
add_label(
ax, 3.8, 6.25, " I Dostawca dostarcza do Projektu", fontsize=8, color="#333333"
)
add_label(
ax, 3.8, 5.95, " I Czesc jest uzywana w Projekcie", fontsize=8, color="#333333"
)
add_label(
ax,
3.8,
5.65,
" ==> Dostawca dostarcza te Czesc do tego Projektu.",
fontsize=8,
color="black",
)
# Arrow down
add_arrow(ax, 1.8, 5.1, 1.8, 4.6, "dekompozycja 5NF", "#333333")
# After: three binary tables
h1 = ["Dostawca*", "Czesc*"]
r1 = [
["Alfa", "Sruba"],
["Alfa", "Nakretka"],
["Beta", "Sruba"],
["Beta", "Nakretka"],
]
cw1 = [0.9, 0.9]
draw_table(ax, 0.3, 4.3, "DostawcaCzesc", h1, r1, cw1, title_fontsize=9)
h2 = ["Dostawca*", "Projekt*"]
r2 = [["Alfa", "Most"], ["Alfa", "Wiezowiec"], ["Beta", "Wiezowiec"]]
cw2 = [0.9, 1.0]
draw_table(ax, 3.0, 4.3, "DostawcaProjekt", h2, r2, cw2, title_fontsize=9)
h3 = ["Czesc*", "Projekt*"]
r3 = [
["Sruba", "Most"],
["Sruba", "Wiezowiec"],
["Nakretka", "Most"],
["Nakretka", "Wiezowiec"],
]
cw3 = [0.9, 1.0]
draw_table(ax, 5.7, 4.3, "CzescProjekt", h3, r3, cw3, title_fontsize=9)
# Join reconstruction note
rect = mpatches.FancyBboxPatch(
(8.3, 3.5),
3.0,
4.0,
boxstyle="round,pad=0.1",
facecolor="#F0F0F0",
edgecolor="black",
linewidth=1.0,
)
ax.add_patch(rect)
add_label(ax, 8.5, 7.2, "5NF (PJNF):", fontsize=10)
add_label(ax, 8.5, 6.8, "Project-Join NF", fontsize=8, color="#333333")
add_label(ax, 8.5, 6.35, "Kazda zaleznosc", fontsize=8)
add_label(ax, 8.5, 6.05, "zlaczenia (JD)", fontsize=8)
add_label(ax, 8.5, 5.75, "implikowana przez", fontsize=8)
add_label(ax, 8.5, 5.45, "klucze kandydujace.", fontsize=8)
add_label(ax, 8.5, 4.9, "Rekonstrukcja:", fontsize=9)
add_label(ax, 8.5, 4.55, "DC JOIN DP JOIN CP", fontsize=8, color="#333333")
add_label(ax, 8.5, 4.2, "= oryginalna tabela", fontsize=8, color="#333333")
add_label(ax, 8.5, 3.75, "(bezstratnie!)", fontsize=8, color="#333333")
# Verification example at the bottom
add_label(
ax,
0.3,
2.0,
"Weryfikacja: Alfa dostarcza Nakretke?"
" Alfa -> Wiezowiec? Nakretka -> Wiezowiec?",
fontsize=8,
)
add_label(
ax,
0.3,
1.65,
" TAK, TAK, TAK --> wg reguly cyklicznej:"
" Alfa dostarcza Nakretke do Wiezowca.",
fontsize=8,
color="#333333",
)
add_label(
ax,
0.3,
1.25,
"Ale: Alfa dostarcza Nakretke? TAK. Alfa -> Most? TAK. Nakretka -> Most? TAK.",
fontsize=8,
)
add_label(
ax,
0.3,
0.9,
" --> Alfa dostarcza Nakretke do Mostu."
" (Tego wiersza NIE MA w oryginale -- BLAD!)",
fontsize=8,
color="black",
)
add_label(
ax,
0.3,
0.5,
" Dekompozycja 5NF jest poprawna TYLKO"
" jesli regula cykliczna rzeczywiscie zachodzi!",
fontsize=8,
color="black",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_5nf_example.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_5nf_example.png")
# ============================================================
# DIAGRAM 8: Full normalization summary flowchart
# ============================================================
def draw_summary_flow() -> None:
"""Draw summary flow."""
fig, ax = create_figure(11.69, 6.0)
# Boxes for each NF
box_y = 4.5
box_h = 1.8
box_w = 1.4
gap = 0.25
nf_data = [
("0NF", "Nienormalna", "Listy w\nkomorkach,\nbrak klucza"),
("1NF", "Atomowosc", "Kazda komorka\n= 1 wartosc,\njest klucz"),
("2NF", "Pelny klucz", "Brak czesciowej\nzaleznosci od\nklucza zlozonego"),
("3NF", "Tylko klucz", "Brak zaleznosci\nprzechodniej\nA->B->C"),
("BCNF", "Nadklucz", "Lewa strona\nkazdej FD\n= nadklucz"),
("4NF", "Brak MVD", "Brak nietryw.\nwielowart.\nzaleznosci"),
("5NF", "Brak JD", "Kazda zal.\nzlaczenia\nimpl. kluczem"),
]
for i, (name, subtitle, desc) in enumerate(nf_data):
x = 0.3 + i * (box_w + gap)
# Main box
rect = mpatches.FancyBboxPatch(
(x, box_y - box_h),
box_w,
box_h,
boxstyle="round,pad=0.05",
facecolor="#F5F5F5" if i == 0 else "#FFFFFF",
edgecolor="black",
linewidth=1.2,
)
ax.add_patch(rect)
# NF name
ax.text(
x + box_w / 2,
box_y - 0.15,
name,
fontsize=12,
fontweight="bold",
ha="center",
va="top",
family="monospace",
)
# Subtitle
ax.text(
x + box_w / 2,
box_y - 0.45,
subtitle,
fontsize=7,
ha="center",
va="top",
family="monospace",
color="#333333",
)
# Description
ax.text(
x + box_w / 2,
box_y - 0.75,
desc,
fontsize=6.5,
ha="center",
va="top",
family="monospace",
color="#555555",
linespacing=1.3,
)
# Arrow to next
if i < len(nf_data) - 1:
ax.annotate(
"",
xy=(x + box_w + 0.02, box_y - box_h / 2),
xytext=(x + box_w + gap - 0.02, box_y - box_h / 2),
arrowprops={"arrowstyle": "<-", "color": "black", "lw": 1.5},
)
# Mnemonic quote at the bottom
ax.text(
5.85,
2.2,
'"Klucz, caly klucz i tylko klucz -- tak mi dopomoz Codd"',
fontsize=11,
ha="center",
va="center",
family="monospace",
style="italic",
)
ax.text(
5.85,
1.8,
"1NF: klucz istnieje | 2NF: caly klucz | 3NF: tylko klucz",
fontsize=9,
ha="center",
va="center",
family="monospace",
color="#333333",
)
ax.text(
5.85,
1.4,
"BCNF: kazdy determinant = nadklucz | 4NF: +brak MVD | 5NF: +brak JD",
fontsize=9,
ha="center",
va="center",
family="monospace",
color="#333333",
)
# Hierarchy
ax.text(
5.85,
0.8,
"5NF (zawiera sie w) 4NF (zaw.) BCNF"
" (zaw.) 3NF (zaw.) 2NF (zaw.) 1NF",
fontsize=8,
ha="center",
va="center",
family="monospace",
color="#555555",
)
fig.savefig(
str(Path(OUTPUT_DIR) / "nf_summary_flow.png"),
bbox_inches="tight",
facecolor="white",
pad_inches=0.2,
)
plt.close(fig)
logger.info("Generated: nf_summary_flow.png")
# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
logger.info("Generating normalization diagrams...")
from python_pkg.praca_magisterska_video.generate_images._norm_advanced import (
draw_3nf,
draw_4nf,
draw_bcnf,
)
from python_pkg.praca_magisterska_video.generate_images._norm_basic import (
draw_0nf,
draw_1nf,
draw_2nf,
)
from python_pkg.praca_magisterska_video.generate_images._norm_higher import (
draw_5nf,
draw_summary_flow,
)
draw_0nf()
draw_1nf()
draw_2nf()

View File

@ -20,8 +20,6 @@ from pathlib import Path
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
@ -74,9 +72,7 @@ def draw_box(
facecolor=fill,
)
else:
rect = mpatches.Rectangle(
(x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill
)
rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill)
ax.add_patch(rect)
ax.text(
x + w / 2,
@ -113,949 +109,26 @@ def draw_arrow(
# ============================================================
# 1. Pattern Template Structure (NaPSiRoKo mnemonic)
# ============================================================
def generate_pattern_template() -> None:
"""Generate pattern template diagram with NaPSiRoKo mnemonic."""
fig, ax = plt.subplots(figsize=(8.27, 6))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Szablon opisu wzorca \u2014 \u201eNaPSiRoKo\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Main card outline
card_x, card_y, card_w, card_h = 1.5, 0.5, 7, 7
card = FancyBboxPatch(
(card_x, card_y),
card_w,
card_h,
boxstyle="round,pad=0.15",
lw=2.5,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(card)
# Title of card
ax.text(
card_x + card_w / 2,
card_y + card_h - 0.35,
"KARTA WZORCA",
ha="center",
va="center",
fontsize=FS_TITLE,
fontweight="bold",
)
# Fields as horizontal bands
fields = [
("Na", "NAZWA", "Layered, Observer, Microservices", GRAY1),
(
"P",
"PROBLEM / KONTEKST",
"Kiedy stosować? Jaki problem rozwiązuje?",
"white",
),
(
"Si",
"SIŁY (forces)",
"Konkurencyjne wymagania do pogodzenia\n"
"(np. testowalność vs wydajność)",
GRAY1,
),
("Ro", "ROZWIĄZANIE", "Struktura, diagram, zachowanie", "white"),
(
"Ko",
"KONSEKWENCJE",
"Tradeoffs: co zyskujemy, co tracimy",
GRAY1,
),
]
band_x = card_x + 0.3
band_w = card_w - 0.6
band_h = 1.05
start_y = card_y + card_h - 1.1
for i, (abbr, title, desc, fill) in enumerate(fields):
by = start_y - i * (band_h + 0.15)
# Abbreviation circle on the left
circle = plt.Circle(
(band_x + 0.35, by + band_h / 2),
0.28,
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(circle)
ax.text(
band_x + 0.35,
by + band_h / 2,
abbr,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
# Field box
fx = band_x + 0.8
fw = band_w - 0.8
rect = FancyBboxPatch(
(fx, by),
fw,
band_h,
boxstyle="round,pad=0.06",
lw=1,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
fx + 0.15,
by + band_h - 0.25,
title,
ha="left",
va="center",
fontsize=FS,
fontweight="bold",
)
ax.text(
fx + 0.15,
by + 0.25,
desc,
ha="left",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#444444",
)
# Arrow connecting fields
if i < len(fields) - 1:
draw_arrow(
ax,
band_x + 0.35,
by - 0.02,
band_x + 0.35,
by - 0.13,
lw=1.0,
)
# Extra fields note at bottom
ax.text(
card_x + card_w / 2,
card_y + 0.25,
"+ Powiązane wzorce • Znane zastosowania • Warianty",
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
# Mnemonic reminder on the right
ax.text(
9.8,
4,
"Mnemonik:\nNaPSiRoKo",
ha="center",
va="center",
fontsize=10,
fontweight="bold",
rotation=90,
color="#666666",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_pattern_template.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 2. Catalog Classification Map
# ============================================================
def generate_catalog_map() -> None:
"""Generate catalog classification map diagram."""
fig, ax = plt.subplots(figsize=(8.27, 7))
ax.set_xlim(0, 12)
ax.set_ylim(0, 9)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Mapa katalog\u00f3w wzorc\u00f3w \u2014"
" \u201ePawe\u0142 Gra\u0142 Efektownie"
" Pod Chmurami\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Y-axis: Scale (architectural -> design -> idiom)
ax.text(
0.3,
7.8,
"SKALA",
fontsize=10,
fontweight="bold",
ha="center",
va="center",
rotation=90,
)
ax.annotate(
"",
xy=(0.3, 2.0),
xytext=(0.3, 7.5),
arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
)
scale_labels = [
(7.0, "Architektoniczny\n(cały system)"),
(5.0, "Projektowy\n(klasa/obiekt)"),
(3.0, "Idiomatyczny\n(linia kodu)"),
]
for sy, label in scale_labels:
ax.text(
1.0,
sy,
label,
fontsize=FS_SMALL,
ha="left",
va="center",
fontstyle="italic",
)
ax.plot(
[0.15, 0.45], [sy, sy], color=GRAY3, lw=0.8, ls="--"
)
# X-axis: Domain
ax.text(
6.5,
1.2,
"DOMENA ZASTOSOWANIA",
fontsize=10,
fontweight="bold",
ha="center",
va="center",
)
ax.annotate(
"",
xy=(11.5, 1.5),
xytext=(2.0, 1.5),
arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
)
# Catalog boxes positioned by scale and domain
catalogs = [
(
2.5,
6.2,
2.5,
1.4,
"POSA",
"1996 • Buschmann\nLayers, Broker,\n"
"Pipes & Filters, MVC",
GRAY1,
"P",
),
(
2.5,
4.2,
2.5,
1.4,
"GoF",
"1994 • Gamma et al.\n23 wzorce:\n"
"5 kreac. / 7 strukt. / 11 behaw.",
GRAY2,
"G",
),
(
5.5,
6.2,
2.5,
1.4,
"EIP",
"2003 • Hohpe & Woolf\nMessage Channel,\n"
"Router, Aggregator",
GRAY1,
"E",
),
(
5.5,
4.2,
2.5,
1.4,
"PoEAA",
"2002 • M. Fowler\nRepository,"
" Unit of Work,\nDomain Model",
"white",
"P",
),
(
8.5,
6.2,
2.8,
1.4,
"Cloud\nPatterns",
"~2015 • Azure/AWS\nCircuit Breaker,\n"
"Saga, Sidecar",
GRAY1,
"C",
),
]
for cx, cy, cw, ch, name, sub, fill, ml in catalogs:
rect = FancyBboxPatch(
(cx, cy),
cw,
ch,
boxstyle="round,pad=0.1",
lw=1.5,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
cx + cw / 2,
cy + ch - 0.3,
name,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
ax.text(
cx + cw / 2,
cy + 0.4,
sub,
ha="center",
va="center",
fontsize=FS_SMALL,
linespacing=1.3,
)
# Mnemonic letter in corner
circle = plt.Circle(
(cx + 0.25, cy + ch - 0.25),
0.2,
lw=1,
edgecolor=LN,
facecolor=GRAY5,
)
ax.add_patch(circle)
ax.text(
cx + 0.25,
cy + ch - 0.25,
ml,
ha="center",
va="center",
fontsize=8,
fontweight="bold",
)
# Mnemonic bar at bottom
mnem_y = 2.2
ax.text(
6.0,
mnem_y,
"PGEP+C → Paweł Grał Efektownie Pod Chmurami",
ha="center",
va="center",
fontsize=10,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 1.5,
},
)
# Domain labels along x-axis
domains = [
(3.75, 1.7, "Architektura"),
(6.75, 1.7, "Integracja / Enterprise"),
(9.9, 1.7, "Chmura"),
]
for dx, dy, dlabel in domains:
ax.text(
dx,
dy,
dlabel,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_catalog_map.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 3. Three Pillars of Cataloguing
# ============================================================
def generate_three_pillars() -> None:
"""Generate three pillars of cataloguing diagram."""
fig, ax = plt.subplots(figsize=(8.27, 5.5))
ax.set_xlim(0, 12)
ax.set_ylim(0, 7)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Jak są katalogowane wzorce? — Trzy filary",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Roof / banner
roof_pts = np.array([[1, 5.5], [6, 6.8], [11, 5.5]])
roof = plt.Polygon(
roof_pts,
closed=True,
lw=2,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(roof)
ax.text(
6,
6.0,
"KATALOGOWANIE WZORCÓW",
ha="center",
va="center",
fontsize=11,
fontweight="bold",
)
# Three pillars
pillars = [
(
1.3,
"1. SZABLON\nOPISU",
"Każdy wzorzec ma\nte same pola:\n"
"Nazwa → Problem\n→ Siły → Rozwiązanie\n"
"→ Konsekwencje",
"Analogia:\nformatka\nencyklopedii",
),
(
4.8,
"2. KLASYFIKACJA\nWIELOOSIOWA",
"Osie podziału:\n"
"• Skala (arch/proj/idiom)\n"
"• Domena problemu\n"
"• Atrybut jakościowy\n"
"• Domena zastosowania",
"Analogia:\nkategorie\nw bibliotece",
),
(
8.3,
"3. JĘZYK\nWZORCÓW",
"Wzorce referują się\nwzajemnie tworząc\n"
"sieć/graf:\nA → wymaga → B\n"
"B → wariant → C",
"Analogia:\n\u201ezobacz te\u017c\u201d\n"
"w encyklopedii",
),
]
for px, title, desc, analogy in pillars:
pw, ph = 2.8, 5.0
py = 0.5
# Pillar rectangle
rect = FancyBboxPatch(
(px, py),
pw,
ph,
boxstyle="round,pad=0.1",
lw=1.8,
edgecolor=LN,
facecolor="white",
)
ax.add_patch(rect)
# Title
ax.text(
px + pw / 2,
py + ph - 0.55,
title,
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
# Horizontal line under title
ax.plot(
[px + 0.2, px + pw - 0.2],
[py + ph - 1.0, py + ph - 1.0],
color=LN,
lw=0.8,
)
# Description
ax.text(
px + pw / 2,
py + ph / 2 - 0.3,
desc,
ha="center",
va="center",
fontsize=FS_SMALL,
linespacing=1.4,
)
# Analogy box at bottom
analogy_rect = FancyBboxPatch(
(px + 0.2, py + 0.15),
pw - 0.4,
1.0,
boxstyle="round,pad=0.06",
lw=0.8,
edgecolor=GRAY3,
facecolor=GRAY1,
)
ax.add_patch(analogy_rect)
ax.text(
px + pw / 2,
py + 0.65,
analogy,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#555555",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_three_pillars.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 4. Filled-in Observer Pattern Card
# ============================================================
def _get_observer_band_height(index: int) -> float:
"""Return band height for the given field index."""
return _BAND_HEIGHTS[index]
def generate_observer_card_filled() -> None:
"""Generate filled-in Observer pattern card diagram."""
fig, ax = plt.subplots(figsize=(8.27, 8.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Wypełniona karta wzorca — Observer (GoF)",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Main card outline
card_x, card_y, card_w, card_h = 0.8, 0.3, 8.4, 9.2
card = FancyBboxPatch(
(card_x, card_y),
card_w,
card_h,
boxstyle="round,pad=0.15",
lw=2.5,
edgecolor=LN,
facecolor=GRAY4,
)
ax.add_patch(card)
# Fields with actual Observer content
fields = [
("Na", "NAZWA", "Observer", GRAY2, True),
(
"P",
"PROBLEM",
"Obiekt (Subject) zmienia stan → wielu"
" zależnych\n"
"obiektów musi zareagować, ale Subject nie\n"
"powinien znać ich konkretnych typów.",
GRAY1,
False,
),
(
"Si",
"SIŁY",
"• loose coupling (nie znać obserwatorów"
" z nazwy)\n"
" vs koszt powiadomień"
" (N obserwatorów = N wywołań)\n"
"• otwartość na rozszerzenia"
" vs złożoność debugowania",
"white",
False,
),
(
"Ro",
"ROZWIĄZANIE",
"Subject przechowuje listę Observer.\n"
"Metody: attach(o), detach(o), notify().\n"
"notify() iteruje po liście i woła update()\n"
"na każdym obserwatorze.",
GRAY1,
False,
),
(
"Ko",
"KONSEKWENCJE",
"(+) Luźne wiązanie — Subject ↔ Observer\n"
"(+) Nowi obserwatorzy bez zmian w Subject\n"
"(-) Kaskada powiadomień może być kosztowna\n"
"(-) Memory leaks jeśli nie detach()",
"white",
False,
),
]
band_x = card_x + 0.3
band_w = card_w - 0.6
start_y = card_y + card_h - 0.65
for i, (abbr, title, content, fill, is_title_field) in enumerate(
fields
):
band_h = _get_observer_band_height(i)
by = start_y - sum(
_get_observer_band_height(j) + 0.15 for j in range(i)
)
# Abbreviation circle
circle = plt.Circle(
(band_x + 0.35, by + band_h / 2),
0.28,
lw=1.5,
edgecolor=LN,
facecolor=GRAY3,
)
ax.add_patch(circle)
ax.text(
band_x + 0.35,
by + band_h / 2,
abbr,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
# Field box
fx = band_x + 0.8
fw = band_w - 0.8
rect = FancyBboxPatch(
(fx, by),
fw,
band_h,
boxstyle="round,pad=0.06",
lw=1,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
if is_title_field:
ax.text(
fx + fw / 2,
by + band_h / 2,
f"{title}: {content}",
ha="center",
va="center",
fontsize=12,
fontweight="bold",
)
else:
ax.text(
fx + 0.15,
by + band_h - 0.2,
title,
ha="left",
va="center",
fontsize=FS,
fontweight="bold",
)
ax.text(
fx + 0.15,
by + band_h / 2 - 0.15,
content,
ha="left",
va="center",
fontsize=FS_SMALL,
family="monospace",
linespacing=1.3,
)
# Arrow
if i < len(fields) - 1:
draw_arrow(
ax,
band_x + 0.35,
by - 0.02,
band_x + 0.35,
by - 0.13,
lw=1.0,
)
# Extra info at bottom
extra_y = 0.55
extras = [
"Powiązane: Mediator (centralizuje),"
" Pub/Sub (rozproszony),"
" MVC (View = Observer)",
"Znane użycia: Java Swing listeners,"
" C# event/delegate,"
" React useState, DOM addEventListener",
]
for j, txt in enumerate(extras):
ax.text(
card_x + card_w / 2,
extra_y + (1 - j) * 0.25,
txt,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
color="#444444",
)
fig.tight_layout()
out = str(Path(OUTPUT_DIR) / "q14_observer_card_filled.png")
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# 5. Pattern Language Navigation Graph
# ============================================================
def generate_pattern_language_navigation() -> None:
"""Generate pattern language navigation graph diagram."""
fig, ax = plt.subplots(figsize=(8.27, 9))
ax.set_xlim(0, 12)
ax.set_ylim(0, 12)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG)
ax.set_title(
"Język wzorców \u2014 nawigacja"
" \u201eproblem \u2192 wzorzec"
" \u2192 nowy problem\u201d",
fontsize=FS_TITLE,
fontweight="bold",
pad=15,
)
# Node positions: (x, y, label, is_pattern, fill)
nodes = [
(1.5, 10.5, "Monolith\nnie skaluje się", False, "white"),
(
1.5, 8.2,
"Jak routować\nżądania do\nserwisów?",
False, "white",
),
(
1.5, 5.9,
"Co gdy serwis\nnie odpowiada?",
False, "white",
),
(
1.5, 3.6,
"Jak zachować\nspójność\ntransakcji?",
False, "white",
),
(
1.5, 1.3,
"Jak odnaleźć\nadres serwisu?",
False, "white",
),
(7.0, 9.3, "Microservices", True, GRAY2),
(7.0, 7.0, "API Gateway", True, GRAY2),
(7.0, 4.7, "Circuit Breaker", True, GRAY2),
(7.0, 2.4, "Saga", True, GRAY2),
(10.0, 5.9, "Service\nDiscovery", True, GRAY1),
]
# Draw nodes
node_w_prob = 2.8
node_h_prob = 1.3
node_w_pat = 2.5
node_h_pat = 1.0
for nx, ny, label, is_pattern, fill in nodes:
if is_pattern:
w, h = node_w_pat, node_h_pat
rect = FancyBboxPatch(
(nx - w / 2, ny - h / 2),
w,
h,
boxstyle="round,pad=0.1",
lw=2,
edgecolor=LN,
facecolor=fill,
)
ax.add_patch(rect)
ax.text(
nx,
ny,
label,
ha="center",
va="center",
fontsize=10,
fontweight="bold",
)
else:
w, h = node_w_prob, node_h_prob
rect = FancyBboxPatch(
(nx - w / 2, ny - h / 2),
w,
h,
boxstyle="round,pad=0.1",
lw=1.2,
edgecolor=LN,
facecolor=fill,
linestyle="--",
)
ax.add_patch(rect)
ax.text(
nx,
ny,
label,
ha="center",
va="center",
fontsize=FS_SMALL,
fontstyle="italic",
)
# Arrows: problem -> pattern, pattern -> problem
arrows = [
(2.9, 10.5, 5.75, 9.5, "rozwiązuje →", "->", 1.5),
(7.0, 8.8, 2.9, 8.5, "← rodzi problem", "->", 1.0),
(2.9, 8.0, 5.75, 7.2, "rozwiązuje →", "->", 1.5),
(7.0, 6.5, 2.9, 6.2, "← rodzi problem", "->", 1.0),
(2.9, 5.7, 5.75, 5.0, "rozwiązuje →", "->", 1.5),
(7.0, 4.2, 2.9, 3.9, "← rodzi problem", "->", 1.0),
(2.9, 3.3, 5.75, 2.6, "rozwiązuje →", "->", 1.5),
(8.25, 9.0, 9.5, 6.5, "wymaga →", "->", 1.0),
(2.9, 1.3, 8.75, 5.6, "rozwiązuje →", "->", 1.2),
]
for x1, y1, x2, y2, label, style, lw in arrows:
ax.annotate(
"",
xy=(x2, y2),
xytext=(x1, y1),
arrowprops={
"arrowstyle": style,
"color": LN,
"lw": lw,
"connectionstyle": "arc3,rad=0.05",
},
)
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
ax.text(
mx,
my + 0.2,
label,
ha="center",
va="center",
fontsize=6.5,
fontstyle="italic",
color="#555555",
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": "white",
"edgecolor": "none",
"alpha": 0.8,
},
)
# Legend
legend_y = 0.3
r1 = FancyBboxPatch(
(1.0, legend_y - 0.2),
1.5,
0.4,
boxstyle="round,pad=0.05",
lw=1,
edgecolor=LN,
facecolor="white",
linestyle="--",
)
ax.add_patch(r1)
ax.text(
1.75, legend_y, "Problem",
ha="center", va="center", fontsize=7,
)
r2 = FancyBboxPatch(
(3.5, legend_y - 0.2),
1.5,
0.4,
boxstyle="round,pad=0.05",
lw=1.5,
edgecolor=LN,
facecolor=GRAY2,
)
ax.add_patch(r2)
ax.text(
4.25,
legend_y,
"Wzorzec",
ha="center",
va="center",
fontsize=7,
fontweight="bold",
)
ax.text(
6.5,
legend_y,
"Nawigacja: Problem \u2192 Wzorzec"
" \u2192 Nowy Problem \u2192 Wzorzec \u2192 ...",
ha="left",
va="center",
fontsize=7,
fontstyle="italic",
)
fig.tight_layout()
out = str(
Path(OUTPUT_DIR) / "q14_pattern_language_navigation.png"
)
fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
plt.close(fig)
_logger.info(" Saved: %s", out)
# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
from python_pkg.praca_magisterska_video.generate_images._pattern_navigation import (
generate_pattern_language_navigation,
)
from python_pkg.praca_magisterska_video.generate_images._pattern_pillars_observer import (
generate_observer_card_filled,
generate_three_pillars,
)
from python_pkg.praca_magisterska_video.generate_images._pattern_template_catalog import (
generate_catalog_map,
generate_pattern_template,
)
logging.basicConfig(level=logging.INFO)
_logger.info("Generating PYTANIE 14 diagrams...")
_logger.info("Generating pattern diagrams...")
generate_pattern_template()
generate_catalog_map()
generate_three_pillars()
generate_observer_card_filled()
generate_pattern_language_navigation()
_logger.info("Done!")
_logger.info("All pattern diagrams saved to %s/", OUTPUT_DIR)

View File

@ -15,10 +15,7 @@ import matplotlib as mpl
mpl.use("Agg")
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch, Polygon
from matplotlib.path import Path as MplPath
import matplotlib.pyplot as plt
if TYPE_CHECKING:
from matplotlib.axes import Axes
@ -36,7 +33,11 @@ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
def draw_arrow(
ax: Axes, x1: float, y1: float, x2: float, y2: float,
ax: Axes,
x1: float,
y1: float,
x2: float,
y2: float,
) -> None:
"""Draw arrow."""
ax.annotate(
@ -48,12 +49,19 @@ def draw_arrow(
def draw_line(
ax: Axes, x1: float, y1: float, x2: float, y2: float,
ax: Axes,
x1: float,
y1: float,
x2: float,
y2: float,
) -> None:
"""Draw line."""
ax.plot(
[x1, x2], [y1, y2],
color=LINE_COLOR, lw=1.3, solid_capstyle="round",
[x1, x2],
[y1, y2],
color=LINE_COLOR,
lw=1.3,
solid_capstyle="round",
)
@ -120,785 +128,20 @@ def draw_diamond(
# =========================================================================
def _draw_bpmn_pool_and_lanes(
ax: Axes,
) -> tuple[float, float, float, float]:
"""Draw BPMN pool outline and swim lanes, return lane positions."""
pool_x, pool_y, pool_w, pool_h = 3, 3, 104, 68
ax.add_patch(
plt.Rectangle(
(pool_x, pool_y),
pool_w,
pool_h,
lw=2,
edgecolor=LINE_COLOR,
facecolor="white",
)
)
label_strip = pool_x + 4
ax.plot(
[label_strip, label_strip],
[pool_y, pool_y + pool_h],
color=LINE_COLOR,
lw=1.5,
)
ax.text(
pool_x + 2,
pool_y + pool_h / 2,
"FIRMA",
fontsize=11,
fontweight="bold",
rotation=90,
ha="center",
va="center",
)
lane_top = pool_y + pool_h
lane_mid1 = pool_y + pool_h * 2 / 3
lane_mid2 = pool_y + pool_h * 1 / 3
ax.plot(
[label_strip, pool_x + pool_w],
[lane_mid1, lane_mid1],
color=LINE_COLOR,
lw=1,
)
ax.plot(
[label_strip, pool_x + pool_w],
[lane_mid2, lane_mid2],
color=LINE_COLOR,
lw=1,
)
y_bok = (lane_top + lane_mid1) / 2
y_jak = (lane_mid1 + lane_mid2) / 2
y_mag = (lane_mid2 + pool_y) / 2
ax.text(
label_strip + 2.5,
y_bok,
"BOK",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
ax.text(
label_strip + 2.5,
y_jak,
"Jako\u015b\u0107",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
ax.text(
label_strip + 2.5,
y_mag,
"Magazyn",
fontsize=8,
ha="center",
va="center",
rotation=90,
fontstyle="italic",
)
content_left = label_strip + 5
return y_bok, y_jak, y_mag, content_left
def _draw_bpmn_elements(
ax: Axes,
y_bok: float,
y_jak: float,
y_mag: float,
content_left: float,
) -> None:
"""Draw all BPMN tasks, gateways, and events."""
sx = content_left + 4
ax.add_patch(
plt.Circle(
(sx, y_bok), 2, lw=2, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(
sx, y_bok - 3.5, "Reklamacja\nwp\u0142ywa", fontsize=6, ha="center",
)
t1x = sx + 14
draw_rounded_rect(ax, t1x, y_bok, 14, 6, "Przyjmij\nzg\u0142oszenie")
draw_arrow(ax, sx + 2, y_bok, t1x - 7, y_bok)
t2x = t1x + 18
draw_rounded_rect(
ax, t2x, y_jak, 14, 6, "Zweryfikuj\nzasadno\u015b\u0107",
)
elbow_x = t1x + 10
draw_line(ax, t1x + 7, y_bok, elbow_x, y_bok)
draw_line(ax, elbow_x, y_bok, elbow_x, y_jak)
draw_arrow(ax, elbow_x, y_jak, t2x - 7, y_jak)
gx = t2x + 14
draw_diamond(ax, gx, y_jak, 3.5, "X")
draw_arrow(ax, t2x + 7, y_jak, gx - 3.5, y_jak)
t3x = gx + 14
draw_rounded_rect(
ax, t3x, y_mag, 14, 6, "Przygotuj\nwymian\u0119/zwrot",
)
draw_line(ax, gx, y_jak - 3.5, gx, y_mag)
draw_arrow(ax, gx, y_mag, t3x - 7, y_mag)
ax.text(gx + 1.5, y_jak - 6, "Tak", fontsize=7, ha="left")
t4x = gx + 14
draw_rounded_rect(
ax, t4x, y_jak, 14, 6, "Odrzu\u0107\nreklamacj\u0119",
)
draw_arrow(ax, gx + 3.5, y_jak, t4x - 7, y_jak)
ax.text(gx + 4, y_jak + 2, "Nie", fontsize=7, ha="left")
mx = t4x + 14
draw_diamond(ax, mx, y_bok, 3.5, "X")
draw_line(ax, t4x + 7, y_jak, mx, y_jak)
draw_arrow(ax, mx, y_jak, mx, y_bok - 3.5)
draw_line(ax, t3x + 7, y_mag, mx - 4, y_mag)
draw_line(ax, mx - 4, y_mag, mx - 4, y_bok)
draw_arrow(ax, mx - 4, y_bok, mx - 3.5, y_bok)
t5x = mx + 13
draw_rounded_rect(ax, t5x, y_bok, 14, 6, "Powiadom\nklienta")
draw_arrow(ax, mx + 3.5, y_bok, t5x - 7, y_bok)
ex = t5x + 12
ax.add_patch(
plt.Circle(
(ex, y_bok), 2, lw=3, edgecolor=LINE_COLOR, facecolor="white",
)
)
draw_arrow(ax, t5x + 7, y_bok, ex - 2, y_bok)
ax.text(ex, y_bok - 3.5, "Koniec", fontsize=6, ha="center")
def _draw_bpmn_legend(ax: Axes) -> None:
"""Draw BPMN legend."""
ly = 1
ax.text(
12, ly, "Legenda:", fontsize=7, fontweight="bold", va="center",
)
ax.add_patch(
plt.Circle(
(22, ly), 1, lw=2, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(24, ly, "Start", fontsize=6, va="center")
ax.add_patch(
plt.Circle(
(30, ly), 1, lw=3, edgecolor=LINE_COLOR, facecolor="white",
)
)
ax.text(32, ly, "Koniec", fontsize=6, va="center")
draw_diamond(ax, 40, ly, 1.5, "X", fontsize=5)
ax.text(43, ly, "Bramka XOR", fontsize=6, va="center")
draw_rounded_rect(ax, 58, ly, 7, 2.5, "Zadanie", fontsize=6)
ax.text(65, ly, "Sequence Flow \u2192", fontsize=6, va="center")
def generate_bpmn() -> None:
"""Generate bpmn."""
fig, ax = plt.subplots(figsize=(11, 7.5))
ax.set_xlim(0, 110)
ax.set_ylim(0, 75)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"BPMN 2.0 \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
y_bok, y_jak, y_mag, content_left = _draw_bpmn_pool_and_lanes(ax)
_draw_bpmn_elements(ax, y_bok, y_jak, y_mag, content_left)
_draw_bpmn_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "bpmn_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK BPMN saved")
# =========================================================================
# 2. UML Activity Diagram
# =========================================================================
def _draw_uml_elements(ax: Axes) -> None:
"""Draw all UML activity diagram elements."""
cx = 50
y = 93
step = 11
ax.add_patch(
plt.Circle((cx, y), 1.8, facecolor="black", edgecolor="black"),
)
y -= step
draw_rounded_rect(
ax, cx, y, 28, 6, "Przyjmij zg\u0142oszenie reklamacji",
)
draw_arrow(ax, cx, y + step - 1.8, cx, y + 3)
y -= step
draw_rounded_rect(
ax, cx, y, 28, 6, "Zweryfikuj zasadno\u015b\u0107",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 3)
y -= step
draw_diamond(ax, cx, y, 4)
draw_arrow(ax, cx, y + step - 3, cx, y + 4)
ax.text(
cx + 6, y + 5, "[zasadna?]", fontsize=8, fontstyle="italic",
)
dec_y = y
branch_y = dec_y - step
left_x = cx - 24
draw_rounded_rect(
ax, left_x, branch_y, 22, 6, "Przygotuj\nwymian\u0119/zwrot",
)
draw_line(ax, cx - 4, dec_y, left_x, dec_y)
draw_arrow(ax, left_x, dec_y, left_x, branch_y + 3)
ax.text(
left_x + 2, dec_y + 1.5, "[tak]",
fontsize=8, fontstyle="italic",
)
right_x = cx + 24
draw_rounded_rect(
ax, right_x, branch_y, 22, 6, "Odrzu\u0107\nreklamacj\u0119",
)
draw_line(ax, cx + 4, dec_y, right_x, dec_y)
draw_arrow(ax, right_x, dec_y, right_x, branch_y + 3)
ax.text(
right_x - 12, dec_y + 1.5, "[nie]",
fontsize=8, fontstyle="italic",
)
merge_y = branch_y - step
draw_diamond(ax, cx, merge_y, 4)
draw_line(ax, left_x, branch_y - 3, left_x, merge_y)
draw_line(ax, left_x, merge_y, cx - 4, merge_y)
draw_line(ax, right_x, branch_y - 3, right_x, merge_y)
draw_line(ax, right_x, merge_y, cx + 4, merge_y)
y = merge_y - step
draw_rounded_rect(ax, cx, y, 28, 6, "Powiadom klienta")
draw_arrow(ax, cx, merge_y - 4, cx, y + 3)
ey = y - step
ax.add_patch(
plt.Circle(
(cx, ey), 2.5, lw=2, facecolor="white", edgecolor="black",
)
)
ax.add_patch(
plt.Circle((cx, ey), 1.5, facecolor="black", edgecolor="black"),
)
draw_arrow(ax, cx, y - 3, cx, ey + 2.5)
def _draw_uml_legend(ax: Axes) -> None:
"""Draw UML activity diagram legend."""
ly = 5
ax.add_patch(
plt.Circle((12, ly), 1.2, facecolor="black", edgecolor="black"),
)
ax.text(15, ly, "= Pocz\u0105tek", fontsize=7, va="center")
ax.add_patch(
plt.Circle(
(32, ly), 1.3, lw=2, facecolor="white", edgecolor="black",
)
)
ax.add_patch(
plt.Circle((32, ly), 0.8, facecolor="black", edgecolor="black"),
)
ax.text(35, ly, "= Koniec", fontsize=7, va="center")
draw_diamond(ax, 50, ly, 1.5)
ax.text(53, ly, "= Decyzja/Merge", fontsize=7, va="center")
draw_rounded_rect(ax, 78, ly, 9, 3, "Akcja", fontsize=7)
def generate_uml_activity() -> None:
"""Generate uml activity."""
fig, ax = plt.subplots(figsize=(8.27, 10))
ax.set_xlim(0, 100)
ax.set_ylim(0, 100)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"UML Activity Diagram \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
_draw_uml_elements(ax)
_draw_uml_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "uml_activity_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK UML Activity saved")
# =========================================================================
# 3. EPC (Event-driven Process Chain)
# =========================================================================
def _draw_epc_event(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC event shape (rounded grey box)."""
w, h = 26, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.5",
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="#D8D8D8",
)
ax.add_patch(rect)
ax.text(x, y, text, ha="center", va="center", fontsize=8)
def _draw_epc_function(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC function shape (rounded white box, bold)."""
w, h = 26, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=0.3",
lw=2,
edgecolor=LINE_COLOR,
facecolor="white",
)
ax.add_patch(rect)
ax.text(
x, y, text,
ha="center", va="center", fontsize=8, fontweight="bold",
)
def _draw_epc_connector(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw an EPC logical connector (circle)."""
circle = plt.Circle(
(x, y), 2.8, lw=1.5, edgecolor=LINE_COLOR, facecolor="white",
)
ax.add_patch(circle)
ax.text(
x, y, text,
ha="center", va="center", fontsize=9, fontweight="bold",
)
def _draw_epc_flow(
ax: Axes,
) -> tuple[float, float, float]:
"""Draw sequential EPC flow from E1 through XOR split."""
cx = 50
y = 114
step = 9.5
_draw_epc_event(ax, cx, y, "Reklamacja wp\u0142yn\u0119\u0142a")
y -= step
_draw_epc_function(ax, cx, y, "Przyjmij zg\u0142oszenie")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Zg\u0142oszenie przyj\u0119te")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_function(ax, cx, y, "Zweryfikuj zasadno\u015b\u0107")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Zasadno\u015b\u0107 oceniona")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_epc_connector(ax, cx, y, "XOR")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
return cx, y, step
def _draw_epc_branches(
ax: Axes,
cx: float,
split_y: float,
step: float,
) -> None:
"""Draw EPC branches, merge, and post-merge elements."""
left_x = cx - 28
right_x = cx + 28
by = split_y - step
_draw_epc_event(ax, left_x, by, "Reklamacja zasadna")
draw_line(ax, cx - 2.8, split_y, left_x, split_y)
draw_arrow(ax, left_x, split_y, left_x, by + 2.8)
by2 = by - step
_draw_epc_function(
ax, left_x, by2, "Przygotuj wymian\u0119/zwrot",
)
draw_arrow(ax, left_x, by - 2.8, left_x, by2 + 2.8)
by3 = by2 - step
_draw_epc_event(ax, left_x, by3, "Wymiana przygotowana")
draw_arrow(ax, left_x, by2 - 2.8, left_x, by3 + 2.8)
_draw_epc_event(ax, right_x, by, "Reklamacja niezasadna")
draw_line(ax, cx + 2.8, split_y, right_x, split_y)
draw_arrow(ax, right_x, split_y, right_x, by + 2.8)
_draw_epc_function(ax, right_x, by2, "Odrzu\u0107 reklamacj\u0119")
draw_arrow(ax, right_x, by - 2.8, right_x, by2 + 2.8)
_draw_epc_event(ax, right_x, by3, "Reklamacja odrzucona")
draw_arrow(ax, right_x, by2 - 2.8, right_x, by3 + 2.8)
merge_y = by3 - step
_draw_epc_connector(ax, cx, merge_y, "XOR")
draw_line(ax, left_x, by3 - 2.8, left_x, merge_y)
draw_line(ax, left_x, merge_y, cx - 2.8, merge_y)
draw_line(ax, right_x, by3 - 2.8, right_x, merge_y)
draw_line(ax, right_x, merge_y, cx + 2.8, merge_y)
y = merge_y - step
_draw_epc_function(ax, cx, y, "Powiadom klienta")
draw_arrow(ax, cx, merge_y - 2.8, cx, y + 2.8)
y -= step
_draw_epc_event(ax, cx, y, "Klient powiadomiony")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
def _draw_epc_legend(ax: Axes) -> None:
"""Draw EPC legend."""
ly = 3
_draw_epc_event(ax, 16, ly, "Zdarzenie")
_draw_epc_function(ax, 46, ly, "Funkcja")
_draw_epc_connector(ax, 68, ly, "XOR")
ax.text(
72, ly, "= \u0141\u0105cznik logiczny", fontsize=7, va="center",
)
def generate_epc() -> None:
"""Generate epc."""
fig, ax = plt.subplots(figsize=(8.27, 11))
ax.set_xlim(0, 100)
ax.set_ylim(0, 120)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"EPC (Event-driven Process Chain)"
" \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
cx, split_y, step = _draw_epc_flow(ax)
_draw_epc_branches(ax, cx, split_y, step)
_draw_epc_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "epc_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK EPC saved")
# =========================================================================
# 4. Classic Flowchart
# =========================================================================
def _draw_fc_terminal(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart terminal (rounded) shape."""
w, h = 20, 5.5
rect = FancyBboxPatch(
(x - w / 2, y - h / 2),
w,
h,
boxstyle="round,pad=1.0",
lw=2,
edgecolor=LINE_COLOR,
facecolor="#E0E0E0",
)
ax.add_patch(rect)
ax.text(
x,
y,
text,
ha="center",
va="center",
fontsize=FONT_SIZE,
fontweight="bold",
)
def _draw_fc_process_box(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart process box (rectangle)."""
w, h = 26, 6
rect = plt.Rectangle(
(x - w / 2, y - h / 2),
w,
h,
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="white",
)
ax.add_patch(rect)
ax.text(
x, y, text, ha="center", va="center", fontsize=FONT_SIZE,
)
def _draw_fc_io_shape(
ax: Axes, x: float, y: float, text: str,
) -> None:
"""Draw a flowchart I/O parallelogram."""
w, h = 26, 5.5
skew = 3
verts = [
(x - w / 2 + skew, y + h / 2),
(x + w / 2 + skew, y + h / 2),
(x + w / 2 - skew, y - h / 2),
(x - w / 2 - skew, y - h / 2),
(x - w / 2 + skew, y + h / 2),
]
codes = [
MplPath.MOVETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.CLOSEPOLY,
]
patch = mpatches.PathPatch(
MplPath(verts, codes),
facecolor="white",
edgecolor=LINE_COLOR,
lw=1.5,
)
ax.add_patch(patch)
ax.text(
x, y, text, ha="center", va="center", fontsize=FONT_SIZE,
)
def _draw_fc_elements(ax: Axes) -> None:
"""Draw all flowchart elements."""
cx = 50
y = 103
step = 11
_draw_fc_terminal(ax, cx, y, "START")
y -= step
_draw_fc_io_shape(ax, cx, y, "Reklamacja od klienta")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
y -= step
_draw_fc_process_box(ax, cx, y, "Przyjmij zg\u0142oszenie")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 3)
y -= step
_draw_fc_process_box(
ax, cx, y, "Zweryfikuj zasadno\u015b\u0107",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 3)
y -= step
draw_diamond(ax, cx, y, 4.5, "Zasadna?")
draw_arrow(ax, cx, y + step - 3, cx, y + 4.5)
dec_y = y
left_x = cx - 26
_draw_fc_process_box(
ax, left_x, dec_y, "Przygotuj wymian\u0119/zwrot",
)
draw_line(ax, cx - 4.5, dec_y, left_x + 13, dec_y)
ax.text(
cx - 7, dec_y + 2, "Tak",
fontsize=8, ha="center", fontweight="bold",
)
right_x = cx + 26
_draw_fc_process_box(
ax, right_x, dec_y, "Odrzu\u0107 reklamacj\u0119",
)
draw_line(ax, cx + 4.5, dec_y, right_x - 13, dec_y)
ax.text(
cx + 7, dec_y + 2, "Nie",
fontsize=8, ha="center", fontweight="bold",
)
merge_y = dec_y - step
draw_line(ax, left_x, dec_y - 3, left_x, merge_y)
draw_line(ax, right_x, dec_y - 3, right_x, merge_y)
draw_line(ax, left_x, merge_y, right_x, merge_y)
ax.plot(cx, merge_y, "ko", markersize=4)
y = merge_y - step + 3
_draw_fc_process_box(ax, cx, y, "Powiadom klienta")
draw_arrow(ax, cx, merge_y, cx, y + 3)
y -= step
_draw_fc_io_shape(
ax, cx, y, "Odpowied\u017a do klienta",
)
draw_arrow(ax, cx, y + step - 3, cx, y + 2.8)
y -= step
_draw_fc_terminal(ax, cx, y, "KONIEC")
draw_arrow(ax, cx, y + step - 2.8, cx, y + 2.8)
def _draw_fc_legend(ax: Axes) -> None:
"""Draw flowchart legend."""
ly = 4
ax.text(
5, ly, "Legenda:", fontsize=7, fontweight="bold", va="center",
)
_draw_fc_terminal(ax, 18, ly, "")
ax.text(
18, ly, "Start/\nKoniec",
fontsize=5.5, ha="center", va="center",
)
w, h = 9, 3
ax.add_patch(
plt.Rectangle(
(32 - w / 2, ly - h / 2),
w,
h,
lw=1.5,
edgecolor=LINE_COLOR,
facecolor="white",
)
)
ax.text(32, ly, "Proces", fontsize=6, ha="center", va="center")
draw_diamond(ax, 46, ly, 2)
ax.text(49.5, ly, "= Decyzja", fontsize=6, va="center")
skew = 1.5
w2, h2 = 9, 3
verts = [
(62 - w2 / 2 + skew, ly + h2 / 2),
(62 + w2 / 2 + skew, ly + h2 / 2),
(62 + w2 / 2 - skew, ly - h2 / 2),
(62 - w2 / 2 - skew, ly - h2 / 2),
(62 - w2 / 2 + skew, ly + h2 / 2),
]
codes = [
MplPath.MOVETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.LINETO,
MplPath.CLOSEPOLY,
]
ax.add_patch(
mpatches.PathPatch(
MplPath(verts, codes),
facecolor="white",
edgecolor=LINE_COLOR,
lw=1.2,
)
)
ax.text(62, ly, "We/Wy", fontsize=6, ha="center", va="center")
def generate_flowchart() -> None:
"""Generate flowchart."""
fig, ax = plt.subplots(figsize=(8.27, 11))
ax.set_xlim(0, 100)
ax.set_ylim(0, 110)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor(BG_COLOR)
ax.set_title(
"Schemat blokowy (Flowchart)"
" \u2014 Obs\u0142uga reklamacji",
fontsize=TITLE_SIZE,
fontweight="bold",
pad=12,
)
_draw_fc_elements(ax)
_draw_fc_legend(ax)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "flowchart_reklamacja.png"),
dpi=DPI,
facecolor="white",
bbox_inches="tight",
)
plt.close(fig)
_logger.info(" OK Flowchart saved")
# =========================================================================
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
_logger.info("Generating diagrams to %s/...", OUTPUT_DIR)
from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import (
generate_bpmn,
generate_uml_activity,
)
from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import (
generate_epc,
generate_flowchart,
)
logging.basicConfig(level=logging.INFO)
_logger.info("Generating process diagrams...")
generate_bpmn()
generate_uml_activity()
generate_epc()
generate_flowchart()
_logger.info("\nAll 4 diagrams saved to %s/", OUTPUT_DIR)
for fname in sorted(p.name for p in Path(OUTPUT_DIR).iterdir()):
if fname.endswith(".png"):
size_kb = (
Path(
str(Path(OUTPUT_DIR).stat().st_size / fname),
)
/ 1024
)
_logger.info(" %s (%.0f KB)", fname, size_kb)
_logger.info("All process diagrams saved to %s/", OUTPUT_DIR)

View File

@ -23,8 +23,6 @@ from pathlib import Path
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
@ -132,886 +130,28 @@ def draw_arrow(
# ============================================================
# 1. T-R-M-S Abstraction Pyramid
# ============================================================
def draw_trms_pyramid() -> None:
"""Draw trms pyramid."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Poziomy abstrakcji języków programowania robotów (T-R-M-S)",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Pyramid layers (bottom to top)
layers = [
# Fields: y left_x right_x label sublabel fill examples timing
(
0.5,
1.0,
9.0,
"SERVO-LEVEL",
"Sterowanie silnikami",
GRAY3,
"C/C++, FPGA, VHDL\nPID, PWM",
"~1 ms",
),
(
2.0,
1.8,
8.2,
"MOTION-LEVEL",
"Planowanie trajektorii",
GRAY2,
"MoveIt, OMPL\nIK, collision avoidance",
"~20 ms",
),
(
3.5,
2.6,
7.4,
"ROBOT-LEVEL",
"Komendy ruchu",
GRAY1,
"RAPID, KRL, Karel\nPDL2, URScript, ROS",
"~100 ms",
),
(
5.0,
3.4,
6.6,
"TASK-LEVEL",
"Opis celu",
GRAY4,
"PDDL, BT, STRIPS\nplanowanie AI",
"~sekundy",
),
]
h = 1.3
for y, lx, rx, label, sublabel, fill, examples, timing in layers:
rx - lx
# Draw trapezoid
trap = plt.Polygon(
[(lx, y), (rx, y), (rx - 0.4, y + h), (lx + 0.4, y + h)],
closed=True,
facecolor=fill,
edgecolor=LN,
lw=1.5,
)
ax.add_patch(trap)
# Label
ax.text(
(lx + rx) / 2,
y + h * 0.65,
label,
ha="center",
va="center",
fontsize=9,
fontweight="bold",
)
ax.text(
(lx + rx) / 2,
y + h * 0.35,
sublabel,
ha="center",
va="center",
fontsize=7,
style="italic",
)
# Examples - right side
ax.text(
rx + 0.2,
y + h * 0.5,
examples,
ha="left",
va="center",
fontsize=6.5,
color="#333333",
)
# Timing - left side
ax.text(
lx - 0.2,
y + h * 0.5,
timing,
ha="right",
va="center",
fontsize=7,
fontweight="bold",
color="#333333",
)
# Arrow on left
ax.annotate(
"",
xy=(0.5, 6.2),
xytext=(0.5, 0.8),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
0.5,
3.5,
"Abstrakcja\nrośnie",
ha="center",
va="center",
fontsize=7,
rotation=90,
fontweight="bold",
)
# Arrow on right side for timing
ax.annotate(
"",
xy=(9.7, 0.8),
xytext=(9.7, 6.2),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
9.7,
3.5,
"Szybkość\nreakcji",
ha="center",
va="center",
fontsize=7,
rotation=270,
fontweight="bold",
)
# Mnemonic at bottom
ax.text(
5.0,
0.0,
'Mnemonik: „Tomek Robi Mechaniczne Serwa" (T→R→M→S, od góry do dołu)',
ha="center",
va="center",
fontsize=7,
style="italic",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_trms_pyramid.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_trms_pyramid.png")
# ============================================================
# 2. Vendor Languages Comparison
# ============================================================
def draw_vendor_comparison() -> None:
"""Draw vendor comparison."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7.5)
ax.axis("off")
ax.set_title(
"Języki producentów robotów — porównanie",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Table headers
headers = [
"Cecha",
"RAPID\n(ABB)",
"KRL\n(KUKA)",
"Karel\n(FANUC)",
"PDL2\n(Comau)",
"URScript\n(UR)",
]
col_widths = [1.8, 1.6, 1.6, 1.6, 1.6, 1.6]
col_x = [0.1]
for w in col_widths[:-1]:
col_x.append(col_x[-1] + w)
row_h = 0.7
header_y = 6.3
rows = [
[
"Składnia",
"typ własny\nstrukturalna",
"Pascal-like\nstrukturalna",
"Pascal-like\nstrukturalna",
"proceduralna\nC-like",
"Python-like\nskryptowy",
],
[
"Ruch liniowy",
"MoveL",
"LIN",
"MOVE TO\nw/LINEAR",
"MOVE\nLINEAR TO",
"movel()",
],
["Ruch joint", "MoveJ", "PTP", "MOVE TO", "MOVE TO", "movej()"],
[
"Ruch kołowy",
"MoveC",
"CIRC",
"(brak\nwbudow.)",
"MOVE\nCIRCULAR",
"movec()",
],
[
"I/O",
"SetDO/\nWaitDI",
"OUT/IN",
"DOUT/DIN",
"OUT/IN",
"set_digital\n_out()",
],
[
"Zmienne",
"num, robtarget\nstring, bool",
"INT, REAL\nPOS, E6POS",
"INTEGER\nPOSITION",
"INTEGER\nPOSITION",
"int, float\npose",
],
[
"Symulator",
"RobotStudio",
"KUKA.Sim",
"ROBOGUIDE",
"RoboSim",
"URSim\n(darmowy)",
],
]
# Draw header row
for j, (hdr, w) in enumerate(zip(headers, col_widths, strict=False)):
x = col_x[j]
fill = GRAY2 if j == 0 else GRAY1
draw_box(
ax,
x,
header_y,
w - 0.05,
row_h,
hdr,
fill=fill,
fontsize=7,
fontweight="bold",
rounded=False,
)
# Draw data rows
for i, row in enumerate(rows):
y = header_y - (i + 1) * row_h
for j, (cell, w) in enumerate(zip(row, col_widths, strict=False)):
x = col_x[j]
fill = GRAY4 if j == 0 else (WHITE if i % 2 == 0 else GRAY4)
fw = "bold" if j == 0 else "normal"
draw_box(
ax,
x,
y,
w - 0.05,
row_h - 0.02,
cell,
fill=fill,
fontsize=6,
fontweight=fw,
rounded=False,
)
# Note
ax.text(
5.0,
0.5,
"Vendor lock-in: program w RAPID ≠ działa na KUKA. "
"ROS/ROS 2 jako warstwa unifikująca.",
ha="center",
va="center",
fontsize=7,
style="italic",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": LN,
"lw": 0.8,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_vendor_comparison.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_vendor_comparison.png")
# ============================================================
# 3. Robot Movement Types (PTP, LIN, CIRC)
# ============================================================
def _draw_ptp_subplot(ax: Axes) -> None:
"""Draw the PTP (Point-to-Point) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"PTP (Point-to-Point)\nMoveJ / PTP",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
start = (0.5, 0.5)
end = (3.5, 3.5)
ax.plot(*start, "ko", ms=10, zorder=5)
ax.plot(*end, "ks", ms=10, zorder=5)
ax.text(start[0] - 0.3, start[1] - 0.3, "Start", fontsize=7, ha="center")
ax.text(end[0] + 0.3, end[1] + 0.3, "Cel", fontsize=7, ha="center")
# Curved path (joint space = not necessarily straight in Cartesian)
t = np.linspace(0, 1, 50)
x_ptp = start[0] + (end[0] - start[0]) * t + 0.8 * np.sin(np.pi * t)
y_ptp = start[1] + (end[1] - start[1]) * t - 0.3 * np.sin(np.pi * t)
ax.plot(x_ptp, y_ptp, "k-", lw=2)
ax.annotate(
"",
xy=(x_ptp[-1], y_ptp[-1]),
xytext=(x_ptp[-3], y_ptp[-3]),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
ax.text(
2.8,
1.2,
"Ścieżka\nw kartezjańskiej\nnieokreślona!",
fontsize=6,
ha="center",
style="italic",
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5},
)
ax.text(
2.0,
-0.3,
"Najszybszy, ale\nścieżka nieprzewidywalna",
fontsize=6,
ha="center",
style="italic",
)
ax.set_xlabel("")
ax.set_ylabel("")
ax.tick_params(labelsize=6)
def _draw_lin_subplot(ax: Axes) -> None:
"""Draw the LIN (Linear) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"LIN (Linear)\nMoveL / LIN",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
start = (0.5, 1.0)
end = (3.5, 3.5)
ax.plot(*start, "ko", ms=10, zorder=5)
ax.plot(*end, "ks", ms=10, zorder=5)
ax.text(start[0] - 0.3, start[1] - 0.3, "Start", fontsize=7, ha="center")
ax.text(end[0] + 0.3, end[1] + 0.3, "Cel", fontsize=7, ha="center")
# Straight line
ax.plot([start[0], end[0]], [start[1], end[1]], "k-", lw=2)
ax.annotate(
"",
xy=end,
xytext=(
start[0] + 0.9 * (end[0] - start[0]),
start[1] + 0.9 * (end[1] - start[1]),
),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
# Show intermediate points
for frac in [0.25, 0.5, 0.75]:
px = start[0] + frac * (end[0] - start[0])
py = start[1] + frac * (end[1] - start[1])
ax.plot(px, py, "k.", ms=6)
ax.text(
2.0,
-0.3,
"Prosta linia TCP\nIK w każdym punkcie",
fontsize=6,
ha="center",
style="italic",
)
ax.tick_params(labelsize=6)
def _draw_circ_subplot(ax: Axes) -> None:
"""Draw the CIRC (Circular) subplot."""
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-0.5, 4.5)
ax.set_aspect("equal")
ax.set_title(
"CIRC (Circular)\nMoveC / CIRC",
fontsize=8,
fontweight="bold",
)
ax.grid(visible=True, alpha=0.3)
# Arc through 3 points
center = (2.0, 1.5)
radius = 2.0
theta_start = np.radians(20)
theta_end = np.radians(160)
theta = np.linspace(theta_start, theta_end, 50)
x_circ = center[0] + radius * np.cos(theta)
y_circ = center[1] + radius * np.sin(theta)
ax.plot(x_circ, y_circ, "k-", lw=2)
ax.annotate(
"",
xy=(x_circ[-1], y_circ[-1]),
xytext=(x_circ[-3], y_circ[-3]),
arrowprops={"arrowstyle": "->", "color": "black", "lw": 2},
)
# Start, auxiliary, end points
ax.plot(x_circ[0], y_circ[0], "ko", ms=10, zorder=5)
ax.plot(x_circ[24], y_circ[24], "k^", ms=8, zorder=5)
ax.plot(x_circ[-1], y_circ[-1], "ks", ms=10, zorder=5)
ax.text(x_circ[0] + 0.3, y_circ[0] - 0.3, "Start", fontsize=7)
ax.text(
x_circ[24] + 0.05,
y_circ[24] + 0.25,
"Pkt\npomocniczy",
fontsize=6,
ha="center",
)
ax.text(x_circ[-1] - 0.5, y_circ[-1] - 0.3, "Cel", fontsize=7)
# Center
ax.plot(*center, "k+", ms=8, mew=1.5)
ax.text(center[0], center[1] - 0.3, "środek", fontsize=6, ha="center")
ax.text(
2.0,
-0.3,
"Łuk wyznaczony\nprzez 3 punkty",
fontsize=6,
ha="center",
style="italic",
)
ax.tick_params(labelsize=6)
def draw_movement_types() -> None:
"""Draw movement types."""
fig, axes = plt.subplots(1, 3, figsize=(8.27, 3.2))
fig.suptitle(
"Typy ruchu robota: PTP, LIN, CIRC",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
_draw_ptp_subplot(axes[0])
_draw_lin_subplot(axes[1])
_draw_circ_subplot(axes[2])
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_movement_types.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_movement_types.png")
# ============================================================
# 4. Online vs Offline Programming
# ============================================================
def draw_online_offline() -> None:
"""Draw online offline."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 4.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 6.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Programowanie robotów: Online (teach-in) vs Offline",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# === ONLINE side (left) ===
# Title
draw_box(
ax,
0.3,
5.2,
4.2,
0.8,
"ONLINE\n(teach-in / pendant)",
fill=GRAY2,
fontsize=9,
fontweight="bold",
)
steps_online = [
(4.2, "Operator przy robocie\nz teach pendantem"),
(3.2, 'Prowadzi ramię\n„za rękę" do punktów'),
(2.2, "Robot zapamiętuje\npozycje (record)"),
(1.2, "Odtwarzanie\nzapisanej ścieżki"),
]
for y, txt in steps_online:
draw_box(ax, 0.5, y, 3.8, 0.8, txt, fill=WHITE, fontsize=7)
for i in range(len(steps_online) - 1):
draw_arrow(ax, 2.4, steps_online[i][0], 2.4, steps_online[i + 1][0] + 0.8)
# Pros/cons
ax.text(
2.4,
0.6,
"✓ Proste, intuicyjne\n✗ Wymaga zatrzymania produkcji\n✗ Niska precyzja",
ha="center",
va="center",
fontsize=6.5,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5, "lw": 0.8},
)
# Divider
ax.plot([4.9, 4.9], [0.3, 6.2], "k--", lw=1, alpha=0.5)
# === OFFLINE side (right) ===
draw_box(
ax,
5.3,
5.2,
4.2,
0.8,
"OFFLINE\n(symulacja / CAD/CAM)",
fill=GRAY2,
fontsize=9,
fontweight="bold",
)
steps_offline = [
(4.2, "Model 3D robota +\nśrodowisko w symulatorze"),
(3.2, "Programowanie ścieżek\nw środowisku wirtualnym"),
(2.2, "Weryfikacja kolizji\ni optymalizacja"),
(1.2, "Transfer na\nrzeczywistego robota"),
]
for y, txt in steps_offline:
draw_box(ax, 5.5, y, 3.8, 0.8, txt, fill=WHITE, fontsize=7)
for i in range(len(steps_offline) - 1):
draw_arrow(ax, 7.4, steps_offline[i][0], 7.4, steps_offline[i + 1][0] + 0.8)
ax.text(
7.4,
0.6,
"✓ Bez zatrzymania produkcji\n"
"✓ Wysoka precyzja, symulacja\n"
"✗ Wymaga kalibracji",
ha="center",
va="center",
fontsize=6.5,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": GRAY5, "lw": 0.8},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_online_offline.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_online_offline.png")
# ============================================================
# 5. ROS Architecture (pub/sub)
# ============================================================
def draw_ros_architecture() -> None:
"""Draw ros architecture."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 4.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 6.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"ROS — architektura publish/subscribe",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Nodes
nodes = [
(1.0, 4.5, "Czujnik\n(LiDAR)", GRAY1),
(1.0, 2.5, "Kamera\n(RGB-D)", GRAY1),
(4.0, 4.5, "Lokalizacja\n(SLAM)", GRAY4),
(4.0, 2.5, "Percepcja\n(detekcja)", GRAY4),
(7.0, 3.5, "Planowanie\nruchu (MoveIt)", GRAY2),
(7.0, 1.0, "Sterownik\nsilników", GRAY3),
]
for x, y, txt, fill in nodes:
draw_box(ax, x, y, 2.2, 1.0, txt, fill=fill, fontsize=7, fontweight="bold")
# Topics (arrows with labels)
topics = [
# Fields: from_x from_y to_x to_y label
(3.2, 5.0, 4.0, 5.0, "/scan"),
(3.2, 3.0, 4.0, 3.0, "/image"),
(6.2, 5.0, 7.0, 4.3, "/pose"),
(6.2, 3.0, 7.0, 3.8, "/objects"),
(8.0, 3.5, 8.0, 2.0, "/cmd_vel"),
]
for x1, y1, x2, y2, label in topics:
draw_arrow(ax, x1, y1, x2, y2, lw=1.5)
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
ax.text(
mx,
my + 0.2,
label,
ha="center",
va="bottom",
fontsize=6,
fontweight="bold",
style="italic",
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": WHITE,
"edgecolor": GRAY5,
"lw": 0.5,
},
)
# ROS Master / roscore
draw_box(
ax,
3.5,
0.3,
3.0,
0.8,
"ROS Master (roscore)\nRejestr węzłów i tematów",
fill=GRAY2,
fontsize=7,
fontweight="bold",
)
# Dashed lines to master
for x, y, _, _ in nodes[:4]:
ax.plot([x + 1.1, 5.0], [y, 1.1], "k:", lw=0.5, alpha=0.4)
# Legend
ax.text(
0.3,
0.8,
"Węzeł (Node) = proces\n"
"Temat (Topic) = kanał pub/sub\n"
"Wiadomość = typowany komunikat",
ha="left",
va="center",
fontsize=6,
bbox={"boxstyle": "round", "facecolor": GRAY4, "edgecolor": LN, "lw": 0.8},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_ros_architecture.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_ros_architecture.png")
# ============================================================
# 6. RAPID program structure example
# ============================================================
def draw_rapid_structure() -> None:
"""Draw rapid structure."""
fig, ax = plt.subplots(1, 1, figsize=(8.27, 5.5))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.axis("off")
ax.set_title(
"Struktura programu RAPID (ABB) — przykład pick & place",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Program structure blocks
# Simplified: just draw code blocks
code_sections = [
(
"Deklaracje danych (stałe, zmienne)",
GRAY4,
[
"CONST robtarget pHome := [[500,0,600],[1,0,0,0],...];",
"CONST robtarget pPick := [[400,200,100],[1,0,0,0],...];",
"CONST robtarget pPlace := [[400,-200,100],[1,0,0,0],...];",
"VAR num nCycles := 0;",
"PERS tooldata tGripper := [...];",
],
),
(
"Procedura główna: main()",
GRAY1,
[
"PROC main()",
" MoveJ pHome, v1000, z50, tGripper;",
" WHILE TRUE DO",
" PickPart;",
" PlacePart;",
" Incr nCycles;",
" ENDWHILE",
"ENDPROC",
],
),
(
"Podprocedura: PickPart()",
GRAY1,
[
"PROC PickPart()",
" MoveL Offs(pPick,0,0,50), v500, z10, tGripper;",
" MoveL pPick, v100, fine, tGripper;",
" SetDO doGripper, 1; ! zamknij chwytak",
" WaitTime 0.5;",
" MoveL Offs(pPick,0,0,50), v500, z10, tGripper;",
"ENDPROC",
],
),
]
y_cur = 7.2
for title, fill, lines in code_sections:
0.25 * len(lines) + 0.5
# Title bar
draw_box(
ax,
0.5,
y_cur - 0.35,
9.0,
0.35,
title,
fill=fill,
fontsize=7,
fontweight="bold",
rounded=False,
)
y_cur -= 0.35
# Code lines
for _i, line in enumerate(lines):
y_cur -= 0.25
ax.text(
0.7,
y_cur + 0.12,
line,
fontsize=5.5,
fontfamily="monospace",
va="center",
)
# Border around code
code_h = 0.25 * len(lines)
rect = mpatches.Rectangle(
(0.5, y_cur - 0.05),
9.0,
code_h + 0.15,
lw=0.8,
edgecolor=GRAY5,
facecolor=WHITE,
zorder=-1,
)
ax.add_patch(rect)
y_cur -= 0.3
# Annotations on right
annotations = [
(
6.5,
"robtarget = pozycja\nkartezjańska + orientacja\n+ konfiguracja ramienia",
),
(
4.5,
"v500 = prędkość 500 mm/s\n"
"z10 = strefa zbliżenia 10mm\n"
"fine = dokładne dojście",
),
(2.5, "SetDO = Digital Output\nSterowanie I/O\n(chwytak, zawory)"),
]
for yy, txt in annotations:
ax.text(
9.8,
yy,
txt,
fontsize=5.5,
ha="left",
va="center",
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": GRAY4,
"edgecolor": GRAY5,
"lw": 0.5,
},
)
fig.tight_layout()
fig.savefig(
str(Path(OUTPUT_DIR) / "robot_rapid_example.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close(fig)
_logger.info("Generated robot_rapid_example.png")
# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
from python_pkg.praca_magisterska_video.generate_images._robot_movement_ros import (
draw_movement_types,
draw_online_offline,
)
from python_pkg.praca_magisterska_video.generate_images._robot_pyramid_vendor import (
draw_trms_pyramid,
draw_vendor_comparison,
)
from python_pkg.praca_magisterska_video.generate_images._robot_ros_rapid import (
draw_rapid_structure,
draw_ros_architecture,
)
logging.basicConfig(level=logging.INFO)
_logger.info("Generating PYTANIE 16 diagrams...")
_logger.info("Generating robot language diagrams...")
draw_trms_pyramid()
draw_vendor_comparison()
draw_movement_types()
draw_online_offline()
draw_ros_architecture()
draw_rapid_structure()
_logger.info("Done! All diagrams saved to %s", OUTPUT_DIR)
_logger.info("All robot language diagrams saved to %s/", OUTPUT_DIR)

View File

@ -21,7 +21,7 @@ import matplotlib as mpl
mpl.use("Agg")
# Re-export common utilities for backward compatibility
from python_pkg.praca_magisterska_video.generate_images._sched_common import ( # noqa: F401
from python_pkg.praca_magisterska_video.generate_images._sched_common import (
BG,
DPI,
FONTWEIGHT_THRESHOLD,
@ -38,6 +38,24 @@ from python_pkg.praca_magisterska_video.generate_images._sched_common import (
draw_arrow,
draw_box,
)
__all__ = [
"BG",
"DPI",
"FONTWEIGHT_THRESHOLD",
"FS",
"FS_TITLE",
"GRAY1",
"GRAY2",
"GRAY3",
"GRAY4",
"GRAY5",
"LN",
"MIN_COLUMN_INDEX",
"OUTPUT_DIR",
"draw_arrow",
"draw_box",
]
from python_pkg.praca_magisterska_video.generate_images._sched_complexity_edd import (
draw_complexity_map,
draw_edd_example,

View File

@ -157,11 +157,7 @@ def draw_graph_edge(
ex = x2 - node_radius * dx / length
ey = y2 - node_radius * dy / length
color = (
"#D32F2F"
if relaxed
else ("#1565C0" if highlighted else GRAY3)
)
color = "#D32F2F" if relaxed else ("#1565C0" if highlighted else GRAY3)
lw = 2.5 if (highlighted or relaxed) else 1.5
ax.plot(
@ -191,9 +187,7 @@ def draw_graph_edge(
bbox={
"boxstyle": "round,pad=0.15",
"facecolor": "white",
"edgecolor": (
GRAY3 if not highlighted else color
),
"edgecolor": (GRAY3 if not highlighted else color),
"alpha": 0.95,
},
zorder=4,
@ -239,14 +233,8 @@ def draw_full_graph(
# Draw edges
for u, v, w in EDGES:
hl = (
(u, v) in highlighted_edges
or (v, u) in highlighted_edges
)
rl = (
(u, v) in relaxed_edges
or (v, u) in relaxed_edges
)
hl = (u, v) in highlighted_edges or (v, u) in highlighted_edges
rl = (u, v) in relaxed_edges or (v, u) in relaxed_edges
draw_graph_edge(
ax,
NODE_POS[u],
@ -273,446 +261,20 @@ def draw_full_graph(
# ============================================================
# 1. Graph structure diagram
# ============================================================
def draw_graph_structure() -> None:
"""Draw the shared example graph used across all algorithms."""
_fig, ax = plt.subplots(1, 1, figsize=(5, 4))
ax.set_xlim(-0.5, 5.0)
ax.set_ylim(-1.2, 4.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(
"Przykładowy graf — wspólny dla wszystkich algorytmów\n"
"Wierzchołki: {A, B, C, D}, Start = A",
fontsize=FS_TITLE,
fontweight="bold",
pad=10,
)
# Draw edges
for u, v, w in EDGES:
draw_graph_edge(ax, NODE_POS[u], NODE_POS[v], w)
# Draw nodes
for node_name, pos in NODE_POS.items():
draw_graph_node(ax, node_name, pos)
# Start arrow
ax.annotate(
"START",
xy=(NODE_POS["A"][0] - 0.35, NODE_POS["A"][1]),
xytext=(NODE_POS["A"][0] - 1.2, NODE_POS["A"][1]),
fontsize=FS,
fontweight="bold",
color="#D32F2F",
arrowprops={"arrowstyle": "->", "color": "#D32F2F", "lw": 2},
va="center",
)
# Edge list
ax.text(
2.3,
-0.8,
"Krawędzie: A→B(2), A→C(4), B→D(3), C→D(5)\n|V|=4, |E|=4, wagi ≥ 0",
ha="center",
va="center",
fontsize=FS,
bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "graph_example_structure.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("graph_example_structure.png")
# ============================================================
# 2. Dijkstra traversal
# ============================================================
def draw_dijkstra_traversal() -> None:
"""Draw step-by-step Dijkstra on the shared graph."""
steps = [
{
"title": "Krok 0: Inicjalizacja\nd = {A:0, B:∞, C:∞, D:∞}",
"dist": {"A": "0", "B": "", "C": "", "D": ""},
"current": "A",
"visited": set(),
"highlighted": set(),
"relaxed": set(),
},
{
"title": (
"Krok 1: Przetwarzam A (d=0)\n"
"Relaksacja: A→B: 0+2=2<∞ ✓"
" A→C: 0+4=4<∞ ✓"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": ""},
"current": "A",
"visited": {"A"},
"highlighted": set(),
"relaxed": {("A", "B"), ("A", "C")},
},
{
"title": (
"Krok 2: Przetwarzam B (d=2)"
" — minimum\n"
"Relaksacja: B→D: 2+3=5<∞ ✓"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "B",
"visited": {"A", "B"},
"highlighted": set(),
"relaxed": {("B", "D")},
},
{
"title": (
"Krok 3: Przetwarzam C (d=4)\n"
"Relaksacja: C→D: 4+5=9 > 5"
" ✗ (nie poprawia)"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "C",
"visited": {"A", "B", "C"},
"highlighted": {("C", "D")},
"relaxed": set(),
},
{
"title": (
"Krok 4: WYNIK"
" — wszystkie przetworzone\n"
"d = {A:0, B:2, C:4, D:5}"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": None,
"visited": {"A", "B", "C", "D"},
"highlighted": {("A", "B"), ("B", "D"), ("A", "C")},
"relaxed": set(),
},
]
fig, axes = plt.subplots(1, 5, figsize=(14, 3.5))
fig.suptitle(
"Dijkstra — przejście grafu krok po kroku"
" (zachłannie: zawsze bierz min d)",
fontsize=FS_TITLE,
fontweight="bold",
y=1.02,
)
for _i, (ax, step) in enumerate(zip(axes, steps, strict=False)):
draw_full_graph(
ax,
title=step["title"],
dist=step["dist"],
current=step["current"],
visited=step["visited"],
highlighted_edges=step["highlighted"],
relaxed_edges=step["relaxed"],
)
# Legend
fig.text(
0.5,
-0.04,
"[zolty] = aktualnie przetwarzany"
" [zielony] = odwiedzony (zamkniety)"
" czerwona krawedz = relaksacja OK"
" szara krawedz = nie poprawia",
ha="center",
fontsize=FS,
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": GRAY4,
"edgecolor": GRAY3,
},
)
plt.tight_layout()
plt.savefig(
str(Path(OUTPUT_DIR) / "dijkstra_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("dijkstra_traversal.png")
# ============================================================
# 3. Bellman-Ford traversal
# ============================================================
def draw_bellman_ford_traversal() -> None:
"""Draw step-by-step Bellman-Ford on the shared graph."""
fig = plt.figure(figsize=(14, 7))
fig.suptitle(
"Bellman-Ford — przejście grafu krok po kroku\n"
"(V-1 = 3 iteracje, w każdej relaksuj"
" WSZYSTKIE krawędzie)",
fontsize=FS_TITLE,
fontweight="bold",
y=0.98,
)
# Data for each iteration
iterations = [
{
"title": "Inicjalizacja",
"edges_detail": "",
"dist": {"A": "0", "B": "", "C": "", "D": ""},
"relaxed": set(),
},
{
"title": "Iteracja 1 (V-1=3)",
"edges_detail": (
"A→B: 0+2=2<∞ ✓\nA→C: 0+4=4<∞ ✓\nB→D: 2+3=5<∞ ✓\nC→D: 4+5=9>5 ✗"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": {("A", "B"), ("A", "C"), ("B", "D")},
},
{
"title": "Iteracja 2",
"edges_detail": (
"A→B: 0+2=2=2 ✗\nA→C: 0+4=4=4 ✗\nB→D: 2+3=5=5 ✗\nC→D: 4+5=9>5 ✗"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": set(),
},
{
"title": "Iteracja 3",
"edges_detail": (
"Brak zmian → stabilne!\n(wczesne zakończenie\n optymalizacja)"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"relaxed": set(),
},
]
for i, it in enumerate(iterations):
# Graph subplot
ax_g = fig.add_subplot(2, 4, i + 1)
draw_full_graph(
ax_g,
title=it["title"],
dist=it["dist"],
current=None,
visited=set() if i == 0 else {"A", "B", "C", "D"},
relaxed_edges=it["relaxed"],
)
# Detail subplot below
ax_d = fig.add_subplot(2, 4, i + 5)
ax_d.axis("off")
ax_d.text(
0.5,
0.5,
it["edges_detail"],
ha="center",
va="center",
fontsize=FS,
family="monospace",
bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3},
)
# Negative cycle check note
neg_cycle_msg = (
"Po 3 iteracjach: sprawdz raz jeszcze"
" — nic sie nie zmienia"
" → BRAK cyklu ujemnego → wynik poprawny"
)
fig.text(
0.5,
0.01,
neg_cycle_msg,
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_GREEN,
"edgecolor": LN,
},
)
plt.tight_layout(rect=[0, 0.05, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "bellman_ford_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("bellman_ford_traversal.png")
# ============================================================
# 4. A* traversal
# ============================================================
def draw_astar_traversal() -> None:
"""Draw step-by-step A* on the shared graph with heuristics."""
# Heuristic values (straight-line distance to D)
h_vals = {"A": 4, "B": 2, "C": 3, "D": 0}
fig = plt.figure(figsize=(14, 7.5))
fig.suptitle(
"A* — przejście grafu krok po kroku (cel = D)\n"
"f(n) = g(n) + h(n), heurystyka h"
" = oszacowana odległość do D",
fontsize=FS_TITLE,
fontweight="bold",
y=0.99,
)
steps = [
{
"title": "Krok 0: Inicjalizacja\nh(A)=4, h(B)=2, h(C)=3, h(D)=0",
"detail": (
"g(A)=0, f(A)=0+4=4\npq = [(4, A)]\nh = oszacowanie\n odl. do celu D"
),
"dist": {"A": "0"},
"f_vals": {"A": "f=4"},
"current": "A",
"visited": set(),
"relaxed": set(),
},
{
"title": "Krok 1: pop A (f=4)\nA→B: g=2, f=2+2=4\nA→C: g=4, f=4+3=7",
"detail": (
"Relaksacja:\n"
" A→B: g=0+2=2\n"
" f=2+h(B)=2+2=4\n"
" A→C: g=0+4=4\n"
" f=4+h(C)=4+3=7\n"
"pq = [(4,B), (7,C)]"
),
"dist": {"A": "0", "B": "2", "C": "4"},
"current": "A",
"visited": {"A"},
"relaxed": {("A", "B"), ("A", "C")},
},
{
"title": "Krok 2: pop B (f=4) — min!\nB→D: g=5, f=5+0=5",
"detail": (
"B ma f=4 < C(f=7)\n"
"→ A* kieruje się\n"
" W STRONĘ celu!\n"
"Relaksacja:\n"
" B→D: g=2+3=5\n"
" f=5+h(D)=5+0=5\n"
"pq = [(5,D), (7,C)]"
),
"dist": {"A": "0", "B": "2", "C": "4", "D": "5"},
"current": "B",
"visited": {"A", "B"},
"relaxed": {("B", "D")},
},
{
"title": "Krok 3: pop D (f=5)\nu == goal → STOP!",
"detail": (
"D to CEL → KONIEC!\n"
"Nie przetwarzamy C\n"
" (f(C)=7 > f(D)=5)\n\n"
"Ścieżka: A→B→D\n"
"Koszt: 5\n\n"
"Dijkstra odwi-\n"
"edziłby też C!"
),
"dist": {"A": "0", "B": "2", "D": "5"},
"current": "D",
"visited": {"A", "B", "D"},
"relaxed": set(),
},
]
for i, step in enumerate(steps):
# Graph
ax_g = fig.add_subplot(2, 4, i + 1)
draw_full_graph(
ax_g,
title=step["title"],
dist=step["dist"],
current=step["current"],
visited=step["visited"],
relaxed_edges=step["relaxed"],
)
# Add h values as small labels
for node_name, pos in NODE_POS.items():
ax_g.text(
pos[0] + 0.35,
pos[1] + 0.35,
f"h={h_vals[node_name]}",
ha="center",
va="center",
fontsize=5.5,
color="#1565C0",
fontweight="bold",
zorder=7,
bbox={
"boxstyle": "round,pad=0.1",
"facecolor": LIGHT_BLUE,
"edgecolor": "#1565C0",
"alpha": 0.9,
"lw": 0.5,
},
)
# Detail
ax_d = fig.add_subplot(2, 4, i + 5)
ax_d.axis("off")
ax_d.text(
0.5,
0.5,
step["detail"],
ha="center",
va="center",
fontsize=FS,
family="monospace",
bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3},
)
# Comparison note
fig.text(
0.5,
0.01,
"A* odwiedził 3 wierzchołki (A, B, D)"
" — POMINĄŁ C!\n"
"Dijkstra odwiedziłby wszystkie 4."
" Heurystyka h kieruje przeszukiwanie"
" w stronę celu.",
ha="center",
fontsize=FS,
fontweight="bold",
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": LIGHT_BLUE,
"edgecolor": "#1565C0",
},
)
plt.tight_layout(rect=[0, 0.06, 1, 0.95])
plt.savefig(
str(Path(OUTPUT_DIR) / "astar_traversal.png"),
dpi=DPI,
bbox_inches="tight",
facecolor=BG,
)
plt.close()
_logger.info("astar_traversal.png")
# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
from python_pkg.praca_magisterska_video.generate_images._shortest_path_traversals import (
draw_astar_traversal,
draw_bellman_ford_traversal,
draw_dijkstra_traversal,
draw_graph_structure,
)
logging.basicConfig(level=logging.INFO)
_logger.info("Generating shortest path diagrams...")
draw_graph_structure()
draw_dijkstra_traversal()
draw_bellman_ford_traversal()
draw_astar_traversal()
_logger.info("All diagrams saved to %s/", OUTPUT_DIR)
_logger.info("All shortest path diagrams saved to %s/", OUTPUT_DIR)

View File

@ -318,253 +318,15 @@ def _make_step(
)
def _dijkstra_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_DIJKSTRA
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
current="S",
step_text="Inicjalizacja: d[S]=0, reszta=∞. Wybierz S (min d).",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": INF},
current="S",
active_edge=("S", "A"),
step_text="Relaksacja S→A: d[A]=0+2=2. S→B: d[B]=0+5=5.",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="A",
visited={"S"},
active_edge=("A", "C"),
step_text="Zamknij S. Min=A(2). Relaksacja A→C: d[C]=2+3=5.",
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="B",
visited={"S", "A"},
active_edge=("B", "A"),
step_text=(
"Zamknij A. Min=B(5). B→A: 5+1=6>2, "
"nie zmieniaj. B→C: 5+6=11>5."
),
algo_name="Algorytm Dijkstry",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="C",
visited={"S", "A", "B"},
step_text=(
"Zamknij B. Min=C(5). Koniec! "
"Wynik: d={S:0, A:2, B:5, C:5}."
),
algo_name="Dijkstra -- WYNIK",
),
),
]
def _bellman_ford_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_BF
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
step_text=(
"Bellman-Ford: relaksuj WSZYSTKIE "
"krawędzie V-1=3 razy. Ujemne wagi OK!"
),
algo_name="Algorytm Bellmana-Forda",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
active_edge=("S", "A"),
step_text=(
"Iteracja 1: S→A:2, A→C:5, S→B:5. "
"Potem B→A: 5+(-4)=1 < 2 → A=1!"
),
algo_name="Bellman-Ford -- iteracja 1",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "5"},
active_edge=("B", "A"),
step_text=(
"B→A z ujemną wagą -4: d[A] poprawione "
"z 2 na 1! (Dijkstra by to pominął!)"
),
algo_name="Bellman-Ford -- ujemna waga",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "4"},
active_edge=("A", "C"),
step_text=(
"Iteracja 2: A→C: 1+3=4 < 5 → C=4. "
"Propagacja poprawionego A."
),
algo_name="Bellman-Ford -- iteracja 2",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "1", "B": "5", "C": "4"},
step_text=(
"Iteracja 3: brak zmian. V-ta iteracja: "
"brak popraw → brak cyklu ujemnego."
),
algo_name="Bellman-Ford -- WYNIK, O(V*E)",
),
),
]
def _astar_steps() -> list[CompositeVideoClip]:
n = NODE_POS
e = EDGES_DIJKSTRA
return [
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": INF, "B": INF, "C": INF},
current="S",
step_text=(
"A*: f(n)=g(n)+h(n). Cel=C. "
"h(S)=5, h(A)=3, h(B)=4, h(C)=0. f(S)=0+5=5."
),
algo_name="Algorytm A*",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": INF},
current="S",
active_edge=("S", "A"),
step_text=(
"Relaksuj S: A(g=2,f=2+3=5), "
"B(g=5,f=5+4=9). Min f → A(5)."
),
algo_name="A* -- rozwijanie S",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="A",
visited={"S"},
active_edge=("A", "C"),
step_text=(
"Rozwiń A(f=5): A→C: g=2+3=5, "
"f=5+0=5. Min f → C(5) = CEL!"
),
algo_name="A* -- rozwijanie A",
),
),
_make_step(
_StepConfig(
n,
e,
{"S": "0", "A": "2", "B": "5", "C": "5"},
current="C",
visited={"S", "A"},
step_text=(
"Dotarliśmy do C! Koszt=5. "
"A* NIE przetwarza B (3 vs 4 w Dijkstrze)."
),
algo_name="A* -- cel osiągnięty!",
),
),
]
def _comparison_slide() -> CompositeVideoClip:
bg = ColorClip(size=(W, H), color=BG).with_duration(12.0)
title = (
_tc(
text="Porównanie algorytmów",
font_size=40,
color="white",
font=FONT_B,
)
.with_duration(12.0)
.with_position(("center", 40))
)
rows = [
("Cecha", "Dijkstra", "Bellman-Ford", "A*"),
("Typ", "Zachłanny", "Prog. dynamiczne", "Heurystyczny"),
("Problem", "SSSP", "SSSP", "Single-pair"),
("Ujemne wagi", "NIE", "TAK", "NIE"),
("Cykl ujemny", "NIE wykrywa", "TAK wykrywa", "NIE"),
("Złożoność", "O((V+E)log V)", "O(V*E)", "Zależy od h(n)"),
]
clips: list[VideoClip] = [bg, title]
for i, row in enumerate(rows):
y_pos = 120 + i * 85
for j, cell in enumerate(row):
x_pos = 60 + j * 300
fs = 18 if i > 0 else 22
color = "#64B5F6" if i == 0 else "#CFD8DC"
tc = (
_tc(
text=cell,
font_size=fs,
color=color,
font=FONT_B if i == 0 else FONT_R,
)
.with_duration(12.0)
.with_position((x_pos, y_pos))
)
clips.append(tc)
return CompositeVideoClip(clips, size=(W, H)).with_effects(
[FadeIn(0.5), FadeOut(0.5)]
)
def main() -> None:
"""Generate the Q02 shortest path visualization video."""
from python_pkg.praca_magisterska_video._q02_algorithm_steps import (
_astar_steps,
_bellman_ford_steps,
_comparison_slide,
_dijkstra_steps,
)
sections: list[VideoClip] = []
sections.append(

View File

@ -0,0 +1,95 @@
"""Project discovery helpers and shared constants for Repo Explorer."""
from __future__ import annotations
from pathlib import Path
import re
import shutil
from typing import cast
# Strip ANSI/VT100 escape sequences so the Text widget shows plain text
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE.sub("", text)
def _find_terminal() -> list[str]:
"""Return argv prefix for the first available terminal emulator."""
candidates = [
("kitty", ["kitty", "--"]),
("alacritty", ["alacritty", "-e"]),
("konsole", ["konsole", "-e"]),
("gnome-terminal", ["gnome-terminal", "--"]),
("xfce4-terminal", ["xfce4-terminal", "-x"]),
("xterm", ["xterm", "-e"]),
]
for exe, args in candidates:
if shutil.which(exe):
return args
return []
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
IGNORED_DIRS = {
".git",
".venv",
"__pycache__",
"node_modules",
"build",
"target",
".mypy_cache",
".ruff_cache",
}
def _is_ignored(path: Path) -> bool:
return any(part in IGNORED_DIRS for part in path.parts)
def find_projects(root: Path) -> list[dict[str, object]]:
"""Return every directory under *root* that contains a run.sh."""
projects: list[dict[str, object]] = []
for run_sh in sorted(root.rglob("run.sh")):
if _is_ignored(run_sh):
continue
proj_dir = run_sh.parent
rel = proj_dir.relative_to(root)
projects.append({"path": proj_dir, "rel": rel, "name": proj_dir.name})
return projects
def _desc_from_run_sh(run_sh: Path) -> str:
"""Extract leading comment block from run.sh as a description."""
comments: list[str] = []
for line in run_sh.read_text(errors="replace").splitlines():
s = line.strip()
if s.startswith("#!"):
continue
if s.startswith("#"):
comments.append(s[1:].strip())
elif comments:
break
return " ".join(comments)[:300] if comments else ""
def get_description(project_path: Path) -> str:
"""Return a short description from README.md or leading run.sh comments."""
for readme_name in ("README.md", "README.txt", "readme.md"):
readme = project_path / readme_name
if readme.exists():
text = readme.read_text(errors="replace")
for line in text.splitlines():
stripped = line.strip().lstrip("#").strip()
if stripped:
return cast("str", stripped[:300])
run_sh = project_path / "run.sh"
if run_sh.exists():
desc = _desc_from_run_sh(run_sh)
if desc:
return desc
return "(no description)"

View File

@ -0,0 +1,211 @@
"""Process execution mixin for Repo Explorer (embedded PTY and terminal)."""
from __future__ import annotations
import contextlib
import fcntl
import os
import pty
import select
import subprocess
import threading
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.repo_explorer._discovery import REPO_ROOT, _strip_ansi
if TYPE_CHECKING:
from pathlib import Path
class ExecutionMixin:
"""Mixin providing process launch, PTY streaming and stdin forwarding.
Expects the concrete class to define: ``_proc``, ``_master_fd``,
``_terminal_args``, ``_args_var``, ``_stdin_var``, ``_status_var``,
``_run_btn``, ``_stop_btn``, ``_output``, ``_IDLE_FLUSH_TICKS``,
``_selected_path``, and the tkinter ``after`` method.
"""
# Attributes provided by the concrete class (declared for type checkers)
_proc: subprocess.Popen[bytes] | None
_master_fd: int | None
_terminal_args: list[str]
_args_var: tk.StringVar
_stdin_var: tk.StringVar
_status_var: tk.StringVar
_run_btn: ttk.Button # type: ignore[name-defined]
_stop_btn: ttk.Button # type: ignore[name-defined]
_output: tk.Text
_IDLE_FLUSH_TICKS: int
def _selected_path(self) -> Path | None: ...
def after(self, ms: int, *args: object) -> str: ...
# ------------------------------------------------------------------
# Run in external terminal
# ------------------------------------------------------------------
def _run_in_terminal(self) -> None:
path = self._selected_path()
if path is None or not self._terminal_args:
return
args_str = self._args_var.get().strip()
extra = args_str.split() if args_str else []
subprocess.Popen(
[*self._terminal_args, "bash", "run.sh", *extra], cwd=path
)
self._write_output(
f"$ Launched in {self._terminal_args[0]}: "
f"{path.relative_to(REPO_ROOT)}\n",
"info",
)
# ------------------------------------------------------------------
# Run embedded with PTY
# ------------------------------------------------------------------
def _run_embedded(self) -> None:
path = self._selected_path()
if path is None:
return
if self._proc and self._proc.poll() is None:
self._stop()
self._clear()
args_str = self._args_var.get().strip()
extra = args_str.split() if args_str else []
display_cmd = ("bash run.sh " + args_str).strip()
self._write_output(
f"$ {display_cmd} [{path.relative_to(REPO_ROOT)}]\n", "info"
)
master_fd, slave_fd = pty.openpty()
self._master_fd = master_fd
fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
self._proc = subprocess.Popen(
["/usr/bin/bash", "run.sh", *extra],
cwd=path,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
os.close(slave_fd)
self._run_btn.configure(state=tk.DISABLED)
self._stop_btn.configure(state=tk.NORMAL)
self._status_var.set("● running")
threading.Thread(target=self._read_pty, daemon=True).start()
threading.Thread(target=self._wait_proc, daemon=True).start()
@staticmethod
def _decode_buf(buf: bytes) -> str:
"""Decode a byte buffer, strip ANSI codes and carriage returns."""
return _strip_ansi(buf.decode("utf-8", errors="replace").replace("\r", ""))
def _flush_partial_buf(self, buf: bytes) -> None:
"""Flush a partial (no trailing newline) buffer to output."""
text = self._decode_buf(buf)
if text:
self._write_output(text)
def _process_complete_lines(self, buf: bytes) -> bytes:
"""Split buf on newlines, output complete lines, return remainder."""
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
text = self._decode_buf(line)
if text:
self._write_output(text + "\n")
return buf
def _read_pty(self) -> None:
"""Stream PTY output to the widget, stripping ANSI codes.
Partial lines (prompts without a trailing newline) are flushed after
~100 ms of silence so interactive prompts like "Enter value: " appear.
"""
buf = b""
idle_ticks = 0
while self._proc and self._proc.poll() is None:
mfd = self._master_fd
if mfd is None:
break
ready, _, _ = select.select([mfd], [], [], 0.05)
if not ready:
if buf:
idle_ticks += 1
if idle_ticks >= self._IDLE_FLUSH_TICKS:
self._flush_partial_buf(buf)
buf = b""
idle_ticks = 0
continue
idle_ticks = 0
try:
chunk = os.read(mfd, 4096)
except OSError:
break
if not chunk:
break
buf += chunk
buf = self._process_complete_lines(buf)
if buf:
self._flush_partial_buf(buf)
if self._master_fd is not None:
with contextlib.suppress(OSError):
os.close(self._master_fd)
self._master_fd = None
# ------------------------------------------------------------------
# stdin forwarding
# ------------------------------------------------------------------
def _send_stdin(self, _event: object = None) -> None:
text = self._stdin_var.get()
self._stdin_var.set("")
payload = (text + "\n").encode()
if self._master_fd is not None:
with contextlib.suppress(OSError):
os.write(self._master_fd, payload)
def _wait_proc(self) -> None:
if self._proc:
code = self._proc.wait()
self.after(0, self._on_proc_done, code)
def _on_proc_done(self, code: int) -> None:
if code == 0:
self._write_output(f"\n[exited with code {code}]\n", "success")
self._status_var.set("✓ done")
else:
self._write_output(f"\n[exited with code {code}]\n", "error")
self._status_var.set(f"✗ exit {code}")
self._run_btn.configure(state=tk.NORMAL)
self._stop_btn.configure(state=tk.DISABLED)
def _stop(self) -> None:
if self._proc and self._proc.poll() is None:
self._proc.terminate()
self._status_var.set("stopped")
def _clear(self) -> None:
self._output.configure(state=tk.NORMAL)
self._output.delete("1.0", tk.END)
self._output.configure(state=tk.DISABLED)
self._status_var.set("")
def _write_output(self, text: str, tag: str | None = None) -> None:
"""Thread-safe output append via after()."""
self.after(0, self._append_output, text, tag)
def _append_output(self, text: str, tag: str | None) -> None:
self._output.configure(state=tk.NORMAL)
if tag:
self._output.insert(tk.END, text, tag)
else:
self._output.insert(tk.END, text)
self._output.see(tk.END)
self._output.configure(state=tk.DISABLED)

View File

@ -3,119 +3,28 @@
from __future__ import annotations
import contextlib
import fcntl
import os
from pathlib import Path
import pty
import re
import select
import shutil
import subprocess
import threading
import tkinter as tk
from tkinter import font, ttk
from typing import cast
from typing import TYPE_CHECKING, cast
# Strip ANSI/VT100 escape sequences so the Text widget shows plain text
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE.sub("", text)
def _find_terminal() -> list[str]:
"""Return argv prefix for the first available terminal emulator."""
candidates = [
("kitty", ["kitty", "--"]),
("alacritty", ["alacritty", "-e"]),
("konsole", ["konsole", "-e"]),
("gnome-terminal", ["gnome-terminal", "--"]),
("xfce4-terminal", ["xfce4-terminal", "-x"]),
("xterm", ["xterm", "-e"]),
]
for exe, args in candidates:
if shutil.which(exe):
return args
return []
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
IGNORED_DIRS = {
".git",
".venv",
"__pycache__",
"node_modules",
"build",
"target",
".mypy_cache",
".ruff_cache",
}
# ---------------------------------------------------------------------------
# Discovery helpers
# ---------------------------------------------------------------------------
def _is_ignored(path: Path) -> bool:
return any(part in IGNORED_DIRS for part in path.parts)
def find_projects(root: Path) -> list[dict[str, object]]:
"""Return every directory under *root* that contains a run.sh."""
projects: list[dict[str, object]] = []
for run_sh in sorted(root.rglob("run.sh")):
if _is_ignored(run_sh):
continue
proj_dir = run_sh.parent
rel = proj_dir.relative_to(root)
projects.append({"path": proj_dir, "rel": rel, "name": proj_dir.name})
return projects
def _desc_from_run_sh(run_sh: Path) -> str:
"""Extract leading comment block from run.sh as a description."""
comments: list[str] = []
for line in run_sh.read_text(errors="replace").splitlines():
s = line.strip()
if s.startswith("#!"):
continue
if s.startswith("#"):
comments.append(s[1:].strip())
elif comments:
break
return " ".join(comments)[:300] if comments else ""
def get_description(project_path: Path) -> str:
"""Return a short description from README.md or leading run.sh comments."""
for readme_name in ("README.md", "README.txt", "readme.md"):
readme = project_path / readme_name
if readme.exists():
text = readme.read_text(errors="replace")
for line in text.splitlines():
stripped = line.strip().lstrip("#").strip()
if stripped:
return stripped[:300]
run_sh = project_path / "run.sh"
if run_sh.exists():
desc = _desc_from_run_sh(run_sh)
if desc:
return desc
return "(no description)"
from python_pkg.repo_explorer._discovery import (
REPO_ROOT,
_find_terminal,
find_projects,
get_description,
)
from python_pkg.repo_explorer._execution import ExecutionMixin
if TYPE_CHECKING:
import subprocess
# ---------------------------------------------------------------------------
# Main application
# ---------------------------------------------------------------------------
class RepoExplorer(tk.Tk):
class RepoExplorer(ExecutionMixin, tk.Tk):
"""Main application window for browsing and running monorepo projects."""
# Catppuccin Mocha palette
@ -428,173 +337,9 @@ class RepoExplorer(tk.Tk):
)
# ------------------------------------------------------------------
# Run in external terminal (for interactive / keyboard-driven programs)
# Execution methods provided by ExecutionMixin
# ------------------------------------------------------------------
def _run_in_terminal(self) -> None:
path = self._selected_path()
if path is None or not self._terminal_args:
return
args_str = self._args_var.get().strip()
extra = args_str.split() if args_str else []
subprocess.Popen([*self._terminal_args, "bash", "run.sh", *extra], cwd=path)
self._write_output(
f"$ Launched in {self._terminal_args[0]}: {path.relative_to(REPO_ROOT)}\n",
"info",
)
# ------------------------------------------------------------------
# Run embedded with PTY (captures terminal-aware / ncurses output)
# ------------------------------------------------------------------
def _run_embedded(self) -> None:
path = self._selected_path()
if path is None:
return
if self._proc and self._proc.poll() is None:
self._stop()
self._clear()
args_str = self._args_var.get().strip()
extra = args_str.split() if args_str else []
display_cmd = ("bash run.sh " + args_str).strip()
self._write_output(
f"$ {display_cmd} [{path.relative_to(REPO_ROOT)}]\n", "info"
)
master_fd, slave_fd = pty.openpty()
self._master_fd = master_fd
# Non-blocking reads on master so the reader thread doesn't stall
fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
self._proc = subprocess.Popen(
["/usr/bin/bash", "run.sh", *extra],
cwd=path,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
os.close(slave_fd)
self._run_btn.configure(state=tk.DISABLED)
self._stop_btn.configure(state=tk.NORMAL)
self._status_var.set("● running")
threading.Thread(target=self._read_pty, daemon=True).start()
threading.Thread(target=self._wait_proc, daemon=True).start()
@staticmethod
def _decode_buf(buf: bytes) -> str:
"""Decode a byte buffer, strip ANSI codes and carriage returns."""
return _strip_ansi(buf.decode("utf-8", errors="replace").replace("\r", ""))
def _flush_partial_buf(self, buf: bytes) -> None:
"""Flush a partial (no trailing newline) buffer to output."""
text = self._decode_buf(buf)
if text:
self._write_output(text)
def _process_complete_lines(self, buf: bytes) -> bytes:
"""Split buf on newlines, output complete lines, return remainder."""
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
text = self._decode_buf(line)
if text:
self._write_output(text + "\n")
return buf
def _read_pty(self) -> None:
"""Stream PTY output to the widget, stripping ANSI codes.
Partial lines (prompts without a trailing newline) are flushed after
~100 ms of silence so interactive prompts like "Enter value: " appear.
"""
buf = b""
idle_ticks = 0 # consecutive 50 ms timeouts while buf has content
while self._proc and self._proc.poll() is None:
mfd = self._master_fd
if mfd is None:
break
ready, _, _ = select.select([mfd], [], [], 0.05)
if not ready:
# No new data — flush partial buffer after ~100 ms (2 ticks)
if buf:
idle_ticks += 1
if idle_ticks >= self._IDLE_FLUSH_TICKS:
self._flush_partial_buf(buf)
buf = b""
idle_ticks = 0
continue
idle_ticks = 0
try:
chunk = os.read(mfd, 4096)
except OSError:
break
if not chunk:
break
buf += chunk
buf = self._process_complete_lines(buf)
# flush remainder
if buf:
self._flush_partial_buf(buf)
if self._master_fd is not None:
with contextlib.suppress(OSError):
os.close(self._master_fd)
self._master_fd = None
# ------------------------------------------------------------------
# stdin forwarding (typed into the "Send input" field)
# ------------------------------------------------------------------
def _send_stdin(self, _event: object = None) -> None:
text = self._stdin_var.get()
self._stdin_var.set("")
payload = (text + "\n").encode()
if self._master_fd is not None:
with contextlib.suppress(OSError):
os.write(self._master_fd, payload)
def _wait_proc(self) -> None:
if self._proc:
code = self._proc.wait()
self.after(0, self._on_proc_done, code)
def _on_proc_done(self, code: int) -> None:
if code == 0:
self._write_output(f"\n[exited with code {code}]\n", "success")
self._status_var.set("✓ done")
else:
self._write_output(f"\n[exited with code {code}]\n", "error")
self._status_var.set(f"✗ exit {code}")
self._run_btn.configure(state=tk.NORMAL)
self._stop_btn.configure(state=tk.DISABLED)
def _stop(self) -> None:
if self._proc and self._proc.poll() is None:
self._proc.terminate()
self._status_var.set("stopped")
def _clear(self) -> None:
self._output.configure(state=tk.NORMAL)
self._output.delete("1.0", tk.END)
self._output.configure(state=tk.DISABLED)
self._status_var.set("")
def _write_output(self, text: str, tag: str | None = None) -> None:
"""Thread-safe output append via after()."""
self.after(0, self._append_output, text, tag)
def _append_output(self, text: str, tag: str | None) -> None:
self._output.configure(state=tk.NORMAL)
if tag:
self._output.insert(tk.END, text, tag)
else:
self._output.insert(tk.END, text)
self._output.see(tk.END)
self._output.configure(state=tk.DISABLED)
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,188 @@
"""Move scoring, classification, and single-move analysis helpers."""
from __future__ import annotations
from dataclasses import dataclass
import chess
import chess.engine
def score_to_cp(
score: chess.engine.PovScore, *, pov_white: bool
) -> tuple[int | None, int | None]:
"""Return tuple (cp, mate_in) from a PovScore for the given POV color.
If it's a mate score, cp will be None and mate_in will be +/-N
(positive means mate for POV side). If it's a cp score, mate_in will be None.
"""
pov = chess.WHITE if pov_white else chess.BLACK
s = score.pov(pov)
if s.is_mate():
mi = s.mate()
return None, mi
return s.score(mate_score=None), None
# Centipawn loss thresholds for move quality classification (Lichess-like bands)
CP_LOSS_BEST = 10
CP_LOSS_EXCELLENT = 20
CP_LOSS_GOOD = 50
CP_LOSS_INACCURACY = 99
CP_LOSS_MISTAKE = 299
# Centipawn loss thresholds for move classification
_CP_LOSS_BANDS = [
(CP_LOSS_BEST, "Best"),
(CP_LOSS_EXCELLENT, "Excellent"),
(CP_LOSS_GOOD, "Good"),
(CP_LOSS_INACCURACY, "Inaccuracy"),
(CP_LOSS_MISTAKE, "Mistake"),
]
def classify_cp_loss(cp_loss: int | None) -> str:
"""Classify move quality using Lichess-like centipawn loss bands.
Loss is best_eval(cp) - played_eval(cp), from the mover's POV (positive is worse).
Bands (approx, widely cited):
- Best: 0..10 cp
- Excellent: 11..20 cp
- Good: 21..50 cp
- Inaccuracy: 51..99 cp
- Mistake: 100..299 cp
- Blunder: >=300 cp
"""
if cp_loss is None:
return "Unknown"
for threshold, classification in _CP_LOSS_BANDS:
if cp_loss <= threshold:
return classification
return "Blunder"
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
"""Format evaluation score as human-readable string."""
if mate_in is not None:
sign = "+" if mate_in > 0 else ""
return f"M{sign}{mate_in}"
if cp is None:
return "?"
# Convert cp to pawns with sign and 2 decimals
return f"{cp / 100.0:+.2f}"
@dataclass
class MoveAnalysis:
"""Container for single move analysis results."""
san: str
best_san: str
played_cp: int | None
played_mate: int | None
best_cp: int | None
best_mate: int | None
cp_loss: int | None
classification: str
@dataclass
class AnalysisContext:
"""Container for analysis parameters passed between functions."""
engine: chess.engine.SimpleEngine
limit: chess.engine.Limit
multipv: int
def _get_best_move(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
) -> chess.Move | None:
"""Get the engine's best move for a position."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is not None and "pv" in info and info["pv"]:
return info["pv"][0]
res = engine.play(board, limit)
return res.move
def _evaluate_position(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
*,
pov_white: bool,
) -> tuple[int | None, int | None]:
"""Evaluate a position and return (cp, mate_in) from POV."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is None or "score" not in info:
return None, None
return score_to_cp(info["score"], pov_white=pov_white)
def _classify_mate_move(best_mate: int | None, played_mate: int | None) -> str:
"""Classify a move when mate scores are involved."""
if best_mate is None or played_mate is None:
return "Blunder"
if (best_mate > 0) and (played_mate > 0):
if abs(played_mate) > abs(best_mate):
return "Inaccuracy"
return "Best"
if (best_mate < 0) and (played_mate < 0):
if abs(played_mate) < abs(best_mate):
return "Blunder"
return "Best" if abs(played_mate) == abs(best_mate) else "Good"
return "Blunder"
def _analyze_single_move(
ctx: AnalysisContext, board: chess.Board, move: chess.Move
) -> MoveAnalysis:
"""Analyze a single move and return analysis data."""
mover_white = board.turn
san = board.san(move)
best_move = _get_best_move(ctx.engine, board, ctx.limit, ctx.multipv)
best_san = board.san(best_move) if best_move is not None else "?"
board_played = board.copy()
board_played.push(move)
played_cp, played_mate = _evaluate_position(
ctx.engine, board_played, ctx.limit, ctx.multipv, pov_white=mover_white
)
if best_move is not None:
board_best = board.copy()
board_best.push(best_move)
best_cp, best_mate = _evaluate_position(
ctx.engine, board_best, ctx.limit, ctx.multipv, pov_white=mover_white
)
else:
best_cp, best_mate = None, None
cp_loss: int | None = None
if best_mate is not None or played_mate is not None:
classification = _classify_mate_move(best_mate, played_mate)
elif best_cp is not None and played_cp is not None:
cp_loss = max(0, best_cp - played_cp)
classification = classify_cp_loss(cp_loss)
else:
classification = "Unknown"
return MoveAnalysis(
san=san,
best_san=best_san,
played_cp=played_cp,
played_mate=played_mate,
best_cp=best_cp,
best_mate=best_mate,
cp_loss=cp_loss,
classification=classification,
)

View File

@ -23,7 +23,6 @@ from __future__ import annotations
import argparse
import contextlib
from dataclasses import dataclass
import io
import logging
import multiprocessing
@ -47,6 +46,18 @@ except ImportError: # pragma: no cover
_logger.exception(" pip install -r python_pkg/stockfish_analysis/requirements.txt")
raise
from python_pkg.stockfish_analysis._move_analysis import (
AnalysisContext,
MoveAnalysis,
_analyze_single_move,
_classify_mate_move,
_evaluate_position,
_get_best_move,
classify_cp_loss,
fmt_eval,
score_to_cp,
)
# Memory configuration constants
MEMINFO_PARTS_MIN = 2
HIGH_THREAD_COUNT = 16
@ -88,71 +99,6 @@ def extract_pgn_text(raw: str) -> str | None:
return None
def score_to_cp(
score: chess.engine.PovScore, *, pov_white: bool
) -> tuple[int | None, int | None]:
"""Return tuple (cp, mate_in) from a PovScore for the given POV color.
If it's a mate score, cp will be None and mate_in will be +/-N
(positive means mate for POV side). If it's a cp score, mate_in will be None.
"""
pov = chess.WHITE if pov_white else chess.BLACK
s = score.pov(pov)
if s.is_mate():
mi = s.mate()
return None, mi
return s.score(mate_score=None), None
# Centipawn loss thresholds for move quality classification (Lichess-like bands)
CP_LOSS_BEST = 10
CP_LOSS_EXCELLENT = 20
CP_LOSS_GOOD = 50
CP_LOSS_INACCURACY = 99
CP_LOSS_MISTAKE = 299
# Centipawn loss thresholds for move classification
_CP_LOSS_BANDS = [
(CP_LOSS_BEST, "Best"),
(CP_LOSS_EXCELLENT, "Excellent"),
(CP_LOSS_GOOD, "Good"),
(CP_LOSS_INACCURACY, "Inaccuracy"),
(CP_LOSS_MISTAKE, "Mistake"),
]
def classify_cp_loss(cp_loss: int | None) -> str:
"""Classify move quality using Lichess-like centipawn loss bands.
Loss is best_eval(cp) - played_eval(cp), from the mover's POV (positive is worse).
Bands (approx, widely cited):
- Best: 0..10 cp
- Excellent: 11..20 cp
- Good: 21..50 cp
- Inaccuracy: 51..99 cp
- Mistake: 100..299 cp
- Blunder: >=300 cp
"""
if cp_loss is None:
return "Unknown"
for threshold, classification in _CP_LOSS_BANDS:
if cp_loss <= threshold:
return classification
return "Blunder"
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
"""Format evaluation score as human-readable string."""
if mate_in is not None:
sign = "+" if mate_in > 0 else ""
return f"M{sign}{mate_in}"
if cp is None:
return "?"
# Convert cp to pawns with sign and 2 decimals
return f"{cp / 100.0:+.2f}"
def _parse_threads(value: str) -> int | None:
v = value.strip().lower()
if v in ("auto", "max", ""): # auto-detect
@ -221,29 +167,6 @@ def _auto_hash_mb(threads_wanted: int, engine_options: dict[str, object]) -> int
EngineOptions = dict[str, object]
@dataclass
class MoveAnalysis:
"""Container for single move analysis results."""
san: str
best_san: str
played_cp: int | None
played_mate: int | None
best_cp: int | None
best_mate: int | None
cp_loss: int | None
classification: str
@dataclass
class AnalysisContext:
"""Container for analysis parameters passed between functions."""
engine: chess.engine.SimpleEngine
limit: chess.engine.Limit
multipv: int
def _build_argument_parser() -> argparse.ArgumentParser:
"""Build and return the argument parser for the analysis script."""
ap = argparse.ArgumentParser(
@ -444,98 +367,6 @@ def _log_engine_config(
_logger.info("Using engine options: Threads=%s, MultiPV=%s", threads, multipv)
def _get_best_move(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
) -> chess.Move | None:
"""Get the engine's best move for a position."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is not None and "pv" in info and info["pv"]:
return info["pv"][0]
res = engine.play(board, limit)
return res.move
def _evaluate_position(
engine: chess.engine.SimpleEngine,
board: chess.Board,
limit: chess.engine.Limit,
multipv: int,
*,
pov_white: bool,
) -> tuple[int | None, int | None]:
"""Evaluate a position and return (cp, mate_in) from POV."""
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
info = info_raw[0] if isinstance(info_raw, list) else info_raw
if info is None or "score" not in info:
return None, None
return score_to_cp(info["score"], pov_white=pov_white)
def _classify_mate_move(best_mate: int | None, played_mate: int | None) -> str:
"""Classify a move when mate scores are involved."""
if best_mate is None or played_mate is None:
return "Blunder"
if (best_mate > 0) and (played_mate > 0):
if abs(played_mate) > abs(best_mate):
return "Inaccuracy"
return "Best"
if (best_mate < 0) and (played_mate < 0):
if abs(played_mate) < abs(best_mate):
return "Blunder"
return "Best" if abs(played_mate) == abs(best_mate) else "Good"
return "Blunder"
def _analyze_single_move(
ctx: AnalysisContext, board: chess.Board, move: chess.Move
) -> MoveAnalysis:
"""Analyze a single move and return analysis data."""
mover_white = board.turn
san = board.san(move)
best_move = _get_best_move(ctx.engine, board, ctx.limit, ctx.multipv)
best_san = board.san(best_move) if best_move is not None else "?"
board_played = board.copy()
board_played.push(move)
played_cp, played_mate = _evaluate_position(
ctx.engine, board_played, ctx.limit, ctx.multipv, pov_white=mover_white
)
if best_move is not None:
board_best = board.copy()
board_best.push(best_move)
best_cp, best_mate = _evaluate_position(
ctx.engine, board_best, ctx.limit, ctx.multipv, pov_white=mover_white
)
else:
best_cp, best_mate = None, None
cp_loss: int | None = None
if best_mate is not None or played_mate is not None:
classification = _classify_mate_move(best_mate, played_mate)
elif best_cp is not None and played_cp is not None:
cp_loss = max(0, best_cp - played_cp)
classification = classify_cp_loss(cp_loss)
else:
classification = "Unknown"
return MoveAnalysis(
san=san,
best_san=best_san,
played_cp=played_cp,
played_mate=played_mate,
best_cp=best_cp,
best_mate=best_mate,
cp_loss=cp_loss,
classification=classification,
)
def _log_move_analysis(ply: int, result: MoveAnalysis, *, mover_white: bool) -> None:
"""Log a single move's analysis result."""
side = "W" if mover_white else "B"

View File

@ -1,49 +1,25 @@
"""Tests for analyze_chess_game module."""
"""Tests for analyze_chess_game utility and scoring functions."""
from __future__ import annotations
import argparse
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, mock_open, patch
import chess
import chess.engine
import chess.pgn
import pytest
from python_pkg.stockfish_analysis.analyze_chess_game import (
AnalysisContext,
MoveAnalysis,
_analyze_all_moves,
_analyze_last_move,
_analyze_single_move,
_auto_hash_mb,
_build_argument_parser,
_classify_mate_move,
_configure_hash,
_configure_multipv,
_configure_nnue,
_configure_threads,
_detect_total_mem_mb,
_evaluate_position,
_get_best_move,
_load_game,
_log_engine_config,
_log_move_analysis,
_parse_hash_mb,
_parse_threads,
_run_analysis,
_setup_engine,
classify_cp_loss,
extract_pgn_text,
fmt_eval,
main,
score_to_cp,
)
if TYPE_CHECKING:
from pathlib import Path
class TestExtractPgnText:
"""Tests for extract_pgn_text function."""
@ -383,770 +359,3 @@ class TestAutoHashMb:
):
result = _auto_hash_mb(4, {"Hash": NoMaxOpt()})
assert result >= 64
class TestConfigureThreads:
"""Tests for _configure_threads function."""
def test_configure_threads_no_option(self) -> None:
"""Test thread config when engine has no Threads option."""
engine = MagicMock()
result = _configure_threads(engine, {}, 4)
assert result == 4
def test_configure_threads_with_limits(self) -> None:
"""Test thread config respects engine limits."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 8
mock_opt.min = 1
result = _configure_threads(engine, {"Threads": mock_opt}, 16)
assert result == 8
engine.configure.assert_called_once()
def test_configure_threads_auto(self) -> None:
"""Test thread config with auto detection."""
engine = MagicMock()
with patch("multiprocessing.cpu_count", return_value=8):
result = _configure_threads(engine, {}, None)
assert result == 8
def test_configure_threads_exception(self) -> None:
"""Test thread config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = ValueError("Failed")
mock_opt = MagicMock()
mock_opt.max = 8
mock_opt.min = 1
# Should not raise, just log debug
result = _configure_threads(engine, {"Threads": mock_opt}, 4)
assert result == 4
def test_configure_threads_no_max_min(self) -> None:
"""Test thread config when max/min are not integers."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
mock_opt.min = None
result = _configure_threads(engine, {"Threads": mock_opt}, 4)
assert result == 4
class TestConfigureHash:
"""Tests for _configure_hash function."""
def test_configure_hash_no_option(self) -> None:
"""Test hash config when engine has no Hash option."""
engine = MagicMock()
_configure_hash(engine, {}, 512, 4)
engine.configure.assert_not_called()
def test_configure_hash_with_limits(self) -> None:
"""Test hash config respects engine limits."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 1024
mock_opt.min = 16
_configure_hash(engine, {"Hash": mock_opt}, 2048, 4)
engine.configure.assert_called_once()
def test_configure_hash_exception(self) -> None:
"""Test hash config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = TypeError("Failed")
mock_opt = MagicMock()
mock_opt.max = 1024
mock_opt.min = 16
# Should not raise, just log debug
_configure_hash(engine, {"Hash": mock_opt}, 512, 4)
def test_configure_hash_no_max_min(self) -> None:
"""Test hash config when max/min are not integers."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
mock_opt.min = None
_configure_hash(engine, {"Hash": mock_opt}, 512, 4)
engine.configure.assert_called_once()
class TestConfigureMultipv:
"""Tests for _configure_multipv function."""
def test_configure_multipv_no_option(self) -> None:
"""Test MultiPV config when engine has no option."""
engine = MagicMock()
result = _configure_multipv(engine, {}, 3)
assert result == 3
def test_configure_multipv_with_limit(self) -> None:
"""Test MultiPV config respects engine limit."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 2
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 5)
assert result == 2
def test_configure_multipv_exception(self) -> None:
"""Test MultiPV config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = ValueError("Failed")
mock_opt = MagicMock()
mock_opt.max = 5
# Should not raise, just log debug
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3)
assert result == 3
def test_configure_multipv_no_max(self) -> None:
"""Test MultiPV config when max is not integer."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3)
assert result == 3
engine.configure.assert_called_once()
class TestConfigureNnue:
"""Tests for _configure_nnue function."""
def test_configure_nnue_use_nnue(self) -> None:
"""Test NNUE config with 'Use NNUE' option."""
engine = MagicMock()
_configure_nnue(engine, {"Use NNUE": MagicMock()})
engine.configure.assert_called_once_with({"Use NNUE": True})
def test_configure_nnue_usennue(self) -> None:
"""Test NNUE config with 'UseNNUE' option."""
engine = MagicMock()
_configure_nnue(engine, {"UseNNUE": MagicMock()})
engine.configure.assert_called_once_with({"UseNNUE": True})
def test_configure_nnue_not_supported(self) -> None:
"""Test NNUE config when not supported."""
engine = MagicMock()
_configure_nnue(engine, {})
engine.configure.assert_not_called()
class TestBuildArgumentParser:
"""Tests for _build_argument_parser function."""
def test_parser_required_args(self) -> None:
"""Test parser with required arguments."""
parser = _build_argument_parser()
args = parser.parse_args(["test.pgn"])
assert args.file == "test.pgn"
def test_parser_optional_args(self) -> None:
"""Test parser with optional arguments."""
parser = _build_argument_parser()
args = parser.parse_args(
[
"test.pgn",
"--engine",
"sf",
"--time",
"1.0",
"--depth",
"20",
"--multipv",
"3",
"--last-move-only",
]
)
assert args.engine == "sf"
assert args.time == 1.0
assert args.depth == 20
assert args.multipv == 3
assert args.last_move_only is True
class TestLoadGame:
"""Tests for _load_game function."""
def test_load_game_file_not_found(self, tmp_path: Path) -> None:
"""Test loading non-existent file."""
with pytest.raises(SystemExit) as exc:
_load_game(str(tmp_path / "nonexistent.pgn"))
assert exc.value.code == 1
def test_load_game_no_pgn(self, tmp_path: Path) -> None:
"""Test loading file with no PGN content."""
pgn_file = tmp_path / "empty.pgn"
pgn_file.write_text("No PGN here")
with pytest.raises(SystemExit) as exc:
_load_game(str(pgn_file))
assert exc.value.code == 2
def test_load_game_success(self, tmp_path: Path) -> None:
"""Test successful game loading."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *')
game = _load_game(str(pgn_file))
assert game is not None
def test_load_game_invalid_pgn(self, tmp_path: Path) -> None:
"""Test loading file when read_game returns None."""
pgn_file = tmp_path / "invalid.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
# Mock read_game to return None to trigger exit code 3
with (
patch("chess.pgn.read_game", return_value=None),
pytest.raises(SystemExit) as exc,
):
_load_game(str(pgn_file))
assert exc.value.code == 3
class TestSetupEngine:
"""Tests for _setup_engine function."""
def test_setup_engine_not_found(self) -> None:
"""Test engine setup with non-existent engine."""
args = argparse.Namespace(
engine="nonexistent_engine",
time=0.5,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with pytest.raises(SystemExit) as exc:
_setup_engine(args)
assert exc.value.code == 4
def test_setup_engine_with_depth(self) -> None:
"""Test engine setup with depth limit."""
mock_engine = MagicMock()
mock_engine.options = {}
args = argparse.Namespace(
engine="stockfish",
time=0.5,
depth=20,
threads=4,
hash_mb=512,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
engine, _mpv, limit = _setup_engine(args)
assert engine == mock_engine
assert limit.depth == 20
def test_setup_engine_with_time(self) -> None:
"""Test engine setup with time limit."""
mock_engine = MagicMock()
mock_engine.options = {}
args = argparse.Namespace(
engine="stockfish",
time=1.0,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
_engine, _mpv, limit = _setup_engine(args)
assert limit.time == 1.0
def test_setup_engine_options_attr_error(self) -> None:
"""Test engine setup when options raises AttributeError."""
from unittest.mock import PropertyMock
mock_engine = MagicMock()
# Delete the auto-created options attribute first
del mock_engine.options
# Then set up PropertyMock to raise AttributeError
type(mock_engine).options = PropertyMock(side_effect=AttributeError)
args = argparse.Namespace(
engine="stockfish",
time=1.0,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
engine, _mpv, limit = _setup_engine(args)
assert engine == mock_engine
assert limit.time == 1.0
class TestLogEngineConfig:
"""Tests for _log_engine_config function."""
def test_log_with_hash(self) -> None:
"""Test logging config with hash value."""
mock_engine = MagicMock()
mock_hash = MagicMock()
mock_hash.value = 512
mock_engine.options.get.return_value = mock_hash
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
mock_logger.info.assert_called()
def test_log_without_hash(self) -> None:
"""Test logging config without hash value."""
mock_engine = MagicMock()
mock_engine.options.get.return_value = None
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
mock_logger.info.assert_called()
def test_log_with_hash_exception(self) -> None:
"""Test logging config when hash access raises exception."""
from unittest.mock import PropertyMock
mock_engine = MagicMock()
# Make .value access raise
mock_hash = MagicMock()
type(mock_hash).value = PropertyMock(side_effect=TypeError)
mock_engine.options.get.return_value = mock_hash
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
# Should still call info (without hash)
mock_logger.info.assert_called()
class TestGetBestMove:
"""Tests for _get_best_move function."""
def test_get_best_move_from_analysis(self) -> None:
"""Test getting best move from analysis."""
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_engine.analyse.return_value = [{"pv": [mock_move]}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
result = _get_best_move(mock_engine, board, limit, 2)
assert result == mock_move
def test_get_best_move_fallback_to_play(self) -> None:
"""Test getting best move via play when analysis fails."""
mock_engine = MagicMock()
mock_engine.analyse.return_value = [{}]
mock_move = chess.Move.from_uci("e2e4")
mock_engine.play.return_value = MagicMock(move=mock_move)
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
result = _get_best_move(mock_engine, board, limit, 2)
assert result == mock_move
class TestEvaluatePosition:
"""Tests for _evaluate_position function."""
def test_evaluate_position_success(self) -> None:
"""Test successful position evaluation."""
mock_engine = MagicMock()
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 50
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"score": mock_score}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True)
assert cp == 50
assert mate is None
def test_evaluate_position_no_score(self) -> None:
"""Test evaluation with no score."""
mock_engine = MagicMock()
mock_engine.analyse.return_value = [{}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True)
assert cp is None
assert mate is None
class TestClassifyMateMove:
"""Tests for _classify_mate_move function."""
def test_classify_mate_missing_values(self) -> None:
"""Test classification with missing mate values."""
assert _classify_mate_move(None, 2) == "Blunder"
assert _classify_mate_move(2, None) == "Blunder"
def test_classify_mate_both_positive(self) -> None:
"""Test classification with both positive mates."""
assert _classify_mate_move(2, 3) == "Inaccuracy"
assert _classify_mate_move(3, 3) == "Best"
assert _classify_mate_move(3, 2) == "Best"
def test_classify_mate_both_negative(self) -> None:
"""Test classification with both negative mates."""
assert _classify_mate_move(-3, -2) == "Blunder"
assert _classify_mate_move(-2, -2) == "Best"
assert _classify_mate_move(-2, -3) == "Good"
def test_classify_mate_opposite_signs(self) -> None:
"""Test classification with opposite sign mates."""
assert _classify_mate_move(2, -2) == "Blunder"
assert _classify_mate_move(-2, 2) == "Blunder"
class TestAnalyzeSingleMove:
"""Tests for _analyze_single_move function."""
def test_analyze_single_move(self) -> None:
"""Test analyzing a single move."""
mock_engine = MagicMock()
# Mock best move
best_move = chess.Move.from_uci("e2e4")
mock_engine.analyse.return_value = [{"pv": [best_move]}]
# Mock score
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [best_move], "score": mock_score}]
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
assert result.san == "e4"
def test_analyze_single_move_no_best_move(self) -> None:
"""Test analyzing when engine returns no best move."""
mock_engine = MagicMock()
# Mock engine returning no pv
mock_engine.analyse.return_value = [{}]
mock_engine.play.return_value = MagicMock(move=None)
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
assert result.best_san == "?"
def test_analyze_single_move_with_mate(self) -> None:
"""Test analyzing a move with mate score."""
mock_engine = MagicMock()
best_move = chess.Move.from_uci("e2e4")
def mock_analyse(
_board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = True
mock_pov.mate.return_value = 3
mock_score.pov.return_value = mock_pov
return [{"pv": [best_move], "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
def test_analyze_single_move_unknown_classification(self) -> None:
"""Test analyzing when both cp and mate are None."""
mock_engine = MagicMock()
best_move = chess.Move.from_uci("e2e4")
def mock_analyse(
_board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = None
mock_score.pov.return_value = mock_pov
return [{"pv": [best_move], "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert result.classification == "Unknown"
class TestLogMoveAnalysis:
"""Tests for _log_move_analysis function."""
def test_log_move_analysis(self) -> None:
"""Test logging move analysis."""
result = MoveAnalysis(
san="e4",
best_san="e4",
played_cp=30,
played_mate=None,
best_cp=30,
best_mate=None,
cp_loss=0,
classification="Best",
)
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_move_analysis(1, result, mover_white=True)
mock_logger.info.assert_called()
class TestRunAnalysis:
"""Tests for _run_analysis function."""
def test_run_analysis_all_moves(self) -> None:
"""Test running analysis on all moves."""
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
"""Return a legal move for the given position."""
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
node = game.add_variation(chess.Move.from_uci("e2e4"))
node.add_variation(chess.Move.from_uci("e7e5"))
_run_analysis(game, ctx, last_move_only=False)
class TestAnalyzeLastMove:
"""Tests for _analyze_last_move function."""
def test_analyze_last_move_no_moves(self) -> None:
"""Test analyzing last move with no moves."""
mock_engine = MagicMock()
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
board = game.board()
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_analyze_last_move(game, board, ctx)
mock_logger.warning.assert_called_once()
def test_analyze_last_move_with_moves(self) -> None:
"""Test analyzing last move with actual moves."""
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
"""Return a legal move for the given position."""
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
node = game.add_variation(chess.Move.from_uci("e2e4"))
node.add_variation(chess.Move.from_uci("e7e5"))
board = game.board()
_analyze_last_move(game, board, ctx)
class TestAnalyzeAllMoves:
"""Tests for _analyze_all_moves function."""
def test_analyze_all_moves(self) -> None:
"""Test analyzing all moves."""
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
game.add_variation(chess.Move.from_uci("e2e4"))
board = game.board()
_analyze_all_moves(game, board, ctx)
class TestMain:
"""Tests for main function."""
def test_main(self, tmp_path: Path) -> None:
"""Test main function."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
mock_engine.options = {}
with (
patch("sys.argv", ["prog", str(pgn_file)]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()
def test_main_last_move_only(self, tmp_path: Path) -> None:
"""Test main function with --last-move-only flag."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *')
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
mock_engine.options = {}
with (
patch("sys.argv", ["prog", str(pgn_file), "--last-move-only"]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()
def test_main_with_engine_options_attr_error(self, tmp_path: Path) -> None:
"""Test main when engine.options raises AttributeError."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
# Delete auto-created options, then set up PropertyMock to raise
from unittest.mock import PropertyMock
del mock_engine.options
type(mock_engine).options = PropertyMock(side_effect=AttributeError)
with (
patch("sys.argv", ["prog", str(pgn_file)]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()

View File

@ -0,0 +1,357 @@
"""Tests for analyze_chess_game configuration functions."""
from __future__ import annotations
import argparse
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from python_pkg.stockfish_analysis.analyze_chess_game import (
_build_argument_parser,
_configure_hash,
_configure_multipv,
_configure_nnue,
_configure_threads,
_load_game,
_log_engine_config,
_setup_engine,
)
if TYPE_CHECKING:
from pathlib import Path
class TestConfigureThreads:
"""Tests for _configure_threads function."""
def test_configure_threads_no_option(self) -> None:
"""Test thread config when engine has no Threads option."""
engine = MagicMock()
result = _configure_threads(engine, {}, 4)
assert result == 4
def test_configure_threads_with_limits(self) -> None:
"""Test thread config respects engine limits."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 8
mock_opt.min = 1
result = _configure_threads(engine, {"Threads": mock_opt}, 16)
assert result == 8
engine.configure.assert_called_once()
def test_configure_threads_auto(self) -> None:
"""Test thread config with auto detection."""
engine = MagicMock()
with patch("multiprocessing.cpu_count", return_value=8):
result = _configure_threads(engine, {}, None)
assert result == 8
def test_configure_threads_exception(self) -> None:
"""Test thread config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = ValueError("Failed")
mock_opt = MagicMock()
mock_opt.max = 8
mock_opt.min = 1
# Should not raise, just log debug
result = _configure_threads(engine, {"Threads": mock_opt}, 4)
assert result == 4
def test_configure_threads_no_max_min(self) -> None:
"""Test thread config when max/min are not integers."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
mock_opt.min = None
result = _configure_threads(engine, {"Threads": mock_opt}, 4)
assert result == 4
class TestConfigureHash:
"""Tests for _configure_hash function."""
def test_configure_hash_no_option(self) -> None:
"""Test hash config when engine has no Hash option."""
engine = MagicMock()
_configure_hash(engine, {}, 512, 4)
engine.configure.assert_not_called()
def test_configure_hash_with_limits(self) -> None:
"""Test hash config respects engine limits."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 1024
mock_opt.min = 16
_configure_hash(engine, {"Hash": mock_opt}, 2048, 4)
engine.configure.assert_called_once()
def test_configure_hash_exception(self) -> None:
"""Test hash config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = TypeError("Failed")
mock_opt = MagicMock()
mock_opt.max = 1024
mock_opt.min = 16
# Should not raise, just log debug
_configure_hash(engine, {"Hash": mock_opt}, 512, 4)
def test_configure_hash_no_max_min(self) -> None:
"""Test hash config when max/min are not integers."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
mock_opt.min = None
_configure_hash(engine, {"Hash": mock_opt}, 512, 4)
engine.configure.assert_called_once()
class TestConfigureMultipv:
"""Tests for _configure_multipv function."""
def test_configure_multipv_no_option(self) -> None:
"""Test MultiPV config when engine has no option."""
engine = MagicMock()
result = _configure_multipv(engine, {}, 3)
assert result == 3
def test_configure_multipv_with_limit(self) -> None:
"""Test MultiPV config respects engine limit."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = 2
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 5)
assert result == 2
def test_configure_multipv_exception(self) -> None:
"""Test MultiPV config handles exceptions."""
engine = MagicMock()
engine.configure.side_effect = ValueError("Failed")
mock_opt = MagicMock()
mock_opt.max = 5
# Should not raise, just log debug
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3)
assert result == 3
def test_configure_multipv_no_max(self) -> None:
"""Test MultiPV config when max is not integer."""
engine = MagicMock()
mock_opt = MagicMock()
mock_opt.max = None
result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3)
assert result == 3
engine.configure.assert_called_once()
class TestConfigureNnue:
"""Tests for _configure_nnue function."""
def test_configure_nnue_use_nnue(self) -> None:
"""Test NNUE config with 'Use NNUE' option."""
engine = MagicMock()
_configure_nnue(engine, {"Use NNUE": MagicMock()})
engine.configure.assert_called_once_with({"Use NNUE": True})
def test_configure_nnue_usennue(self) -> None:
"""Test NNUE config with 'UseNNUE' option."""
engine = MagicMock()
_configure_nnue(engine, {"UseNNUE": MagicMock()})
engine.configure.assert_called_once_with({"UseNNUE": True})
def test_configure_nnue_not_supported(self) -> None:
"""Test NNUE config when not supported."""
engine = MagicMock()
_configure_nnue(engine, {})
engine.configure.assert_not_called()
class TestBuildArgumentParser:
"""Tests for _build_argument_parser function."""
def test_parser_required_args(self) -> None:
"""Test parser with required arguments."""
parser = _build_argument_parser()
args = parser.parse_args(["test.pgn"])
assert args.file == "test.pgn"
def test_parser_optional_args(self) -> None:
"""Test parser with optional arguments."""
parser = _build_argument_parser()
args = parser.parse_args(
[
"test.pgn",
"--engine",
"sf",
"--time",
"1.0",
"--depth",
"20",
"--multipv",
"3",
"--last-move-only",
]
)
assert args.engine == "sf"
assert args.time == 1.0
assert args.depth == 20
assert args.multipv == 3
assert args.last_move_only is True
class TestLoadGame:
"""Tests for _load_game function."""
def test_load_game_file_not_found(self, tmp_path: Path) -> None:
"""Test loading non-existent file."""
with pytest.raises(SystemExit) as exc:
_load_game(str(tmp_path / "nonexistent.pgn"))
assert exc.value.code == 1
def test_load_game_no_pgn(self, tmp_path: Path) -> None:
"""Test loading file with no PGN content."""
pgn_file = tmp_path / "empty.pgn"
pgn_file.write_text("No PGN here")
with pytest.raises(SystemExit) as exc:
_load_game(str(pgn_file))
assert exc.value.code == 2
def test_load_game_success(self, tmp_path: Path) -> None:
"""Test successful game loading."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *')
game = _load_game(str(pgn_file))
assert game is not None
def test_load_game_invalid_pgn(self, tmp_path: Path) -> None:
"""Test loading file when read_game returns None."""
pgn_file = tmp_path / "invalid.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
# Mock read_game to return None to trigger exit code 3
with (
patch("chess.pgn.read_game", return_value=None),
pytest.raises(SystemExit) as exc,
):
_load_game(str(pgn_file))
assert exc.value.code == 3
class TestSetupEngine:
"""Tests for _setup_engine function."""
def test_setup_engine_not_found(self) -> None:
"""Test engine setup with non-existent engine."""
args = argparse.Namespace(
engine="nonexistent_engine",
time=0.5,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with pytest.raises(SystemExit) as exc:
_setup_engine(args)
assert exc.value.code == 4
def test_setup_engine_with_depth(self) -> None:
"""Test engine setup with depth limit."""
mock_engine = MagicMock()
mock_engine.options = {}
args = argparse.Namespace(
engine="stockfish",
time=0.5,
depth=20,
threads=4,
hash_mb=512,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
engine, _mpv, limit = _setup_engine(args)
assert engine == mock_engine
assert limit.depth == 20
def test_setup_engine_with_time(self) -> None:
"""Test engine setup with time limit."""
mock_engine = MagicMock()
mock_engine.options = {}
args = argparse.Namespace(
engine="stockfish",
time=1.0,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
_engine, _mpv, limit = _setup_engine(args)
assert limit.time == 1.0
def test_setup_engine_options_attr_error(self) -> None:
"""Test engine setup when options raises AttributeError."""
mock_engine = MagicMock()
# Delete the auto-created options attribute first
del mock_engine.options
# Then set up PropertyMock to raise AttributeError
type(mock_engine).options = PropertyMock(side_effect=AttributeError)
args = argparse.Namespace(
engine="stockfish",
time=1.0,
depth=None,
threads=None,
hash_mb=None,
multipv=2,
)
with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine):
engine, _mpv, limit = _setup_engine(args)
assert engine == mock_engine
assert limit.time == 1.0
class TestLogEngineConfig:
"""Tests for _log_engine_config function."""
def test_log_with_hash(self) -> None:
"""Test logging config with hash value."""
mock_engine = MagicMock()
mock_hash = MagicMock()
mock_hash.value = 512
mock_engine.options.get.return_value = mock_hash
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
mock_logger.info.assert_called()
def test_log_without_hash(self) -> None:
"""Test logging config without hash value."""
mock_engine = MagicMock()
mock_engine.options.get.return_value = None
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
mock_logger.info.assert_called()
def test_log_with_hash_exception(self) -> None:
"""Test logging config when hash access raises exception."""
mock_engine = MagicMock()
# Make .value access raise
mock_hash = MagicMock()
type(mock_hash).value = PropertyMock(side_effect=TypeError)
mock_engine.options.get.return_value = mock_hash
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_engine_config(mock_engine, 4, 2)
# Should still call info (without hash)
mock_logger.info.assert_called()

View File

@ -0,0 +1,454 @@
"""Tests for analyze_chess_game analysis and main functions."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, PropertyMock, patch
import chess
import chess.engine
import chess.pgn
from python_pkg.stockfish_analysis.analyze_chess_game import (
AnalysisContext,
MoveAnalysis,
_analyze_all_moves,
_analyze_last_move,
_analyze_single_move,
_classify_mate_move,
_evaluate_position,
_get_best_move,
_log_move_analysis,
_run_analysis,
main,
)
if TYPE_CHECKING:
from pathlib import Path
class TestGetBestMove:
"""Tests for _get_best_move function."""
def test_get_best_move_from_analysis(self) -> None:
"""Test getting best move from analysis."""
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_engine.analyse.return_value = [{"pv": [mock_move]}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
result = _get_best_move(mock_engine, board, limit, 2)
assert result == mock_move
def test_get_best_move_fallback_to_play(self) -> None:
"""Test getting best move via play when analysis fails."""
mock_engine = MagicMock()
mock_engine.analyse.return_value = [{}]
mock_move = chess.Move.from_uci("e2e4")
mock_engine.play.return_value = MagicMock(move=mock_move)
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
result = _get_best_move(mock_engine, board, limit, 2)
assert result == mock_move
class TestEvaluatePosition:
"""Tests for _evaluate_position function."""
def test_evaluate_position_success(self) -> None:
"""Test successful position evaluation."""
mock_engine = MagicMock()
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 50
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"score": mock_score}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True)
assert cp == 50
assert mate is None
def test_evaluate_position_no_score(self) -> None:
"""Test evaluation with no score."""
mock_engine = MagicMock()
mock_engine.analyse.return_value = [{}]
board = chess.Board()
limit = chess.engine.Limit(time=0.1)
cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True)
assert cp is None
assert mate is None
class TestClassifyMateMove:
"""Tests for _classify_mate_move function."""
def test_classify_mate_missing_values(self) -> None:
"""Test classification with missing mate values."""
assert _classify_mate_move(None, 2) == "Blunder"
assert _classify_mate_move(2, None) == "Blunder"
def test_classify_mate_both_positive(self) -> None:
"""Test classification with both positive mates."""
assert _classify_mate_move(2, 3) == "Inaccuracy"
assert _classify_mate_move(3, 3) == "Best"
assert _classify_mate_move(3, 2) == "Best"
def test_classify_mate_both_negative(self) -> None:
"""Test classification with both negative mates."""
assert _classify_mate_move(-3, -2) == "Blunder"
assert _classify_mate_move(-2, -2) == "Best"
assert _classify_mate_move(-2, -3) == "Good"
def test_classify_mate_opposite_signs(self) -> None:
"""Test classification with opposite sign mates."""
assert _classify_mate_move(2, -2) == "Blunder"
assert _classify_mate_move(-2, 2) == "Blunder"
class TestAnalyzeSingleMove:
"""Tests for _analyze_single_move function."""
def test_analyze_single_move(self) -> None:
"""Test analyzing a single move."""
mock_engine = MagicMock()
# Mock best move
best_move = chess.Move.from_uci("e2e4")
mock_engine.analyse.return_value = [{"pv": [best_move]}]
# Mock score
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [best_move], "score": mock_score}]
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
assert result.san == "e4"
def test_analyze_single_move_no_best_move(self) -> None:
"""Test analyzing when engine returns no best move."""
mock_engine = MagicMock()
# Mock engine returning no pv
mock_engine.analyse.return_value = [{}]
mock_engine.play.return_value = MagicMock(move=None)
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
assert result.best_san == "?"
def test_analyze_single_move_with_mate(self) -> None:
"""Test analyzing a move with mate score."""
mock_engine = MagicMock()
best_move = chess.Move.from_uci("e2e4")
def mock_analyse(
_board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = True
mock_pov.mate.return_value = 3
mock_score.pov.return_value = mock_pov
return [{"pv": [best_move], "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert isinstance(result, MoveAnalysis)
def test_analyze_single_move_unknown_classification(self) -> None:
"""Test analyzing when both cp and mate are None."""
mock_engine = MagicMock()
best_move = chess.Move.from_uci("e2e4")
def mock_analyse(
_board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = None
mock_score.pov.return_value = mock_pov
return [{"pv": [best_move], "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
board = chess.Board()
move = chess.Move.from_uci("e2e4")
result = _analyze_single_move(ctx, board, move)
assert result.classification == "Unknown"
class TestLogMoveAnalysis:
"""Tests for _log_move_analysis function."""
def test_log_move_analysis(self) -> None:
"""Test logging move analysis."""
result = MoveAnalysis(
san="e4",
best_san="e4",
played_cp=30,
played_mate=None,
best_cp=30,
best_mate=None,
cp_loss=0,
classification="Best",
)
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_log_move_analysis(1, result, mover_white=True)
mock_logger.info.assert_called()
class TestRunAnalysis:
"""Tests for _run_analysis function."""
def test_run_analysis_all_moves(self) -> None:
"""Test running analysis on all moves."""
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
"""Return a legal move for the given position."""
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
node = game.add_variation(chess.Move.from_uci("e2e4"))
node.add_variation(chess.Move.from_uci("e7e5"))
_run_analysis(game, ctx, last_move_only=False)
class TestAnalyzeLastMove:
"""Tests for _analyze_last_move function."""
def test_analyze_last_move_no_moves(self) -> None:
"""Test analyzing last move with no moves."""
mock_engine = MagicMock()
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
board = game.board()
with patch(
"python_pkg.stockfish_analysis.analyze_chess_game._logger"
) as mock_logger:
_analyze_last_move(game, board, ctx)
mock_logger.warning.assert_called_once()
def test_analyze_last_move_with_moves(self) -> None:
"""Test analyzing last move with actual moves."""
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
"""Return a legal move for the given position."""
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
node = game.add_variation(chess.Move.from_uci("e2e4"))
node.add_variation(chess.Move.from_uci("e7e5"))
board = game.board()
_analyze_last_move(game, board, ctx)
class TestAnalyzeAllMoves:
"""Tests for _analyze_all_moves function."""
def test_analyze_all_moves(self) -> None:
"""Test analyzing all moves."""
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
ctx = AnalysisContext(
engine=mock_engine,
limit=chess.engine.Limit(time=0.1),
multipv=2,
)
game = chess.pgn.Game()
game.add_variation(chess.Move.from_uci("e2e4"))
board = game.board()
_analyze_all_moves(game, board, ctx)
class TestMain:
"""Tests for main function."""
def test_main(self, tmp_path: Path) -> None:
"""Test main function."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
mock_engine.options = {}
with (
patch("sys.argv", ["prog", str(pgn_file)]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()
def test_main_last_move_only(self, tmp_path: Path) -> None:
"""Test main function with --last-move-only flag."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *')
mock_engine = MagicMock()
def mock_analyse(
board: chess.Board, **_kwargs: object
) -> list[dict[str, object]]:
legal_moves = list(board.legal_moves)
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
pv = [legal_moves[0]] if legal_moves else []
return [{"pv": pv, "score": mock_score}]
mock_engine.analyse.side_effect = mock_analyse
mock_engine.options = {}
with (
patch("sys.argv", ["prog", str(pgn_file), "--last-move-only"]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()
def test_main_with_engine_options_attr_error(self, tmp_path: Path) -> None:
"""Test main when engine.options raises AttributeError."""
pgn_file = tmp_path / "game.pgn"
pgn_file.write_text('[Event "Test"]\n\n1. e4 *')
mock_engine = MagicMock()
mock_move = chess.Move.from_uci("e2e4")
mock_score = MagicMock()
mock_pov = MagicMock()
mock_pov.is_mate.return_value = False
mock_pov.score.return_value = 30
mock_score.pov.return_value = mock_pov
mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}]
# Delete auto-created options, then set up PropertyMock to raise
del mock_engine.options
type(mock_engine).options = PropertyMock(side_effect=AttributeError)
with (
patch("sys.argv", ["prog", str(pgn_file)]),
patch(
"chess.engine.SimpleEngine.popen_uci",
return_value=mock_engine,
),
):
main()
mock_engine.quit.assert_called_once()

View File

@ -0,0 +1,309 @@
"""Cache classes for vocabulary curve excerpts and Anki decks."""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any
import python_pkg.word_frequency.cache as _cache_mod
# =============================================================================
# Vocabulary Curve Cache
# =============================================================================
class VocabCurveCache:
"""Cache for vocabulary curve analysis results."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize vocabulary curve cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or _cache_mod.get_cache_dir()) / "excerpts"
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_cache_path(self, file_hash: str, length: int) -> Path:
"""Get path to cache file for given hash and length.
Args:
file_hash: Hash of source file.
length: Excerpt length.
Returns:
Path to cache file.
"""
return self.cache_dir / f"{file_hash[:16]}_{length}.json"
def get(
self, filepath: Path, length: int
) -> tuple[str, list[tuple[str, int]]] | None:
"""Get cached excerpt and words for a file and length.
Args:
filepath: Path to source file.
length: Excerpt length.
Returns:
Tuple of (excerpt, words_with_ranks) or None if not cached.
"""
file_hash = _cache_mod.get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
if not cache_path.exists():
return None
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, KeyError, OSError):
return None
else:
# Verify hash matches
if data.get("file_hash") != file_hash:
return None
excerpt = data["excerpt"]
words = [(w, r) for w, r in data["words"]]
return excerpt, words
def set(
self,
filepath: Path,
length: int,
excerpt: str,
words: list[tuple[str, int]],
) -> None:
"""Store excerpt and words in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
excerpt: The excerpt text.
words: List of (word, rank) tuples.
"""
file_hash = _cache_mod.get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
data = {
"file_hash": file_hash,
"filepath": str(filepath),
"length": length,
"excerpt": excerpt,
"words": [[w, r] for w, r in words],
}
cache_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def clear(self) -> None:
"""Clear all cached excerpts."""
for cache_file in self.cache_dir.glob("*.json"):
cache_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
cache_files = list(self.cache_dir.glob("*.json"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(cache_files),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}
# =============================================================================
# Anki Deck Cache
# =============================================================================
@dataclass(frozen=True)
class AnkiDeckKey:
"""Key parameters for Anki deck cache lookups."""
filepath: Path
length: int
target_lang: str
include_context: bool
all_vocab: bool
class AnkiDeckCache:
"""Cache for generated Anki decks."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize Anki deck cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or _cache_mod.get_cache_dir()) / "anki_decks"
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.cache_dir / "metadata.json"
self._metadata: dict[str, Any] | None = None
def _load_metadata(self) -> dict[str, Any]:
"""Load metadata from disk."""
if self._metadata is None:
if self.metadata_file.exists():
try:
self._metadata = json.loads(
self.metadata_file.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError):
self._metadata = {}
else:
self._metadata = {}
return self._metadata
def _save_metadata(self) -> None:
"""Save metadata to disk."""
if self._metadata is not None:
self.metadata_file.write_text(
json.dumps(self._metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@staticmethod
def _make_key(
file_hash: str,
length: int,
target_lang: str,
*,
include_context: bool,
all_vocab: bool,
) -> str:
"""Create cache key for an Anki deck.
Args:
file_hash: Hash of source file.
length: Excerpt length.
target_lang: Target language.
include_context: Whether context is included.
all_vocab: Whether all vocab is included.
Returns:
Cache key string.
"""
flags = f"ctx{int(include_context)}_all{int(all_vocab)}"
return f"{file_hash[:16]}_{length}_{target_lang}_{flags}"
def get(
self,
key: AnkiDeckKey,
) -> tuple[str, str, int, int] | None:
"""Get cached Anki deck.
Args:
key: Cache key parameters.
Returns:
Tuple of (anki_content, excerpt, num_words, max_rank)
or None.
"""
file_hash = _cache_mod.get_file_hash(key.filepath)
cache_key = self._make_key(
file_hash,
key.length,
key.target_lang,
include_context=key.include_context,
all_vocab=key.all_vocab,
)
metadata = self._load_metadata()
if cache_key not in metadata:
return None
entry = metadata[cache_key]
if entry.get("file_hash") != file_hash:
return None
deck_file = self.cache_dir / f"{cache_key}.txt"
if not deck_file.exists():
return None
try:
content = deck_file.read_text(encoding="utf-8")
return (
content,
entry["excerpt"],
entry["num_words"],
entry["max_rank"],
)
except OSError:
return None
def set(
self,
key: AnkiDeckKey,
anki_content: str,
excerpt: str,
num_words: int,
max_rank: int,
) -> None:
"""Store Anki deck in cache.
Args:
key: Cache key parameters.
anki_content: The Anki deck content.
excerpt: The excerpt text.
num_words: Number of words in deck.
max_rank: Maximum word rank.
"""
file_hash = _cache_mod.get_file_hash(key.filepath)
cache_key = self._make_key(
file_hash,
key.length,
key.target_lang,
include_context=key.include_context,
all_vocab=key.all_vocab,
)
# Save deck content
deck_file = self.cache_dir / f"{cache_key}.txt"
deck_file.write_text(anki_content, encoding="utf-8")
# Update metadata
metadata = self._load_metadata()
metadata[cache_key] = {
"file_hash": file_hash,
"filepath": str(key.filepath),
"length": key.length,
"target_lang": key.target_lang,
"include_context": key.include_context,
"all_vocab": key.all_vocab,
"excerpt": excerpt,
"num_words": num_words,
"max_rank": max_rank,
}
self._save_metadata()
def clear(self) -> None:
"""Clear all cached decks."""
self._metadata = {}
for cache_file in self.cache_dir.glob("*.txt"):
cache_file.unlink()
if self.metadata_file.exists():
self.metadata_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
metadata = self._load_metadata()
cache_files = list(self.cache_dir.glob("*.txt"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(metadata),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}

View File

@ -0,0 +1,191 @@
"""Anki deck building and card formatting."""
from __future__ import annotations
import re
from python_pkg.word_frequency._types import DeckInput
from python_pkg.word_frequency.translator import translate_words_batch
def find_word_contexts(
text: str,
words: list[str],
context_words: int = 5,
) -> dict[str, str]:
"""Find example contexts for each word in the text.
Args:
text: The source text.
words: List of words to find contexts for.
context_words: Number of words of context on each side.
Returns:
Dict mapping word to example context.
"""
# Extract all words preserving positions
all_words = re.findall(r"\b[\w]+\b", text, re.UNICODE)
all_words_lower = [w.lower() for w in all_words]
contexts: dict[str, str] = {}
words_lower = {w.lower() for w in words}
for target in words_lower:
# Find first occurrence
for i, word in enumerate(all_words_lower):
if word == target:
start = max(0, i - context_words)
end = min(len(all_words), i + context_words + 1)
context = " ".join(all_words[start:end])
contexts[target] = f"...{context}..."
break
return contexts
def _format_excerpt_card(
excerpt: str,
excerpt_words: list[tuple[str, int]] | None,
) -> str:
"""Format the excerpt as the first Anki card.
Args:
excerpt: The target excerpt text.
excerpt_words: Words in the excerpt with ranks.
Returns:
Formatted excerpt card line.
"""
excerpt_escaped = excerpt.replace(";", ",")
if excerpt_words:
most_frequent = min(excerpt_words, key=lambda x: x[1])[0]
rarest = max(excerpt_words, key=lambda x: x[1])[0]
if most_frequent != rarest:
pattern_rare = re.compile(
rf"\b({re.escape(rarest)})\b", re.IGNORECASE
)
excerpt_escaped = pattern_rare.sub(
r"<b>\1</b>", excerpt_escaped
)
pattern_freq = re.compile(
rf"\b({re.escape(most_frequent)})\b",
re.IGNORECASE,
)
excerpt_escaped = pattern_freq.sub(
r"<i>\1</i>", excerpt_escaped
)
else:
pattern = re.compile(
rf"\b({re.escape(most_frequent)})\b",
re.IGNORECASE,
)
excerpt_escaped = pattern.sub(
r"<b><i>\1</i></b>", excerpt_escaped
)
return f"\U0001f4d6 TARGET EXCERPT;{excerpt_escaped};#0"
def _build_translation_lookup(
words_with_ranks: list[tuple[str, int]],
source_lang: str,
target_lang: str,
*,
no_translate: bool = False,
) -> dict[str, str]:
"""Build word-to-translation lookup dict.
Args:
words_with_ranks: List of (word, rank) tuples.
source_lang: Source language code.
target_lang: Target language code.
no_translate: If True, use placeholder translations.
Returns:
Dict mapping lowercase word to translation.
"""
words = [w for w, _ in words_with_ranks]
if no_translate:
return {w.lower(): "[TODO]" for w in words}
translations = translate_words_batch(words, source_lang, target_lang)
trans_lookup: dict[str, str] = {}
for result in translations:
if result.success:
trans_lookup[result.source_word.lower()] = (
result.translated_word
)
else:
trans_lookup[result.source_word.lower()] = (
f"[{result.source_word}]"
)
return trans_lookup
def generate_anki_deck(
deck_input: DeckInput,
*,
include_context: bool = False,
no_translate: bool = False,
excerpt: str = "",
excerpt_words: list[tuple[str, int]] | None = None,
) -> str:
"""Generate Anki-compatible deck content.
Args:
deck_input: Core deck data (words, langs, contexts, name).
include_context: Whether to include context in cards.
no_translate: If True, skip translation (use placeholder).
excerpt: The target excerpt text to include in cards.
excerpt_words: Words in the excerpt with ranks.
Returns:
Semicolon-separated content ready for Anki import.
"""
lines: list[str] = []
# Add Anki headers
lines.append("#separator:semicolon")
lines.append("#html:true")
lines.append(f"#deck:{deck_input.deck_name}")
lines.append(f"#tags:vocabulary {deck_input.source_lang}")
if include_context:
lines.append("#columns:Front;Back;Rank;Context")
else:
lines.append("#columns:Front;Back;Rank")
lines.append("") # Empty line before data
if excerpt:
lines.append(_format_excerpt_card(excerpt, excerpt_words))
trans_lookup = _build_translation_lookup(
deck_input.words_with_ranks,
deck_input.source_lang,
deck_input.target_lang,
no_translate=no_translate,
)
# Generate cards
for word, rank in deck_input.words_with_ranks:
translation = trans_lookup.get(word.lower(), f"[{word}]")
# Escape semicolons in fields
word_escaped = word.replace(";", ",")
translation_escaped = translation.replace(";", ",")
if include_context and deck_input.contexts:
context = deck_input.contexts.get(word.lower(), "")
if context:
context_escaped = context.replace(";", ",")
pattern = re.compile(re.escape(word), re.IGNORECASE)
context_escaped = pattern.sub(
f"<b>{word}</b>", context_escaped
)
else:
context_escaped = ""
lines.append(
f"{word_escaped};{translation_escaped}"
f";#{rank};{context_escaped}"
)
else:
lines.append(f"{word_escaped};{translation_escaped};#{rank}")
return "\n".join(lines)

View File

@ -0,0 +1,391 @@
"""Core flashcard generation logic."""
from __future__ import annotations
from pathlib import Path
import subprocess
from python_pkg.word_frequency._deck_builder import (
find_word_contexts,
generate_anki_deck,
)
from python_pkg.word_frequency._parsing import (
parse_inverse_mode_output,
parse_vocabulary_curve_output,
)
from python_pkg.word_frequency._types import (
C_EXECUTABLE,
DeckInput,
FlashcardOptions,
)
from python_pkg.word_frequency.analyzer import read_file
from python_pkg.word_frequency.cache import (
AnkiDeckKey,
get_anki_deck_cache,
get_vocab_curve_cache,
)
from python_pkg.word_frequency.translator import detect_language
def run_vocabulary_curve(
filepath: Path, max_length: int, *, dump_vocab: bool = False
) -> str:
"""Run the C vocabulary_curve executable.
Args:
filepath: Path to the text file.
max_length: Maximum excerpt length.
dump_vocab: If True, also dump all vocabulary up to max rank needed.
Returns:
Output from the executable.
Raises:
FileNotFoundError: If executable not found.
subprocess.CalledProcessError: If execution fails.
"""
if not C_EXECUTABLE.exists():
msg = (
f"C executable not found at {C_EXECUTABLE}. "
"Please compile it first: cd C/vocabulary_curve && make"
)
raise FileNotFoundError(msg)
cmd = [str(C_EXECUTABLE), str(filepath), str(max_length)]
if dump_vocab:
cmd.append("--dump-vocab")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
check=True,
)
return result.stdout
def run_vocabulary_curve_inverse(
filepath: Path, max_vocab: int, *, dump_vocab: bool = False
) -> str:
"""Run the C vocabulary_curve executable in inverse mode.
Args:
filepath: Path to the text file.
max_vocab: Maximum vocabulary size (top N words).
dump_vocab: If True, also dump all vocabulary up to max_vocab.
Returns:
Output from the executable.
Raises:
FileNotFoundError: If executable not found.
subprocess.CalledProcessError: If execution fails.
"""
if not C_EXECUTABLE.exists():
msg = (
f"C executable not found at {C_EXECUTABLE}. "
"Please compile it first: cd C/vocabulary_curve && make"
)
raise FileNotFoundError(msg)
cmd = [str(C_EXECUTABLE), str(filepath), "--max-vocab", str(max_vocab)]
if dump_vocab:
cmd.append("--dump-vocab")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
check=True,
)
return result.stdout
def get_cached_excerpt(
filepath: Path, length: int, *, force: bool = False
) -> tuple[str, list[tuple[str, int]]] | None:
"""Get cached excerpt if available.
Args:
filepath: Path to source file.
length: Excerpt length.
force: If True, ignore cache.
Returns:
Tuple of (excerpt, words) or None if not cached.
"""
if force:
return None
return get_vocab_curve_cache().get(filepath, length)
def cache_excerpt(
filepath: Path, length: int, excerpt: str, words: list[tuple[str, int]]
) -> None:
"""Store excerpt in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
excerpt: The excerpt text.
words: List of (word, rank) tuples.
"""
get_vocab_curve_cache().set(filepath, length, excerpt, words)
def get_cached_deck(
key: AnkiDeckKey,
*,
force: bool = False,
) -> tuple[str, str, int, int] | None:
"""Get cached Anki deck if available.
Args:
key: Cache key parameters.
force: If True, ignore cache.
Returns:
Tuple of (content, excerpt, num_words, max_rank) or None.
"""
if force:
return None
return get_anki_deck_cache().get(key)
def cache_deck(
key: AnkiDeckKey,
anki_content: str,
excerpt: str,
num_words: int,
max_rank: int,
) -> None:
"""Store Anki deck in cache.
Args:
key: Cache key parameters.
anki_content: The deck content.
excerpt: The excerpt text.
num_words: Number of words.
max_rank: Maximum rank.
"""
get_anki_deck_cache().set(
key,
anki_content,
excerpt,
num_words,
max_rank,
)
def _detect_source_language(
filepath: Path,
text: str,
) -> str:
"""Auto-detect source language from file content.
Args:
filepath: Path to source file.
text: Already-read text (may be empty).
Returns:
Detected language code.
Raises:
ValueError: If language cannot be detected.
"""
sample_text = read_file(filepath)[:1000] if not text else text[:1000]
detected = detect_language(sample_text)
if detected is None:
msg = (
"Could not auto-detect source language. "
"Please specify with --from (e.g., --from pl for Polish). "
"Install langdetect for auto-detection: "
"pip install langdetect"
)
raise ValueError(msg)
return detected
def generate_flashcards(
filepath: str | Path,
excerpt_length: int,
options: FlashcardOptions | None = None,
*,
all_vocab: bool = True,
) -> tuple[str, str, int, int]:
"""Generate Anki flashcards for vocabulary needed for an excerpt.
Args:
filepath: Path to the source text file.
excerpt_length: Target excerpt length.
options: Flashcard generation options.
all_vocab: If True, include ALL words rank 1 to max rank.
Returns:
Tuple of (anki_content, excerpt, num_words, max_rank).
"""
if options is None:
options = FlashcardOptions()
filepath = Path(filepath)
deck_key = AnkiDeckKey(
filepath=filepath,
length=excerpt_length,
target_lang=options.target_lang,
include_context=options.include_context,
all_vocab=all_vocab,
)
# Check for cached full deck (if not using no_translate)
if not options.no_translate and not options.force:
cached = get_cached_deck(deck_key)
if cached is not None:
return cached
# Read the text (only needed for context finding)
text = read_file(filepath) if options.include_context else ""
# Auto-detect language if not provided
source_lang = options.source_lang
if source_lang is None:
source_lang = _detect_source_language(filepath, text)
# Run vocabulary curve analysis with vocab dump for all words
output = run_vocabulary_curve(
filepath, excerpt_length, dump_vocab=all_vocab
)
excerpt, excerpt_words, all_vocab_words = parse_vocabulary_curve_output(
output, excerpt_length
)
if not excerpt_words:
msg = f"No words found for excerpt length {excerpt_length}"
raise ValueError(msg)
max_rank = max(rank for _, rank in excerpt_words)
words_with_ranks = (
all_vocab_words if all_vocab and all_vocab_words else excerpt_words
)
contexts = None
if options.include_context:
if not text:
text = read_file(filepath)
words = [w for w, _ in words_with_ranks]
contexts = find_word_contexts(text, words)
deck_name = options.deck_name or f"{filepath.stem}_vocab_{excerpt_length}"
anki_content = generate_anki_deck(
DeckInput(
words_with_ranks=words_with_ranks,
source_lang=source_lang,
target_lang=options.target_lang,
contexts=contexts,
deck_name=deck_name,
),
include_context=options.include_context,
no_translate=options.no_translate,
excerpt=excerpt,
excerpt_words=excerpt_words,
)
if not options.no_translate:
cache_deck(
deck_key,
anki_content,
excerpt,
len(words_with_ranks),
max_rank,
)
return anki_content, excerpt, len(words_with_ranks), max_rank
def generate_flashcards_inverse(
filepath: str | Path,
max_vocab: int,
options: FlashcardOptions | None = None,
) -> tuple[str, str, int, int, int]:
"""Generate Anki flashcards for the longest excerpt using top N words.
This is the inverse mode: given a vocabulary size, find the longest
excerpt that can be understood with only those words.
Args:
filepath: Path to the source text file.
max_vocab: Maximum vocabulary size (top N words to learn).
options: Flashcard generation options.
Returns:
Tuple of (anki_content, excerpt, excerpt_length,
num_words, max_rank_used).
"""
if options is None:
options = FlashcardOptions()
filepath = Path(filepath)
text = read_file(filepath) if options.include_context else ""
source_lang = options.source_lang
if source_lang is None:
source_lang = _detect_source_language(filepath, text)
output = run_vocabulary_curve_inverse(
filepath, max_vocab, dump_vocab=True
)
excerpt, excerpt_length, max_rank_used, all_vocab_words = (
parse_inverse_mode_output(output)
)
if excerpt_length == 0:
msg = (
f"No valid excerpt found using only top {max_vocab} "
"words. Try increasing the vocabulary limit."
)
raise ValueError(msg)
if not all_vocab_words:
msg = f"No vocabulary returned for max_vocab={max_vocab}"
raise ValueError(msg)
words_with_ranks = all_vocab_words
excerpt_word_set = set(excerpt.lower().split())
excerpt_words = [
(w, r)
for w, r in all_vocab_words
if w.lower() in excerpt_word_set
]
contexts = None
if options.include_context:
if not text:
text = read_file(filepath)
words = [w for w, _ in words_with_ranks]
contexts = find_word_contexts(text, words)
deck_name = options.deck_name or f"{filepath.stem}_top{max_vocab}"
anki_content = generate_anki_deck(
DeckInput(
words_with_ranks=words_with_ranks,
source_lang=source_lang,
target_lang=options.target_lang,
contexts=contexts,
deck_name=deck_name,
),
include_context=options.include_context,
no_translate=options.no_translate,
excerpt=excerpt,
excerpt_words=excerpt_words or None,
)
return (
anki_content,
excerpt,
excerpt_length,
len(words_with_ranks),
max_rank_used,
)

View File

@ -0,0 +1,163 @@
"""Batch generation helpers for the learning pipe module."""
from __future__ import annotations
from dataclasses import dataclass
from python_pkg.word_frequency._learning_constants import LessonConfig
from python_pkg.word_frequency.excerpt_finder import find_best_excerpt
import python_pkg.word_frequency.translator as _translator
def _detect_translation_language(
text: str,
config: LessonConfig,
lines: list[str],
) -> tuple[str | None, str | None]:
"""Detect translation settings and return (from, to) pair."""
actual_from = config.translate_from
actual_to = config.translate_to or "en"
if actual_from == "auto" or (
config.translate_to and not config.translate_from
):
detected = _translator.detect_language(text)
if detected:
actual_from = detected
lines.append(f"Detected language: {detected}")
else:
lines.append(
"Warning: Could not detect language "
"(install langdetect: "
"pip install langdetect)"
)
actual_from = None
return actual_from, actual_to
def _format_word_list(
batch_words: list[tuple[str, int]],
start_idx: int,
total_words: int,
translations: dict[str, str],
) -> list[str]:
"""Format the vocabulary word list for a batch."""
lines: list[str] = []
for i, (word, count) in enumerate(
batch_words, start=start_idx + 1,
):
percentage = (count / total_words) * 100
if translations:
trans = translations.get(word, "?")
lines.append(
f" {i:3}. {word:<20} -> {trans:<20}"
f" ({count:,} occurrences, "
f"{percentage:.2f}%)"
)
else:
lines.append(
f" {i:3}. {word:<20}"
f" ({count:,} occurrences, "
f"{percentage:.2f}%)"
)
return lines
@dataclass(frozen=True)
class _LessonContext:
"""Shared context for batch generation."""
text: str
word_counts: dict[str, int]
config: LessonConfig
def _generate_batch_section(
ctx: _LessonContext,
batch_num: int,
batch_words: list[tuple[str, int]],
cumulative_words: list[str],
) -> list[str]:
"""Generate lines for a single batch section."""
config = ctx.config
total_words = sum(ctx.word_counts.values())
start_idx = batch_num * config.batch_size
end_idx = start_idx + config.batch_size
lines: list[str] = []
lines.append("-" * 70)
lines.append(
f"BATCH {batch_num + 1}: Words "
f"{start_idx + 1} - "
f"{min(end_idx, start_idx + len(batch_words))}"
)
lines.append("-" * 70)
lines.append("")
# Get translations if requested
translations: dict[str, str] = {}
do_translate = (
config.translate_from is not None
and config.translate_to is not None
)
if do_translate:
words_to_translate = [word for word, _ in batch_words]
translation_results = _translator.translate_words_batch(
words_to_translate,
config.translate_from, # type: ignore[arg-type]
config.translate_to, # type: ignore[arg-type]
)
translations = {
r.source_word: r.translated_word
for r in translation_results
if r.success
}
lines.append("VOCABULARY TO LEARN:")
lines.append("")
lines.extend(
_format_word_list(
batch_words, start_idx, total_words, translations,
)
)
lines.append("")
# Cumulative coverage
cumulative_count = sum(
ctx.word_counts[w]
for w in cumulative_words
if w in ctx.word_counts
)
coverage = (cumulative_count / total_words) * 100
lines.append(
"After learning these words, "
f"you'll recognize ~{coverage:.1f}% of the text"
)
lines.append("")
# Excerpts
lines.append("PRACTICE EXCERPTS:")
lines.append(
"(Excerpts where your learned vocabulary "
"is most concentrated)"
)
lines.append("")
excerpts = find_best_excerpt(
ctx.text,
cumulative_words,
config.excerpt_length,
case_sensitive=config.case_sensitive,
top_n=config.excerpts_per_batch,
)
for j, excerpt in enumerate(excerpts, 1):
lines.append(
f" Excerpt {j} "
f"({excerpt.match_percentage:.1f}% known words):"
)
lines.append(f' "{excerpt.excerpt}"')
lines.append("")
return lines

View File

@ -0,0 +1,155 @@
"""Constants and configuration for the learning pipe module."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
# Common stopwords for various languages (can be overridden with --stopwords)
DEFAULT_STOPWORDS_EN = frozenset(
{
"the",
"a",
"an",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"can",
"this",
"that",
"these",
"those",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"what",
"which",
"who",
"whom",
"whose",
"where",
"when",
"why",
"how",
"all",
"each",
"every",
"both",
"few",
"more",
"most",
"other",
"some",
"such",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
"too",
"very",
"just",
"as",
"if",
"then",
"because",
"while",
"although",
"though",
"after",
"before",
}
)
def load_stopwords(filepath: str | Path | None) -> frozenset[str]:
"""Load stopwords from a file (one word per line).
Args:
filepath: Path to stopwords file, or None to use defaults.
Returns:
Frozenset of stopwords.
"""
if filepath is None:
return frozenset()
path = Path(filepath)
if not path.exists():
return frozenset()
content = path.read_text(encoding="utf-8")
return frozenset(
word.strip().lower() for word in content.splitlines() if word.strip()
)
@dataclass(frozen=True)
class LessonConfig:
"""Configuration for learning lesson generation."""
batch_size: int = 20
num_batches: int = 1
excerpt_length: int = 30
excerpts_per_batch: int = 3
stopwords: frozenset[str] | None = None
skip_default_stopwords: bool = False
skip_numbers: bool = True
case_sensitive: bool = False
translate_from: str | None = None
translate_to: str | None = None
def _resolve_stopwords(config: LessonConfig) -> frozenset[str]:
"""Resolve combined stopwords from config."""
if config.skip_default_stopwords:
return config.stopwords or frozenset()
return DEFAULT_STOPWORDS_EN | (config.stopwords or frozenset())

View File

@ -0,0 +1,173 @@
"""Parsing functions for vocabulary curve output."""
from __future__ import annotations
import contextlib
import re
from python_pkg.word_frequency._types import (
_MIN_EXCERPT_PARTS,
_MIN_VOCAB_DUMP_PARTS,
)
def _parse_vocab_dump(lines: list[str]) -> list[tuple[str, int]]:
"""Parse VOCAB_DUMP section from output lines.
Args:
lines: Output lines from vocabulary_curve.
Returns:
List of (word, rank) tuples.
"""
all_vocab: list[tuple[str, int]] = []
in_vocab_dump = False
for line in lines:
stripped = line.strip()
if stripped == "VOCAB_DUMP_START":
in_vocab_dump = True
continue
if stripped == "VOCAB_DUMP_END":
break
if in_vocab_dump and ";" in stripped:
parts = stripped.split(";")
if len(parts) == _MIN_VOCAB_DUMP_PARTS:
word, rank_str = parts
with contextlib.suppress(ValueError):
all_vocab.append((word, int(rank_str)))
return all_vocab
def _parse_excerpt_lines(lines: list[str], start: int) -> str:
"""Parse excerpt text from output lines starting after 'Excerpt:'.
Args:
lines: Output lines.
start: Index of the line after 'Excerpt:'.
Returns:
Joined excerpt text.
"""
excerpt_parts: list[str] = []
idx = start
while idx < len(lines):
next_line = lines[idx].strip()
next_line = next_line.removeprefix('"')
if next_line.endswith('"'):
next_line = next_line[:-1]
excerpt_parts.append(next_line)
break
excerpt_parts.append(next_line)
idx += 1
return " ".join(excerpt_parts)
def parse_inverse_mode_output(
output: str,
) -> tuple[str, int, int, list[tuple[str, int]]]:
"""Parse output from vocabulary_curve inverse mode.
Args:
output: Raw output from vocabulary_curve --max-vocab.
Returns:
Tuple of (excerpt_text, excerpt_length, max_rank_used, all_vocab_words).
"""
lines = output.split("\n")
excerpt = ""
excerpt_length = 0
max_rank_used = 0
for i, raw_line in enumerate(lines):
line = raw_line.strip()
if line.startswith("LONGEST EXCERPT:"):
parts = line.split()
if len(parts) >= _MIN_EXCERPT_PARTS:
excerpt_length = int(parts[2])
elif line.startswith("Excerpt:"):
excerpt = _parse_excerpt_lines(lines, i + 1)
elif line.startswith("Rarest word used:"):
match = re.search(r"\(#(\d+)\)", line)
if match:
max_rank_used = int(match.group(1))
all_vocab = _parse_vocab_dump(lines)
return excerpt, excerpt_length, max_rank_used, all_vocab
def _parse_target_length_block(
lines: list[str],
target_length: int,
) -> tuple[str, list[tuple[str, int]]]:
"""Parse the [Length N] block from vocabulary curve output.
Args:
lines: Output lines.
target_length: Target excerpt length to find.
Returns:
Tuple of (excerpt, excerpt_words).
"""
excerpt = ""
excerpt_words: list[tuple[str, int]] = []
i = 0
while i < len(lines):
if lines[i].strip().startswith(f"[Length {target_length}]"):
i += 1
# Find excerpt line
while i < len(lines) and not lines[i].strip().startswith(
"Excerpt:"
):
i += 1
if i < len(lines):
excerpt_line = lines[i].strip()
if '"' in excerpt_line:
start = excerpt_line.index('"') + 1
end = excerpt_line.rindex('"')
excerpt = excerpt_line[start:end]
# Find words line
i += 1
while i < len(lines) and not lines[i].strip().startswith(
"Words:"
):
i += 1
if i < len(lines):
words_line = lines[i].strip()
if words_line.startswith("Words:"):
words_part = words_line[6:].strip()
pattern = r"(\S+)\(#(\d+)\)"
matches = re.findall(pattern, words_part)
excerpt_words = [
(w, int(r)) for w, r in matches
]
break
i += 1
return excerpt, excerpt_words
def parse_vocabulary_curve_output(
output: str, target_length: int
) -> tuple[str, list[tuple[str, int]], list[tuple[str, int]]]:
"""Parse output from vocabulary_curve to get words needed.
Args:
output: Raw output from vocabulary_curve.
target_length: The target excerpt length.
Returns:
Tuple of (excerpt_text, excerpt_words, all_vocab_words).
excerpt_words: words in the excerpt with their ranks.
all_vocab_words: all words up to max rank
(from VOCAB_DUMP if present).
"""
lines = output.split("\n")
excerpt, excerpt_words = _parse_target_length_block(
lines, target_length
)
all_vocab = _parse_vocab_dump(lines)
return excerpt, excerpt_words, all_vocab

View File

@ -0,0 +1,230 @@
"""Command-line interface for the translator module.
Provides argument parsing, CLI handlers, and the main entry point
for the offline translator using Argos Translate.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import python_pkg.word_frequency.translator as _trans
if TYPE_CHECKING:
from collections.abc import Sequence
logger = __import__("logging").getLogger(__name__)
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the translator CLI."""
parser = argparse.ArgumentParser(
description="Offline translator using Argos Translate.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
action_group = parser.add_mutually_exclusive_group()
action_group.add_argument(
"--list-languages",
"-l",
action="store_true",
help="List installed languages",
)
action_group.add_argument(
"--list-available",
"-L",
action="store_true",
help="List available language packages for download",
)
action_group.add_argument(
"--download",
"-d",
nargs="+",
metavar="LANG",
help=(
"Download language packs "
"(e.g., --download en es pl)"
),
)
input_group = parser.add_mutually_exclusive_group()
input_group.add_argument(
"--text",
"-t",
type=str,
help="Single text/word to translate",
)
input_group.add_argument(
"--words",
"-w",
nargs="+",
help="Words to translate",
)
input_group.add_argument(
"--words-file",
"-W",
type=str,
help="File with words to translate (one per line)",
)
parser.add_argument(
"--from",
"-f",
dest="from_lang",
type=str,
default="en",
help="Source language code (default: en)",
)
parser.add_argument(
"--to",
"-T",
dest="to_lang",
type=str,
default="en",
help="Target language code (default: en)",
)
parser.add_argument(
"--output",
"-o",
type=str,
help="Output file path",
)
return parser
def _handle_list_languages() -> int:
"""Handle --list-languages command."""
langs = _trans.get_installed_languages()
if not langs:
sys.stdout.write("No languages installed.\n")
sys.stdout.write(
"Download some with: --download en es pl de fr\n",
)
else:
sys.stdout.write("Installed languages:\n")
for code, name in sorted(langs):
sys.stdout.write(f" {code}: {name}\n")
return 0
def _handle_list_available() -> int:
"""Handle --list-available command."""
packages = _trans.get_available_packages()
if not packages:
sys.stdout.write(
"No packages available "
"(check internet connection).\n",
)
else:
sys.stdout.write("Available language packages:\n")
for from_code, from_name, to_code, to_name in sorted(
packages,
):
sys.stdout.write(
f" {from_code} ({from_name})"
f" -> {to_code} ({to_name})\n",
)
return 0
def _handle_download(lang_codes: list[str]) -> int:
"""Handle --download command."""
download_results = _trans.download_languages(lang_codes)
success_count = sum(
1 for v in download_results.values() if v
)
sys.stdout.write(
f"\nDownloaded {success_count}/"
f"{len(download_results)} language pairs.\n",
)
return 0 if success_count > 0 else 1
def _collect_words(
args: argparse.Namespace,
) -> list[str] | None:
"""Collect words from args. Returns None on error."""
if args.text:
return [args.text]
if args.words:
return args.words
if args.words_file:
try:
content = _trans.read_file(args.words_file)
except FileNotFoundError:
sys.stderr.write(
f"Error: File not found: {args.words_file}\n",
)
return None
return [
w.strip()
for w in content.splitlines()
if w.strip()
]
return []
def _handle_translation(args: argparse.Namespace) -> int:
"""Handle the translation action."""
try:
results = _trans.translate_words_batch(
args.words, args.from_lang, args.to_lang,
)
except ImportError:
logger.exception("Translation import error")
return 1
output = _trans.format_translations(results)
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
sys.stdout.write(
f"Translations written to {args.output}\n",
)
else:
sys.stdout.write(output + "\n")
if any(not r.success for r in results):
return 1
return 0
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point for the translator.
Args:
argv: Command line arguments.
Returns:
Exit code.
"""
parser = _build_parser()
args = parser.parse_args(argv)
if not _trans._check_argos():
sys.stderr.write(
"Error: argostranslate is not installed.\n"
"Install it with: pip install argostranslate\n",
)
return 1
if args.list_languages:
return _handle_list_languages()
if args.list_available:
return _handle_list_available()
if args.download:
return _handle_download(args.download)
words = _collect_words(args)
if not words:
if words is not None:
parser.print_help()
return 1
args.words = words
return _handle_translation(args)

View File

@ -0,0 +1,312 @@
"""Helper utilities for the translator module.
Contains GPU initialization, backend availability checks, language detection,
translation result types, formatting, and Argos Translate setup functions.
"""
from __future__ import annotations
import importlib
import logging
import os
from pathlib import Path
import subprocess
import sys
from typing import NamedTuple
try:
import torch
except ImportError:
torch = None # type: ignore[assignment]
try:
import argostranslate.package
import argostranslate.translate
except ImportError:
argostranslate = None # type: ignore[assignment]
try:
from deep_translator import GoogleTranslator
except ImportError:
GoogleTranslator = None
try:
import langdetect
except ImportError:
langdetect = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
_LANG_DETECT_SAMPLE_SIZE = 5000
class _TranslatorState:
"""Holds module-level state for lazy-initialized backends."""
gpu_initialized: bool = False
def _check_cuda_available() -> bool:
"""Check if CUDA is available for GPU acceleration."""
return torch is not None and torch.cuda.is_available()
def _validate_gpu_device() -> str:
"""Validate GPU device availability and return device name.
Raises:
RuntimeError: If no GPU devices are found.
"""
device_count = torch.cuda.device_count()
if device_count == 0:
msg = "CUDA reports available but no GPU devices found"
raise RuntimeError(msg)
return torch.cuda.get_device_name(0)
def _init_gpu_if_available() -> None:
"""Initialize GPU for argostranslate if CUDA is available.
Raises:
RuntimeError: If CUDA is available but GPU init fails.
"""
if _TranslatorState.gpu_initialized:
return
if not _check_cuda_available():
_TranslatorState.gpu_initialized = True
return
logger.info(
"CUDA detected, initializing GPU acceleration..."
)
try:
device_name = _validate_gpu_device()
logger.info(" Using GPU: %s", device_name)
os.environ["CT2_CUDA_ALLOW_FP16"] = "1"
os.environ["CT2_USE_EXPERIMENTAL_PACKED_GEMM"] = "1"
_TranslatorState.gpu_initialized = True
logger.info(" GPU acceleration enabled.")
except Exception as e:
msg = (
f"CUDA is available but GPU initialization failed: "
f"{e}\nThis may be due to incompatible CUDA "
"version or driver issues.\n"
"To disable GPU and use CPU only, set "
"environment variable: CT2_FORCE_CPU=1"
)
raise RuntimeError(msg) from e
def _check_deep_translator() -> bool:
"""Check if deep-translator is available."""
return GoogleTranslator is not None
def _check_langdetect() -> bool:
"""Check if langdetect is available."""
return langdetect is not None
def detect_language(text: str) -> str | None:
"""Detect the language of a text.
Args:
text: The text to analyze.
Returns:
ISO 639-1 language code (e.g., 'en', 'la', 'pl') or None if detection fails.
"""
if not _check_langdetect():
return None
try:
sample = (
text[:_LANG_DETECT_SAMPLE_SIZE]
if len(text) > _LANG_DETECT_SAMPLE_SIZE
else text
)
return langdetect.detect(sample) # type: ignore[no-any-return,union-attr]
except langdetect.LangDetectException: # type: ignore[attr-defined,union-attr]
return None
class TranslationResult(NamedTuple):
"""Result of a translation."""
source_word: str
translated_word: str
source_lang: str
target_lang: str
success: bool
error: str | None = None
def format_translations(
results: list[TranslationResult],
*,
show_errors: bool = True,
) -> str:
"""Format translation results as a table.
Args:
results: List of TranslationResult to format.
show_errors: If True, show error messages for failed translations.
Returns:
Formatted string with translations.
"""
if not results:
return "No translations."
lines: list[str] = []
# Find max widths
max_source = max(len(r.source_word) for r in results)
max_source = max(max_source, 6) # "Source" header
successful_lengths = [len(r.translated_word) for r in results if r.success]
max_trans = max(successful_lengths) if successful_lengths else 0
max_trans = max(max_trans, 11) # "Translation" header minimum
# Header
from_lang = results[0].source_lang
to_lang = results[0].target_lang
lines.append(f"Translation: {from_lang} -> {to_lang}")
lines.append("")
lines.append(f"{'Source':<{max_source}} {'Translation':<{max_trans}}")
lines.append("-" * (max_source + max_trans + 2))
# Data
for r in results:
if r.success:
lines.append(
f"{r.source_word:<{max_source}} {r.translated_word:<{max_trans}}"
)
elif show_errors:
error_msg = f"[Error: {r.error}]" if r.error else "[Failed]"
lines.append(f"{r.source_word:<{max_source}} {error_msg}")
return "\n".join(lines)
def read_file(filepath: str | Path) -> str:
"""Read text content from a file."""
return Path(filepath).read_text(encoding="utf-8")
def _ensure_argos_installed() -> None:
"""Ensure argostranslate is installed, attempt installation if not.
Raises:
ImportError: If argos cannot be installed.
"""
if argostranslate is not None:
return
logger.info("argostranslate not found. Attempting to install...")
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "argostranslate"],
check=True,
capture_output=True,
)
# Attempt runtime re-import
importlib.import_module("argostranslate.package")
importlib.import_module("argostranslate.translate")
logger.info("argostranslate installed successfully.")
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if e.stderr else str(e)
msg = (
"argostranslate is required for offline "
"translation.\n\n"
"Install manually with one of:\n"
" pip install argostranslate"
" # In a virtualenv\n"
" pipx install argostranslate"
" # System-wide via pipx\n"
" pacman -S python-argostranslate"
" # Arch Linux (if available)\n\n"
f"Original error: {error_msg}"
)
raise ImportError(msg) from e
except ImportError:
msg = (
"argostranslate installation succeeded but "
"import failed"
)
raise ImportError(msg) from None
def _ensure_language_pair(from_lang: str, to_lang: str) -> None:
"""Ensure the language pair is available, download if needed.
Args:
from_lang: Source language code.
to_lang: Target language code.
Raises:
ValueError: If language pair cannot be obtained.
"""
installed_languages = (
argostranslate.translate.get_installed_languages()
)
from_lang_obj = None
to_lang_obj = None
for lang in installed_languages:
if lang.code == from_lang:
from_lang_obj = lang
if lang.code == to_lang:
to_lang_obj = lang
if from_lang_obj and to_lang_obj:
# Check if translation is available
translation = from_lang_obj.get_translation(to_lang_obj)
if translation:
return # Already available
# Need to download
logger.info(
"Downloading language pack: %s -> %s...",
from_lang,
to_lang,
)
logger.info(" Fetching package index...")
argostranslate.package.update_package_index()
available = argostranslate.package.get_available_packages()
pkg = next(
(
p
for p in available
if p.from_code == from_lang and p.to_code == to_lang
),
None,
)
if pkg is None:
msg = (
f"No language pack available for "
f"{from_lang} -> {to_lang}. "
"Available pairs can be listed with "
"--list-languages."
)
raise ValueError(msg)
logger.info(
" Downloading package (~50-100MB, "
"this may take a minute)...",
)
download_path = pkg.download()
logger.info(" Installing language pack...")
argostranslate.package.install_from_path(download_path)
logger.info(
"Language pack %s -> %s installed.",
from_lang,
to_lang,
)

View File

@ -0,0 +1,50 @@
"""Shared types and constants for the Anki generator."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
_MIN_VOCAB_DUMP_PARTS = 2
_MIN_EXCERPT_PARTS = 3
_ONE_KB = 1024
_ONE_MB = 1024 * 1024
@dataclass(frozen=True)
class FlashcardOptions:
"""Options for flashcard generation."""
source_lang: str | None = None
target_lang: str = "en"
deck_name: str | None = None
include_context: bool = False
no_translate: bool = False
force: bool = False
@dataclass(frozen=True)
class DeckInput:
"""Input data for Anki deck generation."""
words_with_ranks: list[tuple[str, int]]
source_lang: str
target_lang: str = "en"
contexts: dict[str, str] | None = None
deck_name: str = "Vocabulary"
# Path to C vocabulary_curve executable
C_EXECUTABLE = (
Path(__file__).parent.parent.parent / "C" / "vocabulary_curve" / "vocabulary_curve"
)
class VocabWord(NamedTuple):
"""A vocabulary word with its metadata."""
word: str
rank: int
translation: str
context: str

View File

@ -30,795 +30,68 @@ Output:
from __future__ import annotations
import argparse
import contextlib
from dataclasses import dataclass
import logging
from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
try:
from python_pkg.word_frequency.analyzer import read_file
from python_pkg.word_frequency.cache import (
AnkiDeckKey,
clear_all_caches,
get_all_cache_stats,
get_anki_deck_cache,
get_vocab_curve_cache,
)
from python_pkg.word_frequency.translator import (
detect_language,
translate_words_batch,
)
except ImportError:
from analyzer import read_file
from cache import (
AnkiDeckKey,
clear_all_caches,
get_all_cache_stats,
get_anki_deck_cache,
get_vocab_curve_cache,
)
from translator import detect_language, translate_words_batch
from python_pkg.word_frequency._deck_builder import (
find_word_contexts,
generate_anki_deck,
)
from python_pkg.word_frequency._generation import (
cache_deck,
cache_excerpt,
generate_flashcards,
generate_flashcards_inverse,
get_cached_deck,
get_cached_excerpt,
run_vocabulary_curve,
run_vocabulary_curve_inverse,
)
from python_pkg.word_frequency._parsing import (
parse_inverse_mode_output,
parse_vocabulary_curve_output,
)
from python_pkg.word_frequency._types import (
_ONE_KB,
_ONE_MB,
C_EXECUTABLE,
DeckInput,
FlashcardOptions,
VocabWord,
)
from python_pkg.word_frequency.cache import (
clear_all_caches,
get_all_cache_stats,
)
logger = logging.getLogger(__name__)
_MIN_VOCAB_DUMP_PARTS = 2
_MIN_EXCERPT_PARTS = 3
_ONE_KB = 1024
_ONE_MB = 1024 * 1024
@dataclass(frozen=True)
class FlashcardOptions:
"""Options for flashcard generation."""
source_lang: str | None = None
target_lang: str = "en"
deck_name: str | None = None
include_context: bool = False
no_translate: bool = False
force: bool = False
@dataclass(frozen=True)
class DeckInput:
"""Input data for Anki deck generation."""
words_with_ranks: list[tuple[str, int]]
source_lang: str
target_lang: str = "en"
contexts: dict[str, str] | None = None
deck_name: str = "Vocabulary"
# Path to C vocabulary_curve executable
C_EXECUTABLE = (
Path(__file__).parent.parent.parent / "C" / "vocabulary_curve" / "vocabulary_curve"
)
class VocabWord(NamedTuple):
"""A vocabulary word with its metadata."""
word: str
rank: int
translation: str
context: str
def run_vocabulary_curve(
filepath: Path, max_length: int, *, dump_vocab: bool = False
) -> str:
"""Run the C vocabulary_curve executable.
Args:
filepath: Path to the text file.
max_length: Maximum excerpt length.
dump_vocab: If True, also dump all vocabulary up to max rank needed.
Returns:
Output from the executable.
Raises:
FileNotFoundError: If executable not found.
subprocess.CalledProcessError: If execution fails.
"""
if not C_EXECUTABLE.exists():
msg = (
f"C executable not found at {C_EXECUTABLE}. "
"Please compile it first: cd C/vocabulary_curve && make"
)
raise FileNotFoundError(msg)
cmd = [str(C_EXECUTABLE), str(filepath), str(max_length)]
if dump_vocab:
cmd.append("--dump-vocab")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
check=True,
)
return result.stdout
def run_vocabulary_curve_inverse(
filepath: Path, max_vocab: int, *, dump_vocab: bool = False
) -> str:
"""Run the C vocabulary_curve executable in inverse mode.
Args:
filepath: Path to the text file.
max_vocab: Maximum vocabulary size (top N words).
dump_vocab: If True, also dump all vocabulary up to max_vocab.
Returns:
Output from the executable.
Raises:
FileNotFoundError: If executable not found.
subprocess.CalledProcessError: If execution fails.
"""
if not C_EXECUTABLE.exists():
msg = (
f"C executable not found at {C_EXECUTABLE}. "
"Please compile it first: cd C/vocabulary_curve && make"
)
raise FileNotFoundError(msg)
cmd = [str(C_EXECUTABLE), str(filepath), "--max-vocab", str(max_vocab)]
if dump_vocab:
cmd.append("--dump-vocab")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
check=True,
)
return result.stdout
def _parse_vocab_dump(lines: list[str]) -> list[tuple[str, int]]:
"""Parse VOCAB_DUMP section from output lines.
Args:
lines: Output lines from vocabulary_curve.
Returns:
List of (word, rank) tuples.
"""
all_vocab: list[tuple[str, int]] = []
in_vocab_dump = False
for line in lines:
stripped = line.strip()
if stripped == "VOCAB_DUMP_START":
in_vocab_dump = True
continue
if stripped == "VOCAB_DUMP_END":
break
if in_vocab_dump and ";" in stripped:
parts = stripped.split(";")
if len(parts) == _MIN_VOCAB_DUMP_PARTS:
word, rank_str = parts
with contextlib.suppress(ValueError):
all_vocab.append((word, int(rank_str)))
return all_vocab
def _parse_excerpt_lines(lines: list[str], start: int) -> str:
"""Parse excerpt text from output lines starting after 'Excerpt:'.
Args:
lines: Output lines.
start: Index of the line after 'Excerpt:'.
Returns:
Joined excerpt text.
"""
excerpt_parts: list[str] = []
idx = start
while idx < len(lines):
next_line = lines[idx].strip()
next_line = next_line.removeprefix('"')
if next_line.endswith('"'):
next_line = next_line[:-1]
excerpt_parts.append(next_line)
break
excerpt_parts.append(next_line)
idx += 1
return " ".join(excerpt_parts)
def parse_inverse_mode_output(
output: str,
) -> tuple[str, int, int, list[tuple[str, int]]]:
"""Parse output from vocabulary_curve inverse mode.
Args:
output: Raw output from vocabulary_curve --max-vocab.
Returns:
Tuple of (excerpt_text, excerpt_length, max_rank_used, all_vocab_words).
"""
lines = output.split("\n")
excerpt = ""
excerpt_length = 0
max_rank_used = 0
for i, raw_line in enumerate(lines):
line = raw_line.strip()
if line.startswith("LONGEST EXCERPT:"):
parts = line.split()
if len(parts) >= _MIN_EXCERPT_PARTS:
excerpt_length = int(parts[2])
elif line.startswith("Excerpt:"):
excerpt = _parse_excerpt_lines(lines, i + 1)
elif line.startswith("Rarest word used:"):
match = re.search(r"\(#(\d+)\)", line)
if match:
max_rank_used = int(match.group(1))
all_vocab = _parse_vocab_dump(lines)
return excerpt, excerpt_length, max_rank_used, all_vocab
def _parse_target_length_block(
lines: list[str],
target_length: int,
) -> tuple[str, list[tuple[str, int]]]:
"""Parse the [Length N] block from vocabulary curve output.
Args:
lines: Output lines.
target_length: Target excerpt length to find.
Returns:
Tuple of (excerpt, excerpt_words).
"""
excerpt = ""
excerpt_words: list[tuple[str, int]] = []
i = 0
while i < len(lines):
if lines[i].strip().startswith(f"[Length {target_length}]"):
i += 1
# Find excerpt line
while i < len(lines) and not lines[i].strip().startswith(
"Excerpt:"
):
i += 1
if i < len(lines):
excerpt_line = lines[i].strip()
if '"' in excerpt_line:
start = excerpt_line.index('"') + 1
end = excerpt_line.rindex('"')
excerpt = excerpt_line[start:end]
# Find words line
i += 1
while i < len(lines) and not lines[i].strip().startswith(
"Words:"
):
i += 1
if i < len(lines):
words_line = lines[i].strip()
if words_line.startswith("Words:"):
words_part = words_line[6:].strip()
pattern = r"(\S+)\(#(\d+)\)"
matches = re.findall(pattern, words_part)
excerpt_words = [
(w, int(r)) for w, r in matches
]
break
i += 1
return excerpt, excerpt_words
def parse_vocabulary_curve_output(
output: str, target_length: int
) -> tuple[str, list[tuple[str, int]], list[tuple[str, int]]]:
"""Parse output from vocabulary_curve to get words needed.
Args:
output: Raw output from vocabulary_curve.
target_length: The target excerpt length.
Returns:
Tuple of (excerpt_text, excerpt_words, all_vocab_words).
excerpt_words: words in the excerpt with their ranks.
all_vocab_words: all words up to max rank
(from VOCAB_DUMP if present).
"""
lines = output.split("\n")
excerpt, excerpt_words = _parse_target_length_block(
lines, target_length
)
all_vocab = _parse_vocab_dump(lines)
return excerpt, excerpt_words, all_vocab
def find_word_contexts(
text: str,
words: list[str],
context_words: int = 5,
) -> dict[str, str]:
"""Find example contexts for each word in the text.
Args:
text: The source text.
words: List of words to find contexts for.
context_words: Number of words of context on each side.
Returns:
Dict mapping word to example context.
"""
# Extract all words preserving positions
all_words = re.findall(r"\b[\w]+\b", text, re.UNICODE)
all_words_lower = [w.lower() for w in all_words]
contexts: dict[str, str] = {}
words_lower = {w.lower() for w in words}
for target in words_lower:
# Find first occurrence
for i, word in enumerate(all_words_lower):
if word == target:
start = max(0, i - context_words)
end = min(len(all_words), i + context_words + 1)
context = " ".join(all_words[start:end])
contexts[target] = f"...{context}..."
break
return contexts
def _format_excerpt_card(
excerpt: str,
excerpt_words: list[tuple[str, int]] | None,
) -> str:
"""Format the excerpt as the first Anki card.
Args:
excerpt: The target excerpt text.
excerpt_words: Words in the excerpt with ranks.
Returns:
Formatted excerpt card line.
"""
excerpt_escaped = excerpt.replace(";", ",")
if excerpt_words:
most_frequent = min(excerpt_words, key=lambda x: x[1])[0]
rarest = max(excerpt_words, key=lambda x: x[1])[0]
if most_frequent != rarest:
pattern_rare = re.compile(
rf"\b({re.escape(rarest)})\b", re.IGNORECASE
)
excerpt_escaped = pattern_rare.sub(
r"<b>\1</b>", excerpt_escaped
)
pattern_freq = re.compile(
rf"\b({re.escape(most_frequent)})\b",
re.IGNORECASE,
)
excerpt_escaped = pattern_freq.sub(
r"<i>\1</i>", excerpt_escaped
)
else:
pattern = re.compile(
rf"\b({re.escape(most_frequent)})\b",
re.IGNORECASE,
)
excerpt_escaped = pattern.sub(
r"<b><i>\1</i></b>", excerpt_escaped
)
return f"\U0001f4d6 TARGET EXCERPT;{excerpt_escaped};#0"
def _build_translation_lookup(
words_with_ranks: list[tuple[str, int]],
source_lang: str,
target_lang: str,
*,
no_translate: bool = False,
) -> dict[str, str]:
"""Build word-to-translation lookup dict.
Args:
words_with_ranks: List of (word, rank) tuples.
source_lang: Source language code.
target_lang: Target language code.
no_translate: If True, use placeholder translations.
Returns:
Dict mapping lowercase word to translation.
"""
words = [w for w, _ in words_with_ranks]
if no_translate:
return {w.lower(): "[TODO]" for w in words}
translations = translate_words_batch(words, source_lang, target_lang)
trans_lookup: dict[str, str] = {}
for result in translations:
if result.success:
trans_lookup[result.source_word.lower()] = (
result.translated_word
)
else:
trans_lookup[result.source_word.lower()] = (
f"[{result.source_word}]"
)
return trans_lookup
def generate_anki_deck(
deck_input: DeckInput,
*,
include_context: bool = False,
no_translate: bool = False,
excerpt: str = "",
excerpt_words: list[tuple[str, int]] | None = None,
) -> str:
"""Generate Anki-compatible deck content.
Args:
deck_input: Core deck data (words, langs, contexts, name).
include_context: Whether to include context in cards.
no_translate: If True, skip translation (use placeholder).
excerpt: The target excerpt text to include in cards.
excerpt_words: Words in the excerpt with ranks.
Returns:
Semicolon-separated content ready for Anki import.
"""
lines: list[str] = []
# Add Anki headers
lines.append("#separator:semicolon")
lines.append("#html:true")
lines.append(f"#deck:{deck_input.deck_name}")
lines.append(f"#tags:vocabulary {deck_input.source_lang}")
if include_context:
lines.append("#columns:Front;Back;Rank;Context")
else:
lines.append("#columns:Front;Back;Rank")
lines.append("") # Empty line before data
if excerpt:
lines.append(_format_excerpt_card(excerpt, excerpt_words))
trans_lookup = _build_translation_lookup(
deck_input.words_with_ranks,
deck_input.source_lang,
deck_input.target_lang,
no_translate=no_translate,
)
# Generate cards
for word, rank in deck_input.words_with_ranks:
translation = trans_lookup.get(word.lower(), f"[{word}]")
# Escape semicolons in fields
word_escaped = word.replace(";", ",")
translation_escaped = translation.replace(";", ",")
if include_context and deck_input.contexts:
context = deck_input.contexts.get(word.lower(), "")
if context:
context_escaped = context.replace(";", ",")
pattern = re.compile(re.escape(word), re.IGNORECASE)
context_escaped = pattern.sub(
f"<b>{word}</b>", context_escaped
)
else:
context_escaped = ""
lines.append(
f"{word_escaped};{translation_escaped}"
f";#{rank};{context_escaped}"
)
else:
lines.append(f"{word_escaped};{translation_escaped};#{rank}")
return "\n".join(lines)
def get_cached_excerpt(
filepath: Path, length: int, *, force: bool = False
) -> tuple[str, list[tuple[str, int]]] | None:
"""Get cached excerpt if available.
Args:
filepath: Path to source file.
length: Excerpt length.
force: If True, ignore cache.
Returns:
Tuple of (excerpt, words) or None if not cached.
"""
if force:
return None
return get_vocab_curve_cache().get(filepath, length)
def cache_excerpt(
filepath: Path, length: int, excerpt: str, words: list[tuple[str, int]]
) -> None:
"""Store excerpt in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
excerpt: The excerpt text.
words: List of (word, rank) tuples.
"""
get_vocab_curve_cache().set(filepath, length, excerpt, words)
def get_cached_deck(
key: AnkiDeckKey,
*,
force: bool = False,
) -> tuple[str, str, int, int] | None:
"""Get cached Anki deck if available.
Args:
key: Cache key parameters.
force: If True, ignore cache.
Returns:
Tuple of (content, excerpt, num_words, max_rank) or None.
"""
if force:
return None
return get_anki_deck_cache().get(key)
def cache_deck(
key: AnkiDeckKey,
anki_content: str,
excerpt: str,
num_words: int,
max_rank: int,
) -> None:
"""Store Anki deck in cache.
Args:
key: Cache key parameters.
anki_content: The deck content.
excerpt: The excerpt text.
num_words: Number of words.
max_rank: Maximum rank.
"""
get_anki_deck_cache().set(
key,
anki_content,
excerpt,
num_words,
max_rank,
)
def _detect_source_language(
filepath: Path,
text: str,
) -> str:
"""Auto-detect source language from file content.
Args:
filepath: Path to source file.
text: Already-read text (may be empty).
Returns:
Detected language code.
Raises:
ValueError: If language cannot be detected.
"""
sample_text = read_file(filepath)[:1000] if not text else text[:1000]
detected = detect_language(sample_text)
if detected is None:
msg = (
"Could not auto-detect source language. "
"Please specify with --from (e.g., --from pl for Polish). "
"Install langdetect for auto-detection: "
"pip install langdetect"
)
raise ValueError(msg)
return detected
def generate_flashcards(
filepath: str | Path,
excerpt_length: int,
options: FlashcardOptions | None = None,
*,
all_vocab: bool = True,
) -> tuple[str, str, int, int]:
"""Generate Anki flashcards for vocabulary needed for an excerpt.
Args:
filepath: Path to the source text file.
excerpt_length: Target excerpt length.
options: Flashcard generation options.
all_vocab: If True, include ALL words rank 1 to max rank.
Returns:
Tuple of (anki_content, excerpt, num_words, max_rank).
"""
if options is None:
options = FlashcardOptions()
filepath = Path(filepath)
deck_key = AnkiDeckKey(
filepath=filepath,
length=excerpt_length,
target_lang=options.target_lang,
include_context=options.include_context,
all_vocab=all_vocab,
)
# Check for cached full deck (if not using no_translate)
if not options.no_translate and not options.force:
cached = get_cached_deck(deck_key)
if cached is not None:
return cached
# Read the text (only needed for context finding)
text = read_file(filepath) if options.include_context else ""
# Auto-detect language if not provided
source_lang = options.source_lang
if source_lang is None:
source_lang = _detect_source_language(filepath, text)
# Run vocabulary curve analysis with vocab dump for all words
output = run_vocabulary_curve(
filepath, excerpt_length, dump_vocab=all_vocab
)
excerpt, excerpt_words, all_vocab_words = parse_vocabulary_curve_output(
output, excerpt_length
)
if not excerpt_words:
msg = f"No words found for excerpt length {excerpt_length}"
raise ValueError(msg)
max_rank = max(rank for _, rank in excerpt_words)
words_with_ranks = (
all_vocab_words if all_vocab and all_vocab_words else excerpt_words
)
contexts = None
if options.include_context:
if not text:
text = read_file(filepath)
words = [w for w, _ in words_with_ranks]
contexts = find_word_contexts(text, words)
deck_name = options.deck_name or f"{filepath.stem}_vocab_{excerpt_length}"
anki_content = generate_anki_deck(
DeckInput(
words_with_ranks=words_with_ranks,
source_lang=source_lang,
target_lang=options.target_lang,
contexts=contexts,
deck_name=deck_name,
),
include_context=options.include_context,
no_translate=options.no_translate,
excerpt=excerpt,
excerpt_words=excerpt_words,
)
if not options.no_translate:
cache_deck(
deck_key,
anki_content,
excerpt,
len(words_with_ranks),
max_rank,
)
return anki_content, excerpt, len(words_with_ranks), max_rank
def generate_flashcards_inverse(
filepath: str | Path,
max_vocab: int,
options: FlashcardOptions | None = None,
) -> tuple[str, str, int, int, int]:
"""Generate Anki flashcards for the longest excerpt using top N words.
This is the inverse mode: given a vocabulary size, find the longest
excerpt that can be understood with only those words.
Args:
filepath: Path to the source text file.
max_vocab: Maximum vocabulary size (top N words to learn).
options: Flashcard generation options.
Returns:
Tuple of (anki_content, excerpt, excerpt_length,
num_words, max_rank_used).
"""
if options is None:
options = FlashcardOptions()
filepath = Path(filepath)
text = read_file(filepath) if options.include_context else ""
source_lang = options.source_lang
if source_lang is None:
source_lang = _detect_source_language(filepath, text)
output = run_vocabulary_curve_inverse(
filepath, max_vocab, dump_vocab=True
)
excerpt, excerpt_length, max_rank_used, all_vocab_words = (
parse_inverse_mode_output(output)
)
if excerpt_length == 0:
msg = (
f"No valid excerpt found using only top {max_vocab} "
"words. Try increasing the vocabulary limit."
)
raise ValueError(msg)
if not all_vocab_words:
msg = f"No vocabulary returned for max_vocab={max_vocab}"
raise ValueError(msg)
words_with_ranks = all_vocab_words
excerpt_word_set = set(excerpt.lower().split())
excerpt_words = [
(w, r)
for w, r in all_vocab_words
if w.lower() in excerpt_word_set
]
contexts = None
if options.include_context:
if not text:
text = read_file(filepath)
words = [w for w, _ in words_with_ranks]
contexts = find_word_contexts(text, words)
deck_name = options.deck_name or f"{filepath.stem}_top{max_vocab}"
anki_content = generate_anki_deck(
DeckInput(
words_with_ranks=words_with_ranks,
source_lang=source_lang,
target_lang=options.target_lang,
contexts=contexts,
deck_name=deck_name,
),
include_context=options.include_context,
no_translate=options.no_translate,
excerpt=excerpt,
excerpt_words=excerpt_words or None,
)
return (
anki_content,
excerpt,
excerpt_length,
len(words_with_ranks),
max_rank_used,
)
# Re-export public API from helper modules
__all__ = [
"C_EXECUTABLE",
"DeckInput",
"FlashcardOptions",
"VocabWord",
"cache_deck",
"cache_excerpt",
"find_word_contexts",
"generate_anki_deck",
"generate_flashcards",
"generate_flashcards_inverse",
"get_cached_deck",
"get_cached_excerpt",
"main",
"parse_inverse_mode_output",
"parse_vocabulary_curve_output",
"run_vocabulary_curve",
"run_vocabulary_curve_inverse",
]
def _format_cache_size(value: int) -> str:
@ -900,8 +173,7 @@ def _handle_inverse_mode(
output_path = (
Path(args.output)
if args.output
else filepath.parent
/ f"{filepath.stem}_anki_top{args.max_vocab}.txt"
else filepath.parent / f"{filepath.stem}_anki_top{args.max_vocab}.txt"
)
output_path.write_text(anki_content, encoding="utf-8")
@ -942,9 +214,7 @@ def _handle_normal_mode(
"""
if not args.quiet:
logger.info("Analyzing %s...", filepath.name)
logger.info(
"Finding vocabulary for %d-word excerpt...", args.length
)
logger.info("Finding vocabulary for %d-word excerpt...", args.length)
anki_content, excerpt, num_words, max_rank = generate_flashcards(
filepath,
@ -972,16 +242,12 @@ def _handle_normal_mode(
logger.info("=" * 60)
logger.info("FLASHCARD GENERATION COMPLETE")
logger.info("=" * 60)
logger.info(
"Excerpt to understand (%d words):", args.length
)
logger.info("Excerpt to understand (%d words):", args.length)
logger.info(' "%s"', excerpt)
logger.info("")
logger.info("Max word rank needed: #%d", max_rank)
if args.excerpt_words_only:
logger.info(
"Flashcards: %d (excerpt words only)", num_words
)
logger.info("Flashcards: %d (excerpt words only)", num_words)
else:
logger.info(
"Flashcards: %d (ALL words rank #1 to #%d)",
@ -1020,10 +286,7 @@ def _build_parser() -> argparse.ArgumentParser:
"-l",
type=int,
default=None,
help=(
"Target excerpt length "
"(how many words you want to understand)"
),
help=("Target excerpt length " "(how many words you want to understand)"),
)
parser.add_argument(
"--max-vocab",
@ -1155,9 +418,7 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.length is None and args.max_vocab is None:
parser.error("Either --length/-l or --max-vocab/-v is required")
if args.length is not None and args.max_vocab is not None:
parser.error(
"Cannot use both --length and --max-vocab. Choose one mode."
)
parser.error("Cannot use both --length and --max-vocab. Choose one mode.")
try:
return _run_generation(args)

View File

@ -12,7 +12,6 @@ Cache location: ~/.cache/word_frequency/
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import json
import logging
@ -20,6 +19,14 @@ import os
from pathlib import Path
from typing import Any
from python_pkg.word_frequency._cache_decks import (
AnkiDeckCache,
AnkiDeckKey,
VocabCurveCache,
)
__all__ = ["AnkiDeckCache", "AnkiDeckKey", "VocabCurveCache"]
logger = logging.getLogger(__name__)
# Default cache directory
@ -233,310 +240,11 @@ class TranslationCache:
}
# =============================================================================
# Vocabulary Curve Cache
# =============================================================================
class VocabCurveCache:
"""Cache for vocabulary curve analysis results."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize vocabulary curve cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or get_cache_dir()) / "excerpts"
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_cache_path(self, file_hash: str, length: int) -> Path:
"""Get path to cache file for given hash and length.
Args:
file_hash: Hash of source file.
length: Excerpt length.
Returns:
Path to cache file.
"""
return self.cache_dir / f"{file_hash[:16]}_{length}.json"
def get(
self, filepath: Path, length: int
) -> tuple[str, list[tuple[str, int]]] | None:
"""Get cached excerpt and words for a file and length.
Args:
filepath: Path to source file.
length: Excerpt length.
Returns:
Tuple of (excerpt, words_with_ranks) or None if not cached.
"""
file_hash = get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
if not cache_path.exists():
return None
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, KeyError, OSError):
return None
else:
# Verify hash matches
if data.get("file_hash") != file_hash:
return None
excerpt = data["excerpt"]
words = [(w, r) for w, r in data["words"]]
return excerpt, words
def set(
self,
filepath: Path,
length: int,
excerpt: str,
words: list[tuple[str, int]],
) -> None:
"""Store excerpt and words in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
excerpt: The excerpt text.
words: List of (word, rank) tuples.
"""
file_hash = get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
data = {
"file_hash": file_hash,
"filepath": str(filepath),
"length": length,
"excerpt": excerpt,
"words": [[w, r] for w, r in words],
}
cache_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def clear(self) -> None:
"""Clear all cached excerpts."""
for cache_file in self.cache_dir.glob("*.json"):
cache_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
cache_files = list(self.cache_dir.glob("*.json"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(cache_files),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}
# =============================================================================
# Anki Deck Cache
# =============================================================================
@dataclass(frozen=True)
class AnkiDeckKey:
"""Key parameters for Anki deck cache lookups."""
filepath: Path
length: int
target_lang: str
include_context: bool
all_vocab: bool
class AnkiDeckCache:
"""Cache for generated Anki decks."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize Anki deck cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or get_cache_dir()) / "anki_decks"
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.cache_dir / "metadata.json"
self._metadata: dict[str, Any] | None = None
def _load_metadata(self) -> dict[str, Any]:
"""Load metadata from disk."""
if self._metadata is None:
if self.metadata_file.exists():
try:
self._metadata = json.loads(
self.metadata_file.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError):
self._metadata = {}
else:
self._metadata = {}
return self._metadata
def _save_metadata(self) -> None:
"""Save metadata to disk."""
if self._metadata is not None:
self.metadata_file.write_text(
json.dumps(self._metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@staticmethod
def _make_key(
file_hash: str,
length: int,
target_lang: str,
*,
include_context: bool,
all_vocab: bool,
) -> str:
"""Create cache key for an Anki deck.
Args:
file_hash: Hash of source file.
length: Excerpt length.
target_lang: Target language.
include_context: Whether context is included.
all_vocab: Whether all vocab is included.
Returns:
Cache key string.
"""
flags = f"ctx{int(include_context)}_all{int(all_vocab)}"
return f"{file_hash[:16]}_{length}_{target_lang}_{flags}"
def get(
self,
key: AnkiDeckKey,
) -> tuple[str, str, int, int] | None:
"""Get cached Anki deck.
Args:
key: Cache key parameters.
Returns:
Tuple of (anki_content, excerpt, num_words, max_rank)
or None.
"""
file_hash = get_file_hash(key.filepath)
cache_key = self._make_key(
file_hash,
key.length,
key.target_lang,
include_context=key.include_context,
all_vocab=key.all_vocab,
)
metadata = self._load_metadata()
if cache_key not in metadata:
return None
entry = metadata[cache_key]
if entry.get("file_hash") != file_hash:
return None
deck_file = self.cache_dir / f"{cache_key}.txt"
if not deck_file.exists():
return None
try:
content = deck_file.read_text(encoding="utf-8")
return (
content,
entry["excerpt"],
entry["num_words"],
entry["max_rank"],
)
except OSError:
return None
def set(
self,
key: AnkiDeckKey,
anki_content: str,
excerpt: str,
num_words: int,
max_rank: int,
) -> None:
"""Store Anki deck in cache.
Args:
key: Cache key parameters.
anki_content: The Anki deck content.
excerpt: The excerpt text.
num_words: Number of words in deck.
max_rank: Maximum word rank.
"""
file_hash = get_file_hash(key.filepath)
cache_key = self._make_key(
file_hash,
key.length,
key.target_lang,
include_context=key.include_context,
all_vocab=key.all_vocab,
)
# Save deck content
deck_file = self.cache_dir / f"{cache_key}.txt"
deck_file.write_text(anki_content, encoding="utf-8")
# Update metadata
metadata = self._load_metadata()
metadata[cache_key] = {
"file_hash": file_hash,
"filepath": str(key.filepath),
"length": key.length,
"target_lang": key.target_lang,
"include_context": key.include_context,
"all_vocab": key.all_vocab,
"excerpt": excerpt,
"num_words": num_words,
"max_rank": max_rank,
}
self._save_metadata()
def clear(self) -> None:
"""Clear all cached decks."""
self._metadata = {}
for cache_file in self.cache_dir.glob("*.txt"):
cache_file.unlink()
if self.metadata_file.exists():
self.metadata_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
metadata = self._load_metadata()
cache_files = list(self.cache_dir.glob("*.txt"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(metadata),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}
# =============================================================================
# Global Cache Instances
# =============================================================================
class _CacheHolder:
"""Holds singleton cache instances."""

View File

@ -1,640 +0,0 @@
#!/usr/bin/env python3
"""Caching utilities for word frequency analysis.
Provides disk-based caching for:
- Translations (word -> translation mappings)
- Vocabulary curve excerpts (file + length -> excerpt + words)
- Generated Anki decks
Cache location: ~/.cache/word_frequency/
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
from typing import Any
# Default cache directory
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "word_frequency"
def get_cache_dir() -> Path:
"""Get the cache directory, creating it if needed.
Returns:
Path to cache directory.
"""
cache_dir = Path(os.environ.get("WORD_FREQ_CACHE_DIR", str(DEFAULT_CACHE_DIR)))
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_file_hash(filepath: Path) -> str:
"""Compute SHA256 hash of a file's contents.
Args:
filepath: Path to file.
Returns:
Hex digest of file hash.
"""
hasher = hashlib.sha256()
with open(filepath, "rb") as f:
# Read in chunks for large files
for chunk in iter(lambda: f.read(65536), b""):
hasher.update(chunk)
return hasher.hexdigest()
def get_text_hash(text: str) -> str:
"""Compute SHA256 hash of text content.
Args:
text: Text to hash.
Returns:
Hex digest of text hash.
"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
# =============================================================================
# Translation Cache
# =============================================================================
class TranslationCache:
"""Cache for word translations."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize translation cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = cache_dir or get_cache_dir()
self.cache_file = self.cache_dir / "translations.json"
self._cache: dict[str, str] | None = None
self._dirty = False # Track if cache needs saving
def _load_cache(self) -> dict[str, str]:
"""Load cache from disk."""
if self._cache is None:
if self.cache_file.exists():
try:
self._cache = json.loads(
self.cache_file.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError):
self._cache = {}
else:
self._cache = {}
return self._cache
def _save_cache(self) -> None:
"""Save cache to disk if dirty."""
if self._cache is not None and self._dirty:
self.cache_file.write_text(
json.dumps(self._cache, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self._dirty = False
def flush(self) -> None:
"""Force save cache to disk."""
self._save_cache()
@staticmethod
def _make_key(word: str, source_lang: str, target_lang: str) -> str:
"""Create cache key for a translation.
Args:
word: Word to translate.
source_lang: Source language code.
target_lang: Target language code.
Returns:
Cache key string.
"""
return f"{source_lang}:{target_lang}:{word.lower()}"
def get(self, word: str, source_lang: str, target_lang: str) -> str | None:
"""Get cached translation.
Args:
word: Word to look up.
source_lang: Source language code.
target_lang: Target language code.
Returns:
Cached translation or None if not found.
"""
cache = self._load_cache()
key = self._make_key(word, source_lang, target_lang)
return cache.get(key)
def set(
self,
word: str,
source_lang: str,
target_lang: str,
translation: str,
*,
auto_save: bool = False,
) -> None:
"""Store translation in cache.
Args:
word: Original word.
source_lang: Source language code.
target_lang: Target language code.
translation: Translated word.
auto_save: If True, save to disk immediately.
"""
cache = self._load_cache()
key = self._make_key(word, source_lang, target_lang)
cache[key] = translation
self._dirty = True
if auto_save:
self._save_cache()
def get_many(
self, words: list[str], source_lang: str, target_lang: str
) -> dict[str, str]:
"""Get multiple cached translations.
Args:
words: Words to look up.
source_lang: Source language code.
target_lang: Target language code.
Returns:
Dict mapping words to their cached translations.
"""
cache = self._load_cache()
result: dict[str, str] = {}
for word in words:
key = self._make_key(word, source_lang, target_lang)
if key in cache:
result[word.lower()] = cache[key]
return result
def set_many(
self,
translations: dict[str, str],
source_lang: str,
target_lang: str,
) -> None:
"""Store multiple translations in cache and save to disk.
Args:
translations: Dict mapping words to translations.
source_lang: Source language code.
target_lang: Target language code.
"""
cache = self._load_cache()
for word, translation in translations.items():
key = self._make_key(word, source_lang, target_lang)
cache[key] = translation
self._dirty = True
self._save_cache() # Save once after all additions
def clear(self) -> None:
"""Clear all cached translations."""
self._cache = {}
self._dirty = False
if self.cache_file.exists():
self.cache_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
cache = self._load_cache()
return {
"total_entries": len(cache),
"cache_file": str(self.cache_file),
"cache_size_bytes": (
self.cache_file.stat().st_size if self.cache_file.exists() else 0
),
}
# =============================================================================
# Vocabulary Curve Cache
# =============================================================================
class VocabCurveCache:
"""Cache for vocabulary curve analysis results."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize vocabulary curve cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or get_cache_dir()) / "excerpts"
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_cache_path(self, file_hash: str, length: int) -> Path:
"""Get path to cache file for given hash and length.
Args:
file_hash: Hash of source file.
length: Excerpt length.
Returns:
Path to cache file.
"""
return self.cache_dir / f"{file_hash[:16]}_{length}.json"
def get(
self, filepath: Path, length: int
) -> tuple[str, list[tuple[str, int]]] | None:
"""Get cached excerpt and words for a file and length.
Args:
filepath: Path to source file.
length: Excerpt length.
Returns:
Tuple of (excerpt, words_with_ranks) or None if not cached.
"""
file_hash = get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
if not cache_path.exists():
return None
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
# Verify hash matches
if data.get("file_hash") != file_hash:
return None
excerpt = data["excerpt"]
words = [(w, r) for w, r in data["words"]]
return excerpt, words
except (json.JSONDecodeError, KeyError, OSError):
return None
def set(
self,
filepath: Path,
length: int,
excerpt: str,
words: list[tuple[str, int]],
) -> None:
"""Store excerpt and words in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
excerpt: The excerpt text.
words: List of (word, rank) tuples.
"""
file_hash = get_file_hash(filepath)
cache_path = self._get_cache_path(file_hash, length)
data = {
"file_hash": file_hash,
"filepath": str(filepath),
"length": length,
"excerpt": excerpt,
"words": [[w, r] for w, r in words],
}
cache_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def clear(self) -> None:
"""Clear all cached excerpts."""
for cache_file in self.cache_dir.glob("*.json"):
cache_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
cache_files = list(self.cache_dir.glob("*.json"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(cache_files),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}
# =============================================================================
# Anki Deck Cache
# =============================================================================
class AnkiDeckCache:
"""Cache for generated Anki decks."""
def __init__(self, cache_dir: Path | None = None) -> None:
"""Initialize Anki deck cache.
Args:
cache_dir: Optional custom cache directory.
"""
self.cache_dir = (cache_dir or get_cache_dir()) / "anki_decks"
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.cache_dir / "metadata.json"
self._metadata: dict[str, Any] | None = None
def _load_metadata(self) -> dict[str, Any]:
"""Load metadata from disk."""
if self._metadata is None:
if self.metadata_file.exists():
try:
self._metadata = json.loads(
self.metadata_file.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError):
self._metadata = {}
else:
self._metadata = {}
return self._metadata
def _save_metadata(self) -> None:
"""Save metadata to disk."""
if self._metadata is not None:
self.metadata_file.write_text(
json.dumps(self._metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@staticmethod
def _make_key(
file_hash: str,
length: int,
target_lang: str,
include_context: bool,
all_vocab: bool,
) -> str:
"""Create cache key for an Anki deck.
Args:
file_hash: Hash of source file.
length: Excerpt length.
target_lang: Target language.
include_context: Whether context is included.
all_vocab: Whether all vocab is included.
Returns:
Cache key string.
"""
flags = f"ctx{int(include_context)}_all{int(all_vocab)}"
return f"{file_hash[:16]}_{length}_{target_lang}_{flags}"
def get(
self,
filepath: Path,
length: int,
target_lang: str,
include_context: bool,
all_vocab: bool,
) -> tuple[str, str, int, int] | None:
"""Get cached Anki deck.
Args:
filepath: Path to source file.
length: Excerpt length.
target_lang: Target language.
include_context: Whether context is included.
all_vocab: Whether all vocab is included.
Returns:
Tuple of (anki_content, excerpt, num_words, max_rank) or None.
"""
file_hash = get_file_hash(filepath)
key = self._make_key(file_hash, length, target_lang, include_context, all_vocab)
metadata = self._load_metadata()
if key not in metadata:
return None
entry = metadata[key]
if entry.get("file_hash") != file_hash:
return None
deck_file = self.cache_dir / f"{key}.txt"
if not deck_file.exists():
return None
try:
content = deck_file.read_text(encoding="utf-8")
return (
content,
entry["excerpt"],
entry["num_words"],
entry["max_rank"],
)
except OSError:
return None
def set(
self,
filepath: Path,
length: int,
target_lang: str,
include_context: bool,
all_vocab: bool,
anki_content: str,
excerpt: str,
num_words: int,
max_rank: int,
) -> None:
"""Store Anki deck in cache.
Args:
filepath: Path to source file.
length: Excerpt length.
target_lang: Target language.
include_context: Whether context is included.
all_vocab: Whether all vocab is included.
anki_content: The Anki deck content.
excerpt: The excerpt text.
num_words: Number of words in deck.
max_rank: Maximum word rank.
"""
file_hash = get_file_hash(filepath)
key = self._make_key(file_hash, length, target_lang, include_context, all_vocab)
# Save deck content
deck_file = self.cache_dir / f"{key}.txt"
deck_file.write_text(anki_content, encoding="utf-8")
# Update metadata
metadata = self._load_metadata()
metadata[key] = {
"file_hash": file_hash,
"filepath": str(filepath),
"length": length,
"target_lang": target_lang,
"include_context": include_context,
"all_vocab": all_vocab,
"excerpt": excerpt,
"num_words": num_words,
"max_rank": max_rank,
}
self._save_metadata()
def clear(self) -> None:
"""Clear all cached decks."""
self._metadata = {}
for cache_file in self.cache_dir.glob("*.txt"):
cache_file.unlink()
if self.metadata_file.exists():
self.metadata_file.unlink()
def stats(self) -> dict[str, Any]:
"""Get cache statistics.
Returns:
Dict with cache stats.
"""
metadata = self._load_metadata()
cache_files = list(self.cache_dir.glob("*.txt"))
total_size = sum(f.stat().st_size for f in cache_files)
return {
"total_entries": len(metadata),
"cache_dir": str(self.cache_dir),
"cache_size_bytes": total_size,
}
# =============================================================================
# Global Cache Instances
# =============================================================================
# Singleton instances
_translation_cache: TranslationCache | None = None
_vocab_curve_cache: VocabCurveCache | None = None
_anki_deck_cache: AnkiDeckCache | None = None
def get_translation_cache() -> TranslationCache:
"""Get the global translation cache instance."""
global _translation_cache
if _translation_cache is None:
_translation_cache = TranslationCache()
return _translation_cache
def get_vocab_curve_cache() -> VocabCurveCache:
"""Get the global vocabulary curve cache instance."""
global _vocab_curve_cache
if _vocab_curve_cache is None:
_vocab_curve_cache = VocabCurveCache()
return _vocab_curve_cache
def get_anki_deck_cache() -> AnkiDeckCache:
"""Get the global Anki deck cache instance."""
global _anki_deck_cache
if _anki_deck_cache is None:
_anki_deck_cache = AnkiDeckCache()
return _anki_deck_cache
def clear_all_caches() -> None:
"""Clear all caches."""
get_translation_cache().clear()
get_vocab_curve_cache().clear()
get_anki_deck_cache().clear()
def get_all_cache_stats() -> dict[str, dict[str, Any]]:
"""Get statistics for all caches.
Returns:
Dict with stats for each cache type.
"""
return {
"translations": get_translation_cache().stats(),
"vocab_curves": get_vocab_curve_cache().stats(),
"anki_decks": get_anki_deck_cache().stats(),
}
def main() -> int:
"""CLI for cache management.
Returns:
Exit code.
"""
import argparse
parser = argparse.ArgumentParser(description="Manage word frequency caches")
parser.add_argument("--stats", action="store_true", help="Show cache statistics")
parser.add_argument("--clear", action="store_true", help="Clear all caches")
parser.add_argument(
"--clear-translations", action="store_true", help="Clear translation cache"
)
parser.add_argument(
"--clear-excerpts", action="store_true", help="Clear excerpt cache"
)
parser.add_argument(
"--clear-anki", action="store_true", help="Clear Anki deck cache"
)
args = parser.parse_args()
if args.clear:
clear_all_caches()
print("All caches cleared.")
return 0
if args.clear_translations:
get_translation_cache().clear()
print("Translation cache cleared.")
return 0
if args.clear_excerpts:
get_vocab_curve_cache().clear()
print("Excerpt cache cleared.")
return 0
if args.clear_anki:
get_anki_deck_cache().clear()
print("Anki deck cache cleared.")
return 0
# Default: show stats
stats = get_all_cache_stats()
print("Cache Statistics")
print("=" * 50)
for cache_name, cache_stats in stats.items():
print(f"\n{cache_name.upper()}:")
for key, value in cache_stats.items():
if key == "cache_size_bytes":
# Format as human-readable
if value < 1024:
size_str = f"{value} B"
elif value < 1024 * 1024:
size_str = f"{value / 1024:.1f} KB"
else:
size_str = f"{value / (1024 * 1024):.1f} MB"
print(f" {key}: {size_str}")
else:
print(f" {key}: {value}")
return 0
if __name__ == "__main__":
import sys
sys.exit(main())

View File

@ -32,10 +32,7 @@ from pathlib import Path
import sys
from typing import TYPE_CHECKING, NamedTuple
try:
from python_pkg.word_frequency.analyzer import extract_words, read_file
except ModuleNotFoundError:
from analyzer import extract_words, read_file # type: ignore[import-not-found]
from python_pkg.word_frequency.analyzer import extract_words, read_file
if TYPE_CHECKING:
from collections.abc import Sequence

View File

@ -38,27 +38,24 @@ Usage::
from __future__ import annotations
import argparse
from dataclasses import dataclass
from dataclasses import replace as _replace_dc
import logging
from pathlib import Path
import sys
from typing import TYPE_CHECKING
try:
from python_pkg.word_frequency.analyzer import analyze_text, read_file
from python_pkg.word_frequency.excerpt_finder import find_best_excerpt
from python_pkg.word_frequency.translator import (
detect_language,
translate_words_batch,
)
except ModuleNotFoundError:
from analyzer import analyze_text, read_file # type: ignore[import-not-found]
from excerpt_finder import find_best_excerpt # type: ignore[import-not-found]
from translator import ( # type: ignore[import-not-found]
detect_language,
translate_words_batch,
)
from python_pkg.word_frequency._learning_batch import (
_detect_translation_language,
_generate_batch_section,
_LessonContext,
)
from python_pkg.word_frequency._learning_constants import (
DEFAULT_STOPWORDS_EN,
LessonConfig,
_resolve_stopwords,
load_stopwords,
)
from python_pkg.word_frequency.analyzer import analyze_text, read_file
if TYPE_CHECKING:
from collections.abc import Sequence
@ -66,310 +63,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Common stopwords for various languages (can be overridden with --stopwords)
DEFAULT_STOPWORDS_EN = frozenset(
{
"the",
"a",
"an",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"can",
"this",
"that",
"these",
"those",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"what",
"which",
"who",
"whom",
"whose",
"where",
"when",
"why",
"how",
"all",
"each",
"every",
"both",
"few",
"more",
"most",
"other",
"some",
"such",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
"too",
"very",
"just",
"as",
"if",
"then",
"because",
"while",
"although",
"though",
"after",
"before",
}
)
def load_stopwords(filepath: str | Path | None) -> frozenset[str]:
"""Load stopwords from a file (one word per line).
Args:
filepath: Path to stopwords file, or None to use defaults.
Returns:
Frozenset of stopwords.
"""
if filepath is None:
return frozenset()
path = Path(filepath)
if not path.exists():
return frozenset()
content = path.read_text(encoding="utf-8")
return frozenset(
word.strip().lower() for word in content.splitlines() if word.strip()
)
@dataclass(frozen=True)
class LessonConfig:
"""Configuration for learning lesson generation."""
batch_size: int = 20
num_batches: int = 1
excerpt_length: int = 30
excerpts_per_batch: int = 3
stopwords: frozenset[str] | None = None
skip_default_stopwords: bool = False
skip_numbers: bool = True
case_sensitive: bool = False
translate_from: str | None = None
translate_to: str | None = None
def _resolve_stopwords(config: LessonConfig) -> frozenset[str]:
"""Resolve combined stopwords from config."""
if config.skip_default_stopwords:
return config.stopwords or frozenset()
return DEFAULT_STOPWORDS_EN | (config.stopwords or frozenset())
def _detect_translation_language(
text: str,
config: LessonConfig,
lines: list[str],
) -> tuple[str | None, str | None]:
"""Detect translation settings and return (from, to) pair."""
actual_from = config.translate_from
actual_to = config.translate_to or "en"
if actual_from == "auto" or (
config.translate_to and not config.translate_from
):
detected = detect_language(text)
if detected:
actual_from = detected
lines.append(f"Detected language: {detected}")
else:
lines.append(
"Warning: Could not detect language "
"(install langdetect: "
"pip install langdetect)"
)
actual_from = None
return actual_from, actual_to
def _format_word_list(
batch_words: list[tuple[str, int]],
start_idx: int,
total_words: int,
translations: dict[str, str],
) -> list[str]:
"""Format the vocabulary word list for a batch."""
lines: list[str] = []
for i, (word, count) in enumerate(
batch_words, start=start_idx + 1,
):
percentage = (count / total_words) * 100
if translations:
trans = translations.get(word, "?")
lines.append(
f" {i:3}. {word:<20} -> {trans:<20}"
f" ({count:,} occurrences, "
f"{percentage:.2f}%)"
)
else:
lines.append(
f" {i:3}. {word:<20}"
f" ({count:,} occurrences, "
f"{percentage:.2f}%)"
)
return lines
@dataclass(frozen=True)
class _LessonContext:
"""Shared context for batch generation."""
text: str
word_counts: dict[str, int]
config: LessonConfig
def _generate_batch_section(
ctx: _LessonContext,
batch_num: int,
batch_words: list[tuple[str, int]],
cumulative_words: list[str],
) -> list[str]:
"""Generate lines for a single batch section."""
config = ctx.config
total_words = sum(ctx.word_counts.values())
start_idx = batch_num * config.batch_size
end_idx = start_idx + config.batch_size
lines: list[str] = []
lines.append("-" * 70)
lines.append(
f"BATCH {batch_num + 1}: Words "
f"{start_idx + 1} - "
f"{min(end_idx, start_idx + len(batch_words))}"
)
lines.append("-" * 70)
lines.append("")
# Get translations if requested
translations: dict[str, str] = {}
do_translate = (
config.translate_from is not None
and config.translate_to is not None
)
if do_translate:
words_to_translate = [word for word, _ in batch_words]
translation_results = translate_words_batch(
words_to_translate,
config.translate_from, # type: ignore[arg-type]
config.translate_to, # type: ignore[arg-type]
)
translations = {
r.source_word: r.translated_word
for r in translation_results
if r.success
}
lines.append("VOCABULARY TO LEARN:")
lines.append("")
lines.extend(
_format_word_list(
batch_words, start_idx, total_words, translations,
)
)
lines.append("")
# Cumulative coverage
cumulative_count = sum(
ctx.word_counts[w]
for w in cumulative_words
if w in ctx.word_counts
)
coverage = (cumulative_count / total_words) * 100
lines.append(
"After learning these words, "
f"you'll recognize ~{coverage:.1f}% of the text"
)
lines.append("")
# Excerpts
lines.append("PRACTICE EXCERPTS:")
lines.append(
"(Excerpts where your learned vocabulary "
"is most concentrated)"
)
lines.append("")
excerpts = find_best_excerpt(
ctx.text,
cumulative_words,
config.excerpt_length,
case_sensitive=config.case_sensitive,
top_n=config.excerpts_per_batch,
)
for j, excerpt in enumerate(excerpts, 1):
lines.append(
f" Excerpt {j} "
f"({excerpt.match_percentage:.1f}% known words):"
)
lines.append(f' "{excerpt.excerpt}"')
lines.append("")
return lines
def generate_learning_lesson(
text: str,
config: LessonConfig | None = None,
@ -388,7 +81,8 @@ def generate_learning_lesson(
all_stopwords = _resolve_stopwords(config)
word_counts = analyze_text(
text, case_sensitive=config.case_sensitive,
text,
case_sensitive=config.case_sensitive,
)
filtered_words = [
@ -421,11 +115,11 @@ def generate_learning_lesson(
)
actual_from, actual_to = _detect_translation_language(
text, config, lines,
)
do_translate = (
actual_from is not None and actual_to is not None
text,
config,
lines,
)
do_translate = actual_from is not None and actual_to is not None
if do_translate:
lines.append(
f"Translation: {actual_from} -> {actual_to}",
@ -470,25 +164,14 @@ def generate_learning_lesson(
if cumulative_words:
final_coverage = sum(
word_counts[w]
for w in cumulative_words
if w in word_counts
word_counts[w] for w in cumulative_words if w in word_counts
)
final_pct = (final_coverage / total_words) * 100
lines.append(
"Total vocabulary words learned: "
f"{len(cumulative_words)}"
)
lines.append("Total vocabulary words learned: " f"{len(cumulative_words)}")
lines.append(f"Text coverage: {final_pct:.1f}%")
lines.append("")
lines.append(
"TIP: Focus on understanding the excerpts "
"first, then read"
)
lines.append(
"more of the original text as your "
"vocabulary grows!"
)
lines.append("TIP: Focus on understanding the excerpts " "first, then read")
lines.append("more of the original text as your " "vocabulary grows!")
return "\n".join(lines)
@ -588,10 +271,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"--translate-from",
type=str,
metavar="LANG",
help=(
"Source language code (e.g., 'la', 'pl'). "
"If omitted, auto-detected."
),
help=("Source language code (e.g., 'la', 'pl'). " "If omitted, auto-detected."),
)
parser.add_argument(
"--translate-to",
@ -622,9 +302,7 @@ def main(argv: Sequence[str] | None = None) -> int:
translate_to: str | None = None
if not args.no_translate:
translate_from = (
args.translate_from or "auto"
)
translate_from = args.translate_from or "auto"
translate_to = args.translate_to
config = LessonConfig(
@ -644,10 +322,12 @@ def main(argv: Sequence[str] | None = None) -> int:
# Output
if args.output:
Path(args.output).write_text(
lesson, encoding="utf-8",
lesson,
encoding="utf-8",
)
logger.info(
"Lesson written to %s", args.output,
"Lesson written to %s",
args.output,
)
else:
logger.info(lesson)

View File

@ -0,0 +1,92 @@
"""Shared test helpers for translator tests."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.word_frequency import translator
class ArgosAvailableMock:
"""Context manager to mock argostranslate being available and control its output.
Works whether argos is installed or not by patching sys.modules.
"""
def __init__(
self, translate_returns: str | list[str] | Exception | None = None
) -> None:
"""Initialize with return values for translate()."""
self.translate_returns = translate_returns
self.mock_translate_fn = MagicMock()
self.mock_translate_module = MagicMock()
self.mock_package_module = MagicMock()
self.mock_parent = MagicMock()
self._sys_modules_patcher: MagicMock | None = None
self._ensure_patcher: MagicMock | None = None
self._lang_patcher: MagicMock | None = None
self._check_argos_patcher: MagicMock | None = None
self._argos_module_patcher: MagicMock | None = None
def __enter__(self) -> MagicMock:
"""Set up the mocks."""
# Set up translate return value
if isinstance(self.translate_returns, (Exception, list)):
self.mock_translate_fn.side_effect = self.translate_returns
elif self.translate_returns is not None:
self.mock_translate_fn.return_value = self.translate_returns
# Wire up the mock modules
self.mock_translate_module.translate = self.mock_translate_fn
self.mock_translate_module.get_installed_languages = MagicMock(return_value=[])
self.mock_package_module.update_package_index = MagicMock()
self.mock_package_module.get_available_packages = MagicMock(return_value=[])
self.mock_parent.translate = self.mock_translate_module
self.mock_parent.package = self.mock_package_module
# Patch sys.modules to inject our mock (works even if argos not installed)
self._sys_modules_patcher = patch.dict(
"sys.modules",
{
"argostranslate": self.mock_parent,
"argostranslate.translate": self.mock_translate_module,
"argostranslate.package": self.mock_package_module,
},
)
# Patch the module-level argostranslate reference in translator
self._argos_module_patcher = patch.object(
translator, "argostranslate", self.mock_parent, create=True
)
# Patch _ensure_argos_installed and _ensure_language_pair to no-op
self._ensure_patcher = patch.object(
translator, "_ensure_argos_installed", lambda: None
)
self._lang_patcher = patch.object(
translator, "_ensure_language_pair", lambda _f, _t: None
)
self._check_argos_patcher = patch.object(
translator, "_check_argos", return_value=True
)
self._sys_modules_patcher.start() # type: ignore[union-attr]
self._argos_module_patcher.start() # type: ignore[union-attr]
self._ensure_patcher.start() # type: ignore[union-attr]
self._lang_patcher.start() # type: ignore[union-attr]
self._check_argos_patcher.start() # type: ignore[union-attr]
return self.mock_translate_fn
def __exit__(self, *args: object) -> None:
"""Restore original state."""
if self._check_argos_patcher:
self._check_argos_patcher.stop()
if self._lang_patcher:
self._lang_patcher.stop()
if self._ensure_patcher:
self._ensure_patcher.stop()
if self._argos_module_patcher:
self._argos_module_patcher.stop()
if self._sys_modules_patcher:
self._sys_modules_patcher.stop()

View File

@ -0,0 +1,29 @@
"""Shared fixtures for translator tests."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
from python_pkg.word_frequency import translator
@pytest.fixture
def _mock_argos_unavailable() -> Generator[None, None, None]:
"""Mock argostranslate being unavailable (for legacy tests)."""
with patch.object(translator, "_check_argos", return_value=False):
yield
@pytest.fixture
def temp_words_file(tmp_path: Path) -> Path:
"""Create a temporary file with words."""
words_file = tmp_path / "words.txt"
words_file.write_text("hello\nworld\ngoodbye\n", encoding="utf-8")
return words_file

View File

@ -254,9 +254,7 @@ class TestMain:
assert exit_code == 0
assert "Unique words: 3" in captured.out
def test_file_not_found_error(
self, caplog: pytest.LogCaptureFixture
) -> None:
def test_file_not_found_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test error handling for missing file."""
exit_code = main(["--file", "/nonexistent/file.txt"])
assert exit_code == 1

View File

@ -164,7 +164,7 @@ class TestGenerateAnkiDeck:
def test_generates_valid_header(self) -> None:
"""Test that output contains valid Anki headers."""
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
mock_translate.return_value = [
MagicMock(success=True, source_word="hello", translated_word="hola")
@ -185,7 +185,7 @@ class TestGenerateAnkiDeck:
def test_generates_flashcard_content(self) -> None:
"""Test that output contains flashcard data."""
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
mock_translate.return_value = [
MagicMock(success=True, source_word="hello", translated_word="hola"),
@ -208,7 +208,7 @@ class TestGenerateAnkiDeck:
def test_includes_rank(self) -> None:
"""Test that rank is included in output."""
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
mock_translate.return_value = [
MagicMock(success=True, source_word="test", translated_word="prueba")
@ -226,7 +226,7 @@ class TestGenerateAnkiDeck:
def test_escapes_semicolons(self) -> None:
"""Test that semicolons in words are escaped."""
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
mock_translate.return_value = [
MagicMock(
@ -247,7 +247,7 @@ class TestGenerateAnkiDeck:
def test_includes_context_when_requested(self) -> None:
"""Test that context is included when requested."""
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
mock_translate.return_value = [
MagicMock(success=True, source_word="hello", translated_word="hola")
@ -319,7 +319,7 @@ class TestIntegration:
output_file = tmp_path / "output.txt"
with patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate:
# Mock translation to avoid network calls
def mock_translate_fn(
@ -368,7 +368,7 @@ class TestIntegration:
with (
caplog.at_level(logging.INFO),
patch(
"python_pkg.word_frequency.anki_generator.translate_words_batch"
"python_pkg.word_frequency._deck_builder.translate_words_batch"
) as mock_translate,
):
mock_translate.return_value = [

View File

@ -151,7 +151,9 @@ class TestFindBestExcerptWithContext:
"""Test with zero context (should behave like find_best_excerpt)."""
text = "a b c d e f g"
result = find_best_excerpt_with_context(
text, ["c"], excerpt_length=1,
text,
["c"],
excerpt_length=1,
options=ExcerptSearchOptions(context_words=0),
)
@ -161,7 +163,9 @@ class TestFindBestExcerptWithContext:
"""Test with context words."""
text = "a b c d e f g"
result = find_best_excerpt_with_context(
text, ["d"], excerpt_length=1,
text,
["d"],
excerpt_length=1,
options=ExcerptSearchOptions(context_words=2),
)
@ -174,7 +178,9 @@ class TestFindBestExcerptWithContext:
"""Test context doesn't go before start of text."""
text = "a b c d e"
result = find_best_excerpt_with_context(
text, ["a"], excerpt_length=1,
text,
["a"],
excerpt_length=1,
options=ExcerptSearchOptions(context_words=3),
)
@ -186,7 +192,9 @@ class TestFindBestExcerptWithContext:
"""Test context doesn't go beyond end of text."""
text = "a b c d e"
result = find_best_excerpt_with_context(
text, ["e"], excerpt_length=1,
text,
["e"],
excerpt_length=1,
options=ExcerptSearchOptions(context_words=3),
)
@ -259,9 +267,7 @@ class TestMain:
assert exit_code == 0
assert "hello" in caplog.text
def test_file_input(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
def test_file_input(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Test --file input option."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello world hello world", encoding="utf-8")

View File

@ -12,7 +12,6 @@ import pytest
if TYPE_CHECKING:
from pathlib import Path
import python_pkg.word_frequency.learning_pipe as learning_pipe_module
from python_pkg.word_frequency.learning_pipe import (
DEFAULT_STOPWORDS_EN,
LessonConfig,
@ -20,6 +19,7 @@ from python_pkg.word_frequency.learning_pipe import (
load_stopwords,
main,
)
import python_pkg.word_frequency.translator as _translator_module
from python_pkg.word_frequency.translator import TranslationResult
if TYPE_CHECKING:
@ -49,9 +49,9 @@ def _mock_translation() -> Generator[MagicMock, None, None]:
for word in words
]
# Need to patch in learning_pipe module since it imports the function directly
# Need to patch in translator module since _learning_batch looks it up there
with patch.object(
learning_pipe_module, "translate_words_batch", side_effect=fake_batch_translate
_translator_module, "translate_words_batch", side_effect=fake_batch_translate
):
yield
@ -267,9 +267,7 @@ class TestMain:
content = output_file.read_text(encoding="utf-8")
assert "LANGUAGE LEARNING LESSON" in content
def test_custom_stopwords(
self, tmp_path: Path, _mock_translation: None
) -> None:
def test_custom_stopwords(self, tmp_path: Path, _mock_translation: None) -> None:
"""Test with custom stopwords file."""
stopwords_file = tmp_path / "stop.txt"
stopwords_file.write_text("hello\n", encoding="utf-8")
@ -422,16 +420,12 @@ class TestTranslationIntegration:
assert result == 0
def test_translate_to_defaults_to_english(
self, _mock_translation: None
) -> None:
def test_translate_to_defaults_to_english(self, _mock_translation: None) -> None:
"""Test that translate_to defaults to 'en' when using auto-detection."""
text = "hello world"
# When using --translate flag (translate_from="auto"),
# translate_to defaults to "en"
with patch.object(
learning_pipe_module, "detect_language", return_value="es"
):
with patch.object(_translator_module, "detect_language", return_value="es"):
result = generate_learning_lesson(
text,
LessonConfig(

View File

@ -1,153 +1,20 @@
"""Tests for the offline translator module."""
"""Tests for translator module - part 1 (results, translation, batch, formatting)."""
from __future__ import annotations
from pathlib import Path
import sys
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
# Import the module
try:
from python_pkg.word_frequency import translator
from python_pkg.word_frequency.translator import (
TranslationResult,
download_languages,
format_translations,
get_available_packages,
get_installed_languages,
main,
read_file,
translate_word,
translate_words,
translate_words_batch,
)
except ImportError:
# Direct execution support
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from python_pkg.word_frequency import translator
from python_pkg.word_frequency.translator import (
TranslationResult,
download_languages,
format_translations,
get_available_packages,
get_installed_languages,
main,
read_file,
translate_word,
translate_words,
translate_words_batch,
)
# Helper context manager for mocking argostranslate
class ArgosAvailableMock:
"""Context manager to mock argostranslate being available and control its output.
Works whether argos is installed or not by patching sys.modules.
"""
def __init__(
self, translate_returns: str | list[str] | Exception | None = None
) -> None:
"""Initialize with return values for translate()."""
self.translate_returns = translate_returns
self.mock_translate_fn = MagicMock()
self.mock_translate_module = MagicMock()
self.mock_package_module = MagicMock()
self.mock_parent = MagicMock()
self._sys_modules_patcher: MagicMock | None = None
self._ensure_patcher: MagicMock | None = None
self._lang_patcher: MagicMock | None = None
self._check_argos_patcher: MagicMock | None = None
self._argos_module_patcher: MagicMock | None = None
def __enter__(self) -> MagicMock:
"""Set up the mocks."""
# Set up translate return value
if isinstance(self.translate_returns, (Exception, list)):
self.mock_translate_fn.side_effect = self.translate_returns
elif self.translate_returns is not None:
self.mock_translate_fn.return_value = self.translate_returns
# Wire up the mock modules
self.mock_translate_module.translate = self.mock_translate_fn
self.mock_translate_module.get_installed_languages = MagicMock(return_value=[])
self.mock_package_module.update_package_index = MagicMock()
self.mock_package_module.get_available_packages = MagicMock(return_value=[])
self.mock_parent.translate = self.mock_translate_module
self.mock_parent.package = self.mock_package_module
# Patch sys.modules to inject our mock (works even if argos not installed)
self._sys_modules_patcher = patch.dict(
"sys.modules",
{
"argostranslate": self.mock_parent,
"argostranslate.translate": self.mock_translate_module,
"argostranslate.package": self.mock_package_module,
},
)
# Patch the module-level argostranslate reference in translator
self._argos_module_patcher = patch.object(
translator, "argostranslate", self.mock_parent, create=True
)
# Patch _ensure_argos_installed and _ensure_language_pair to no-op
self._ensure_patcher = patch.object(
translator, "_ensure_argos_installed", lambda: None
)
self._lang_patcher = patch.object(
translator, "_ensure_language_pair", lambda _f, _t: None
)
self._check_argos_patcher = patch.object(
translator, "_check_argos", return_value=True
)
self._sys_modules_patcher.start() # type: ignore[union-attr]
self._argos_module_patcher.start() # type: ignore[union-attr]
self._ensure_patcher.start() # type: ignore[union-attr]
self._lang_patcher.start() # type: ignore[union-attr]
self._check_argos_patcher.start() # type: ignore[union-attr]
return self.mock_translate_fn
def __exit__(self, *args: object) -> None:
"""Restore original state."""
if self._check_argos_patcher:
self._check_argos_patcher.stop()
if self._lang_patcher:
self._lang_patcher.stop()
if self._ensure_patcher:
self._ensure_patcher.stop()
if self._argos_module_patcher:
self._argos_module_patcher.stop()
if self._sys_modules_patcher:
self._sys_modules_patcher.stop()
# Fixtures
@pytest.fixture
def _mock_argos_unavailable() -> Generator[None, None, None]:
"""Mock argostranslate being unavailable (for legacy tests)."""
with patch.object(translator, "_check_argos", return_value=False):
yield
@pytest.fixture
def temp_words_file(tmp_path: Path) -> Path:
"""Create a temporary file with words."""
words_file = tmp_path / "words.txt"
words_file.write_text("hello\nworld\ngoodbye\n", encoding="utf-8")
return words_file
from python_pkg.word_frequency import translator
from python_pkg.word_frequency.tests._translator_helpers import ArgosAvailableMock
from python_pkg.word_frequency.translator import (
TranslationResult,
format_translations,
translate_word,
translate_words,
translate_words_batch,
)
# TranslationResult tests
@ -327,9 +194,7 @@ class TestTranslateWordsBatch:
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.object(translator, "argostranslate", mock_parent, create=True),
patch.dict(
"sys.modules",
{
@ -417,309 +282,3 @@ class TestFormatTranslations:
assert "hello" in output
assert "Unknown word" not in output
# get_installed_languages tests
class TestGetInstalledLanguages:
"""Tests for get_installed_languages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = get_installed_languages()
assert result == []
def test_returns_languages(self) -> None:
"""Test returning installed languages."""
mock_lang1 = MagicMock()
mock_lang1.code = "en"
mock_lang1.name = "English"
mock_lang2 = MagicMock()
mock_lang2.code = "es"
mock_lang2.name = "Spanish"
# We need to mock the translate module's get_installed_languages
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = [
mock_lang1,
mock_lang2,
]
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = get_installed_languages()
assert ("en", "English") in result
assert ("es", "Spanish") in result
# get_available_packages tests
class TestGetAvailablePackages:
"""Tests for get_available_packages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = get_available_packages()
assert result == []
# download_languages tests
class TestDownloadLanguages:
"""Tests for download_languages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = download_languages(["en", "es"])
assert result == {}
# read_file tests
class TestReadFile:
"""Tests for read_file function."""
def test_read_file(self, tmp_path: Path) -> None:
"""Test reading a file."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello\nworld", encoding="utf-8")
content = read_file(test_file)
assert content == "hello\nworld"
def test_read_file_not_found(self, tmp_path: Path) -> None:
"""Test reading non-existent file."""
with pytest.raises(FileNotFoundError):
read_file(tmp_path / "nonexistent.txt")
# main function tests
class TestMain:
"""Tests for main CLI function."""
def test_argos_unavailable_error(self, _mock_argos_unavailable: None) -> None:
"""Test error when argos not installed."""
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 1
def test_list_languages_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test listing languages when none installed."""
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = []
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = main(["--list-languages"])
assert result == 0
captured = capsys.readouterr()
assert "No languages installed" in captured.out
def test_list_languages_with_results(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Test listing installed languages."""
mock_lang = MagicMock()
mock_lang.code = "en"
mock_lang.name = "English"
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = [mock_lang]
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = main(["--list-languages"])
assert result == 0
captured = capsys.readouterr()
assert "en" in captured.out
assert "English" in captured.out
def test_translate_single_text(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test translating single text."""
with ArgosAvailableMock("hola"):
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "hola" in captured.out
def test_translate_multiple_words(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test translating multiple words."""
with ArgosAvailableMock(["hola", "mundo"]):
result = main(["--words", "hello", "world", "--from", "en", "--to", "es"])
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "world" in captured.out
def test_translate_from_file(
self,
temp_words_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test translating words from file."""
with ArgosAvailableMock(["hola", "mundo", "adios"]):
result = main(
["--words-file", str(temp_words_file), "--from", "en", "--to", "es"]
)
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "world" in captured.out
assert "goodbye" in captured.out
def test_translate_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test error when words file not found."""
with ArgosAvailableMock():
result = main(
["--words-file", "/nonexistent/file.txt", "--from", "en", "--to", "es"]
)
assert result == 1
captured = capsys.readouterr()
assert "File not found" in captured.err
def test_translate_output_to_file(
self,
tmp_path: Path,
) -> None:
"""Test outputting translations to file."""
output_file = tmp_path / "output.txt"
with ArgosAvailableMock("hola"):
result = main(
[
"--text",
"hello",
"--from",
"en",
"--to",
"es",
"--output",
str(output_file),
]
)
assert result == 0
assert output_file.exists()
content = output_file.read_text(encoding="utf-8")
assert "hello" in content
assert "hola" in content
def test_no_input_shows_help(
self,
) -> None:
"""Test that no input shows help."""
with ArgosAvailableMock():
result = main([])
assert result == 1
def test_translation_failure_returns_error(self) -> None:
"""Test that translation failure returns error code when argos unavailable."""
with patch.object(
translator,
"_ensure_argos_installed",
side_effect=ImportError("argostranslate not available"),
):
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 1
# Integration-style tests (still mocked but testing more flow)
class TestIntegration:
"""Integration-style tests for translator."""
def test_full_translation_flow(self) -> None:
"""Test complete translation flow."""
with ArgosAvailableMock(["uno", "dos", "tres"]) as mock:
mock.side_effect = ["uno", "dos", "tres"]
words = ["one", "two", "three"]
results = translate_words(words, "en", "es", use_cache=False)
assert all(r.success for r in results)
assert [r.translated_word for r in results] == ["uno", "dos", "tres"]
output = format_translations(results)
assert "en -> es" in output
assert "one" in output
assert "uno" in output
def test_mixed_success_failure(self) -> None:
"""Test handling when argos raises exception for some translations."""
# Simulate argos translating first word, then failing, then succeeding
with ArgosAvailableMock() as mock:
mock.side_effect = ["hola", RuntimeError("Unknown"), "mundo"]
results = translate_words(
["hello", "xyz", "world"], "en", "es", use_cache=False
)
# First and third succeed, second fails
assert results[0].success is True
assert results[1].success is False
assert results[2].success is True
output = format_translations(results)
assert "Error" in output

View File

@ -0,0 +1,325 @@
"""Tests for translator module - part 2 (languages, file I/O, CLI, integration)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.word_frequency import translator
from python_pkg.word_frequency.tests._translator_helpers import ArgosAvailableMock
from python_pkg.word_frequency.translator import (
download_languages,
format_translations,
get_available_packages,
get_installed_languages,
main,
read_file,
translate_words,
)
# get_installed_languages tests
class TestGetInstalledLanguages:
"""Tests for get_installed_languages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = get_installed_languages()
assert result == []
def test_returns_languages(self) -> None:
"""Test returning installed languages."""
mock_lang1 = MagicMock()
mock_lang1.code = "en"
mock_lang1.name = "English"
mock_lang2 = MagicMock()
mock_lang2.code = "es"
mock_lang2.name = "Spanish"
# We need to mock the translate module's get_installed_languages
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = [
mock_lang1,
mock_lang2,
]
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = get_installed_languages()
assert ("en", "English") in result
assert ("es", "Spanish") in result
# get_available_packages tests
class TestGetAvailablePackages:
"""Tests for get_available_packages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = get_available_packages()
assert result == []
# download_languages tests
class TestDownloadLanguages:
"""Tests for download_languages function."""
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
"""Test when argos is unavailable."""
result = download_languages(["en", "es"])
assert result == {}
# read_file tests
class TestReadFile:
"""Tests for read_file function."""
def test_read_file(self, tmp_path: Path) -> None:
"""Test reading a file."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello\nworld", encoding="utf-8")
content = read_file(test_file)
assert content == "hello\nworld"
def test_read_file_not_found(self, tmp_path: Path) -> None:
"""Test reading non-existent file."""
with pytest.raises(FileNotFoundError):
read_file(tmp_path / "nonexistent.txt")
# main function tests
class TestMain:
"""Tests for main CLI function."""
def test_argos_unavailable_error(self, _mock_argos_unavailable: None) -> None:
"""Test error when argos not installed."""
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 1
def test_list_languages_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test listing languages when none installed."""
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = []
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = main(["--list-languages"])
assert result == 0
captured = capsys.readouterr()
assert "No languages installed" in captured.out
def test_list_languages_with_results(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Test listing installed languages."""
mock_lang = MagicMock()
mock_lang.code = "en"
mock_lang.name = "English"
mock_translate_module = MagicMock()
mock_translate_module.get_installed_languages.return_value = [mock_lang]
mock_package_module = MagicMock()
mock_parent = MagicMock()
mock_parent.translate = mock_translate_module
mock_parent.package = mock_package_module
with (
patch.object(translator, "_check_argos", return_value=True),
patch.object(
translator, "argostranslate", mock_parent, create=True
),
patch.dict(
"sys.modules",
{
"argostranslate": mock_parent,
"argostranslate.translate": mock_translate_module,
"argostranslate.package": mock_package_module,
},
),
):
result = main(["--list-languages"])
assert result == 0
captured = capsys.readouterr()
assert "en" in captured.out
assert "English" in captured.out
def test_translate_single_text(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test translating single text."""
with ArgosAvailableMock("hola"):
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "hola" in captured.out
def test_translate_multiple_words(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test translating multiple words."""
with ArgosAvailableMock(["hola", "mundo"]):
result = main(["--words", "hello", "world", "--from", "en", "--to", "es"])
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "world" in captured.out
def test_translate_from_file(
self,
temp_words_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test translating words from file."""
with ArgosAvailableMock(["hola", "mundo", "adios"]):
result = main(
["--words-file", str(temp_words_file), "--from", "en", "--to", "es"]
)
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
assert "world" in captured.out
assert "goodbye" in captured.out
def test_translate_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Test error when words file not found."""
with ArgosAvailableMock():
result = main(
["--words-file", "/nonexistent/file.txt", "--from", "en", "--to", "es"]
)
assert result == 1
captured = capsys.readouterr()
assert "File not found" in captured.err
def test_translate_output_to_file(
self,
tmp_path: Path,
) -> None:
"""Test outputting translations to file."""
output_file = tmp_path / "output.txt"
with ArgosAvailableMock("hola"):
result = main(
[
"--text",
"hello",
"--from",
"en",
"--to",
"es",
"--output",
str(output_file),
]
)
assert result == 0
assert output_file.exists()
content = output_file.read_text(encoding="utf-8")
assert "hello" in content
assert "hola" in content
def test_no_input_shows_help(
self,
) -> None:
"""Test that no input shows help."""
with ArgosAvailableMock():
result = main([])
assert result == 1
def test_translation_failure_returns_error(self) -> None:
"""Test that translation failure returns error code when argos unavailable."""
with patch.object(
translator,
"_ensure_argos_installed",
side_effect=ImportError("argostranslate not available"),
):
result = main(["--text", "hello", "--from", "en", "--to", "es"])
assert result == 1
# Integration-style tests (still mocked but testing more flow)
class TestIntegration:
"""Integration-style tests for translator."""
def test_full_translation_flow(self) -> None:
"""Test complete translation flow."""
with ArgosAvailableMock(["uno", "dos", "tres"]) as mock:
mock.side_effect = ["uno", "dos", "tres"]
words = ["one", "two", "three"]
results = translate_words(words, "en", "es", use_cache=False)
assert all(r.success for r in results)
assert [r.translated_word for r in results] == ["uno", "dos", "tres"]
output = format_translations(results)
assert "en -> es" in output
assert "one" in output
assert "uno" in output
def test_mixed_success_failure(self) -> None:
"""Test handling when argos raises exception for some translations."""
# Simulate argos translating first word, then failing, then succeeding
with ArgosAvailableMock() as mock:
mock.side_effect = ["hola", RuntimeError("Unknown"), "mundo"]
results = translate_words(
["hello", "xyz", "world"], "en", "es", use_cache=False
)
# First and third succeed, second fails
assert results[0].success is True
assert results[1].success is False
assert results[2].success is True
output = format_translations(results)
assert "Error" in output

View File

@ -1,75 +1,32 @@
#!/usr/bin/env python3
r"""Translator - translates words/text between languages.
This module provides translation capabilities using either:
1. Argos Translate (offline, requires large downloads)
2. deep-translator (online, uses Google Translate)
This module provides translation capabilities using Argos Translate (offline).
Usage::
# Translate a single word
python -m python_pkg.word_frequency.translator \\
python -m python_pkg.word_frequency.translator \
--text "hello" --from en --to es
# Translate multiple words
python -m python_pkg.word_frequency.translator \\
--words hello world goodbye --from en --to pl
Dependencies::
# Translate words from a file (one word per line)
python -m python_pkg.word_frequency.translator \\
--words-file words.txt --from la --to en
# List available languages
python -m python_pkg.word_frequency.translator \\
--list-languages
# Output to file
python -m python_pkg.word_frequency.translator \\
--words-file vocab.txt --from pl --to en \\
--output translations.txt
Dependencies (install one)::
pip install deep-translator
pip install argostranslate
"""
from __future__ import annotations
import argparse
import importlib
import logging
import os
from pathlib import Path
import subprocess
import sys
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
try:
import torch
except ImportError:
torch = None # type: ignore[assignment]
try:
import argostranslate.package
import argostranslate.translate
except ImportError:
argostranslate = None # type: ignore[assignment]
try:
from deep_translator import GoogleTranslator
except ImportError:
GoogleTranslator = None
try:
import langdetect
except ImportError:
langdetect = None # type: ignore[assignment]
try:
from python_pkg.word_frequency.cache import (
get_translation_cache,
@ -77,123 +34,28 @@ try:
except ImportError:
get_translation_cache = None
from python_pkg.word_frequency._translator_cli import main
from python_pkg.word_frequency._translator_helpers import (
TranslationResult,
_check_cuda_available,
_ensure_argos_installed,
_ensure_language_pair,
_init_gpu_if_available,
detect_language,
format_translations,
read_file,
)
logger = logging.getLogger(__name__)
_LANG_DETECT_SAMPLE_SIZE = 5000
_BATCH_SIZE = 100
class _TranslatorState:
"""Holds module-level state for lazy-initialized backends."""
gpu_initialized: bool = False
def _check_cuda_available() -> bool:
"""Check if CUDA is available for GPU acceleration."""
return torch is not None and torch.cuda.is_available()
def _validate_gpu_device() -> str:
"""Validate GPU device availability and return device name.
Raises:
RuntimeError: If no GPU devices are found.
"""
device_count = torch.cuda.device_count()
if device_count == 0:
msg = "CUDA reports available but no GPU devices found"
raise RuntimeError(msg)
return torch.cuda.get_device_name(0)
def _init_gpu_if_available() -> None:
"""Initialize GPU for argostranslate if CUDA is available.
Raises:
RuntimeError: If CUDA is available but GPU init fails.
"""
if _TranslatorState.gpu_initialized:
return
if not _check_cuda_available():
_TranslatorState.gpu_initialized = True
return
logger.info(
"CUDA detected, initializing GPU acceleration..."
)
try:
device_name = _validate_gpu_device()
logger.info(" Using GPU: %s", device_name)
os.environ["CT2_CUDA_ALLOW_FP16"] = "1"
os.environ["CT2_USE_EXPERIMENTAL_PACKED_GEMM"] = "1"
_TranslatorState.gpu_initialized = True
logger.info(" GPU acceleration enabled.")
except Exception as e:
msg = (
f"CUDA is available but GPU initialization failed: "
f"{e}\nThis may be due to incompatible CUDA "
"version or driver issues.\n"
"To disable GPU and use CPU only, set "
"environment variable: CT2_FORCE_CPU=1"
)
raise RuntimeError(msg) from e
def _check_argos() -> bool:
"""Check if argostranslate is available."""
return argostranslate is not None
def _check_deep_translator() -> bool:
"""Check if deep-translator is available."""
return GoogleTranslator is not None
def _check_langdetect() -> bool:
"""Check if langdetect is available."""
return langdetect is not None
def detect_language(text: str) -> str | None:
"""Detect the language of a text.
Args:
text: The text to analyze.
Returns:
ISO 639-1 language code (e.g., 'en', 'la', 'pl') or None if detection fails.
"""
if not _check_langdetect():
return None
try:
sample = (
text[:_LANG_DETECT_SAMPLE_SIZE]
if len(text) > _LANG_DETECT_SAMPLE_SIZE
else text
)
return langdetect.detect(sample) # type: ignore[no-any-return,union-attr]
except langdetect.LangDetectException: # type: ignore[attr-defined,union-attr]
return None
class TranslationResult(NamedTuple):
"""Result of a translation."""
source_word: str
translated_word: str
source_lang: str
target_lang: str
success: bool
error: str | None = None
def get_installed_languages() -> list[tuple[str, str]]:
"""Get list of installed languages.
@ -291,119 +153,6 @@ def download_languages(lang_codes: Sequence[str]) -> dict[str, bool]:
return results
def _ensure_argos_installed() -> None:
"""Ensure argostranslate is installed, attempt installation if not.
Raises:
ImportError: If argos cannot be installed.
"""
if _check_argos():
return
logger.info("argostranslate not found. Attempting to install...")
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "argostranslate"],
check=True,
capture_output=True,
)
# Attempt runtime re-import
importlib.import_module("argostranslate.package")
importlib.import_module("argostranslate.translate")
logger.info("argostranslate installed successfully.")
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if e.stderr else str(e)
msg = (
"argostranslate is required for offline "
"translation.\n\n"
"Install manually with one of:\n"
" pip install argostranslate"
" # In a virtualenv\n"
" pipx install argostranslate"
" # System-wide via pipx\n"
" pacman -S python-argostranslate"
" # Arch Linux (if available)\n\n"
f"Original error: {error_msg}"
)
raise ImportError(msg) from e
except ImportError:
msg = (
"argostranslate installation succeeded but "
"import failed"
)
raise ImportError(msg) from None
def _ensure_language_pair(from_lang: str, to_lang: str) -> None:
"""Ensure the language pair is available, download if needed.
Args:
from_lang: Source language code.
to_lang: Target language code.
Raises:
ValueError: If language pair cannot be obtained.
"""
installed_languages = (
argostranslate.translate.get_installed_languages()
)
from_lang_obj = None
to_lang_obj = None
for lang in installed_languages:
if lang.code == from_lang:
from_lang_obj = lang
if lang.code == to_lang:
to_lang_obj = lang
if from_lang_obj and to_lang_obj:
# Check if translation is available
translation = from_lang_obj.get_translation(to_lang_obj)
if translation:
return # Already available
# Need to download
logger.info(
"Downloading language pack: %s -> %s...",
from_lang,
to_lang,
)
logger.info(" Fetching package index...")
argostranslate.package.update_package_index()
available = argostranslate.package.get_available_packages()
pkg = next(
(
p
for p in available
if p.from_code == from_lang and p.to_code == to_lang
),
None,
)
if pkg is None:
msg = (
f"No language pack available for "
f"{from_lang} -> {to_lang}. "
"Available pairs can be listed with "
"--list-languages."
)
raise ValueError(msg)
logger.info(
" Downloading package (~50-100MB, "
"this may take a minute)...",
)
download_path = pkg.download()
logger.info(" Installing language pack...")
argostranslate.package.install_from_path(download_path)
logger.info(
"Language pack %s -> %s installed.",
from_lang,
to_lang,
)
def translate_word(
word: str,
from_lang: str,
@ -443,12 +192,17 @@ def translate_word(
try:
translated = argostranslate.translate.translate(
word, from_lang, to_lang,
word,
from_lang,
to_lang,
)
# Cache the result
if use_cache and get_translation_cache is not None:
get_translation_cache().set(
word, from_lang, to_lang, translated,
word,
from_lang,
to_lang,
translated,
)
return TranslationResult(
source_word=word,
@ -551,9 +305,7 @@ def _run_batch_translation(
new_translations: dict[str, str] = {}
num_to_translate = len(words_to_translate)
gpu_status = (
" (GPU)" if _check_cuda_available() else " (CPU)"
)
gpu_status = " (GPU)" if _check_cuda_available() else " (CPU)"
logger.info(
"Translating %d words from %s to %s%s...",
num_to_translate,
@ -577,8 +329,7 @@ def _run_batch_translation(
pct = int(words_done / num_to_translate * 100)
logger.info(
" [%3d%%] Translating batch %d/%d "
"(%d/%d words)...",
" [%3d%%] Translating batch %d/%d " "(%d/%d words)...",
pct,
batch_idx + 1,
total_batches,
@ -587,16 +338,16 @@ def _run_batch_translation(
)
_, batch_translations = _translate_batch_worker(
batch_words, from_lang, to_lang, batch_idx,
batch_words,
from_lang,
to_lang,
batch_idx,
)
new_translations.update(batch_translations)
logger.info(" Translation complete.")
except Exception as e:
msg = (
f"Translation failed for "
f"{from_lang} -> {to_lang}: {e}"
)
msg = f"Translation failed for " f"{from_lang} -> {to_lang}: {e}"
raise RuntimeError(msg) from e
return new_translations
@ -639,26 +390,29 @@ def translate_words_batch(
if use_cache and get_translation_cache is not None:
cache = get_translation_cache()
cached_results = cache.get_many(
list(words), from_lang, to_lang,
list(words),
from_lang,
to_lang,
)
# Find words that still need translation
words_to_translate = [
word for word in words
if word.lower() not in cached_results
]
words_to_translate = [word for word in words if word.lower() not in cached_results]
# Translate uncached words using argos batch
new_translations: dict[str, str] = {}
if words_to_translate:
new_translations = _run_batch_translation(
words_to_translate, from_lang, to_lang,
words_to_translate,
from_lang,
to_lang,
)
# Cache new translations
if use_cache and get_translation_cache is not None:
get_translation_cache().set_many(
new_translations, from_lang, to_lang,
new_translations,
from_lang,
to_lang,
)
# Merge cached and new translations
@ -682,270 +436,7 @@ def translate_words_batch(
return results
def format_translations(
results: list[TranslationResult],
*,
show_errors: bool = True,
) -> str:
"""Format translation results as a table.
Args:
results: List of TranslationResult to format.
show_errors: If True, show error messages for failed translations.
Returns:
Formatted string with translations.
"""
if not results:
return "No translations."
lines: list[str] = []
# Find max widths
max_source = max(len(r.source_word) for r in results)
max_source = max(max_source, 6) # "Source" header
successful_lengths = [len(r.translated_word) for r in results if r.success]
max_trans = max(successful_lengths) if successful_lengths else 0
max_trans = max(max_trans, 11) # "Translation" header minimum
# Header
from_lang = results[0].source_lang
to_lang = results[0].target_lang
lines.append(f"Translation: {from_lang} -> {to_lang}")
lines.append("")
lines.append(f"{'Source':<{max_source}} {'Translation':<{max_trans}}")
lines.append("-" * (max_source + max_trans + 2))
# Data
for r in results:
if r.success:
lines.append(
f"{r.source_word:<{max_source}} {r.translated_word:<{max_trans}}"
)
elif show_errors:
error_msg = f"[Error: {r.error}]" if r.error else "[Failed]"
lines.append(f"{r.source_word:<{max_source}} {error_msg}")
return "\n".join(lines)
def read_file(filepath: str | Path) -> str:
"""Read text content from a file."""
return Path(filepath).read_text(encoding="utf-8")
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the translator CLI."""
parser = argparse.ArgumentParser(
description="Offline translator using Argos Translate.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
action_group = parser.add_mutually_exclusive_group()
action_group.add_argument(
"--list-languages",
"-l",
action="store_true",
help="List installed languages",
)
action_group.add_argument(
"--list-available",
"-L",
action="store_true",
help="List available language packages for download",
)
action_group.add_argument(
"--download",
"-d",
nargs="+",
metavar="LANG",
help=(
"Download language packs "
"(e.g., --download en es pl)"
),
)
input_group = parser.add_mutually_exclusive_group()
input_group.add_argument(
"--text",
"-t",
type=str,
help="Single text/word to translate",
)
input_group.add_argument(
"--words",
"-w",
nargs="+",
help="Words to translate",
)
input_group.add_argument(
"--words-file",
"-W",
type=str,
help="File with words to translate (one per line)",
)
parser.add_argument(
"--from",
"-f",
dest="from_lang",
type=str,
default="en",
help="Source language code (default: en)",
)
parser.add_argument(
"--to",
"-T",
dest="to_lang",
type=str,
default="en",
help="Target language code (default: en)",
)
parser.add_argument(
"--output",
"-o",
type=str,
help="Output file path",
)
return parser
def _handle_list_languages() -> int:
"""Handle --list-languages command."""
langs = get_installed_languages()
if not langs:
sys.stdout.write("No languages installed.\n")
sys.stdout.write(
"Download some with: --download en es pl de fr\n",
)
else:
sys.stdout.write("Installed languages:\n")
for code, name in sorted(langs):
sys.stdout.write(f" {code}: {name}\n")
return 0
def _handle_list_available() -> int:
"""Handle --list-available command."""
packages = get_available_packages()
if not packages:
sys.stdout.write(
"No packages available "
"(check internet connection).\n",
)
else:
sys.stdout.write("Available language packages:\n")
for from_code, from_name, to_code, to_name in sorted(
packages,
):
sys.stdout.write(
f" {from_code} ({from_name})"
f" -> {to_code} ({to_name})\n",
)
return 0
def _handle_download(lang_codes: list[str]) -> int:
"""Handle --download command."""
download_results = download_languages(lang_codes)
success_count = sum(
1 for v in download_results.values() if v
)
sys.stdout.write(
f"\nDownloaded {success_count}/"
f"{len(download_results)} language pairs.\n",
)
return 0 if success_count > 0 else 1
def _collect_words(
args: argparse.Namespace,
) -> list[str] | None:
"""Collect words from args. Returns None on error."""
if args.text:
return [args.text]
if args.words:
return args.words
if args.words_file:
try:
content = read_file(args.words_file)
except FileNotFoundError:
sys.stderr.write(
f"Error: File not found: {args.words_file}\n",
)
return None
return [
w.strip()
for w in content.splitlines()
if w.strip()
]
return []
def _handle_translation(args: argparse.Namespace) -> int:
"""Handle the translation action."""
try:
results = translate_words_batch(
args.words, args.from_lang, args.to_lang,
)
except ImportError:
logger.exception("Translation import error")
return 1
output = format_translations(results)
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
sys.stdout.write(
f"Translations written to {args.output}\n",
)
else:
sys.stdout.write(output + "\n")
if any(not r.success for r in results):
return 1
return 0
def main(argv: Sequence[str] | None = None) -> int:
"""Main entry point for the translator.
Args:
argv: Command line arguments.
Returns:
Exit code.
"""
parser = _build_parser()
args = parser.parse_args(argv)
if not _check_argos():
sys.stderr.write(
"Error: argostranslate is not installed.\n"
"Install it with: pip install argostranslate\n",
)
return 1
if args.list_languages:
return _handle_list_languages()
if args.list_available:
return _handle_list_available()
if args.download:
return _handle_download(args.download)
words = _collect_words(args)
if not words:
if words is not None:
parser.print_help()
return 1
args.words = words
return _handle_translation(args)
if __name__ == "__main__":
import sys
sys.exit(main())

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