diff --git a/python_pkg/anki_decks/polish_coastal_features/polish_coastal_features_anki.py b/python_pkg/anki_decks/polish_coastal_features/polish_coastal_features_anki.py
index 7279ee5..3faa22d 100644
--- a/python_pkg/anki_decks/polish_coastal_features/polish_coastal_features_anki.py
+++ b/python_pkg/anki_decks/polish_coastal_features/polish_coastal_features_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py b/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py
index ce49a89..16849db 100644
--- a/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py
+++ b/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py b/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py
index c5b859b..43102d2 100644
--- a/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py
+++ b/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py b/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py
index 6748be7..93060a8 100644
--- a/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py
+++ b/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py b/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py
index 1560461..2d5fbae 100644
--- a/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py
+++ b/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_nature_reserves/polish_nature_reserves_anki.py b/python_pkg/anki_decks/polish_nature_reserves/polish_nature_reserves_anki.py
index 49d0781..47d0091 100644
--- a/python_pkg/anki_decks/polish_nature_reserves/polish_nature_reserves_anki.py
+++ b/python_pkg/anki_decks/polish_nature_reserves/polish_nature_reserves_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py b/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py
index 0683252..5f8450a 100644
--- a/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py
+++ b/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py
@@ -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 = """
diff --git a/python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py b/python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py
index 9b45207..d7d6bd2 100755
--- a/python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py
+++ b/python_pkg/anki_decks/warsaw_districts/warsaw_districts_anki.py
@@ -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
diff --git a/python_pkg/cinema_planner/_cinema_parsing.py b/python_pkg/cinema_planner/_cinema_parsing.py
new file mode 100644
index 0000000..e9b44ed
--- /dev/null
+++ b/python_pkg/cinema_planner/_cinema_parsing.py
@@ -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
diff --git a/python_pkg/cinema_planner/_cinema_scheduling.py b/python_pkg/cinema_planner/_cinema_scheduling.py
new file mode 100644
index 0000000..533bdd4
--- /dev/null
+++ b/python_pkg/cinema_planner/_cinema_scheduling.py
@@ -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")
diff --git a/python_pkg/cinema_planner/cinema_planner.py b/python_pkg/cinema_planner/cinema_planner.py
index fb06884..4f2afab 100755
--- a/python_pkg/cinema_planner/cinema_planner.py
+++ b/python_pkg/cinema_planner/cinema_planner.py
@@ -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,
diff --git a/python_pkg/keyboard_coop/_dictionary.py b/python_pkg/keyboard_coop/_dictionary.py
new file mode 100644
index 0000000..b4a3235
--- /dev/null
+++ b/python_pkg/keyboard_coop/_dictionary.py
@@ -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)
diff --git a/python_pkg/keyboard_coop/main.py b/python_pkg/keyboard_coop/main.py
index 9aea578..55f73ef 100644
--- a/python_pkg/keyboard_coop/main.py
+++ b/python_pkg/keyboard_coop/main.py
@@ -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
diff --git a/python_pkg/lichess_bot/_game_logic.py b/python_pkg/lichess_bot/_game_logic.py
new file mode 100644
index 0000000..d6ca600
--- /dev/null
+++ b/python_pkg/lichess_bot/_game_logic.py
@@ -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)
diff --git a/python_pkg/lichess_bot/main.py b/python_pkg/lichess_bot/main.py
index bb218d7..49743d9 100644
--- a/python_pkg/lichess_bot/main.py
+++ b/python_pkg/lichess_bot/main.py
@@ -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,
diff --git a/python_pkg/lichess_bot/tests/test_lichess_api.py b/python_pkg/lichess_bot/tests/test_lichess_api.py
index 68ba01c..18fd2ff 100644
--- a/python_pkg/lichess_bot/tests/test_lichess_api.py
+++ b/python_pkg/lichess_bot/tests/test_lichess_api.py
@@ -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
diff --git a/python_pkg/lichess_bot/tests/test_lichess_api_part2.py b/python_pkg/lichess_bot/tests/test_lichess_api_part2.py
new file mode 100644
index 0000000..6e28f10
--- /dev/null
+++ b/python_pkg/lichess_bot/tests/test_lichess_api_part2.py
@@ -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
diff --git a/python_pkg/music_gen/_music_generation.py b/python_pkg/music_gen/_music_generation.py
new file mode 100644
index 0000000..783949c
--- /dev/null
+++ b/python_pkg/music_gen/_music_generation.py
@@ -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
diff --git a/python_pkg/music_gen/_music_speech.py b/python_pkg/music_gen/_music_speech.py
new file mode 100644
index 0000000..072c28c
--- /dev/null
+++ b/python_pkg/music_gen/_music_speech.py
@@ -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
diff --git a/python_pkg/music_gen/music_generator.py b/python_pkg/music_gen/music_generator.py
index a2e5e45..272273b 100755
--- a/python_pkg/music_gen/music_generator.py
+++ b/python_pkg/music_gen/music_generator.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py b/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py
new file mode 100644
index 0000000..eedb3f8
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py
@@ -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)]
+ )
diff --git a/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py b/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py
new file mode 100644
index 0000000..55920ad
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py
@@ -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 ---
diff --git a/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py b/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py
new file mode 100644
index 0000000..569803b
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py b/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py
new file mode 100644
index 0000000..3b92c93
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_arch_layers.py b/python_pkg/praca_magisterska_video/generate_images/_arch_layers.py
new file mode 100644
index 0000000..95fbd46
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_arch_layers.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_common.py b/python_pkg/praca_magisterska_video/generate_images/_automata_common.py
new file mode 100644
index 0000000..8397495
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_automata_common.py
@@ -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,
+ )
diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py b/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py
new file mode 100644
index 0000000..e7fee79
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py b/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py
new file mode 100644
index 0000000..ca7b80c
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py b/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py
new file mode 100644
index 0000000..c92fbfc
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py b/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py
new file mode 100644
index 0000000..823dc11
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py
new file mode 100644
index 0000000..96c2374
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py
@@ -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")
+
+
diff --git a/python_pkg/praca_magisterska_video/generate_images/_norm_advanced.py b/python_pkg/praca_magisterska_video/generate_images/_norm_advanced.py
new file mode 100644
index 0000000..2a01ec7
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_norm_advanced.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_norm_basic.py b/python_pkg/praca_magisterska_video/generate_images/_norm_basic.py
new file mode 100644
index 0000000..487a6b9
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_norm_basic.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_norm_higher.py b/python_pkg/praca_magisterska_video/generate_images/_norm_higher.py
new file mode 100644
index 0000000..acccdc5
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_norm_higher.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_pattern_navigation.py b/python_pkg/praca_magisterska_video/generate_images/_pattern_navigation.py
new file mode 100644
index 0000000..1bce7ee
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_pattern_navigation.py
@@ -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
+# ============================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py b/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py
new file mode 100644
index 0000000..a1efb8b
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py b/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py
new file mode 100644
index 0000000..b435167
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/_process_bpmn_uml.py b/python_pkg/praca_magisterska_video/generate_images/_process_bpmn_uml.py
new file mode 100644
index 0000000..46cf104
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_process_bpmn_uml.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py b/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py
new file mode 100644
index 0000000..c62cf33
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py
@@ -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")
+
+
+# =========================================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9q12_common.py b/python_pkg/praca_magisterska_video/generate_images/_q9q12_common.py
new file mode 100644
index 0000000..1cde342
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_q9q12_common.py
@@ -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,
+ )
diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_flow.py b/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_flow.py
new file mode 100644
index 0000000..93774a2
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_flow.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_graph.py b/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_graph.py
new file mode 100644
index 0000000..6df47a4
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_q9q12_network_graph.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9q12_processes.py b/python_pkg/praca_magisterska_video/generate_images/_q9q12_processes.py
new file mode 100644
index 0000000..d4daf4a
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_q9q12_processes.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_robot_movement_ros.py b/python_pkg/praca_magisterska_video/generate_images/_robot_movement_ros.py
new file mode 100644
index 0000000..3eb30f6
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_robot_movement_ros.py
@@ -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)
+# ============================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/_robot_pyramid_vendor.py b/python_pkg/praca_magisterska_video/generate_images/_robot_pyramid_vendor.py
new file mode 100644
index 0000000..d10a333
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_robot_pyramid_vendor.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_robot_ros_rapid.py b/python_pkg/praca_magisterska_video/generate_images/_robot_ros_rapid.py
new file mode 100644
index 0000000..4ada340
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_robot_ros_rapid.py
@@ -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
+# ============================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py b/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py
new file mode 100644
index 0000000..1ce7845
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py
@@ -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
+# ============================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/_study_consensus.py b/python_pkg/praca_magisterska_video/generate_images/_study_consensus.py
new file mode 100644
index 0000000..921f835
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_study_consensus.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_study_network.py b/python_pkg/praca_magisterska_video/generate_images/_study_network.py
new file mode 100644
index 0000000..894c9ce
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_study_network.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/_study_vision.py b/python_pkg/praca_magisterska_video/generate_images/_study_vision.py
new file mode 100644
index 0000000..44326f2
--- /dev/null
+++ b/python_pkg/praca_magisterska_video/generate_images/_study_vision.py
@@ -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")
diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py b/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py
index 005eebc..418d286 100755
--- a/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py
+++ b/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py
@@ -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()
diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py
index d057477..e86ddb7 100755
--- a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py
+++ b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py
@@ -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,
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py
index 14c60a2..8a0b5f2 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py
index 1cfd7c7..3e7222d 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py
@@ -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 "
".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
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py
index 9d36391..8c6e0a8 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py
@@ -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 = (
- "Kluczowe zagadnienia:" + format_list(headers)
- )
+ answer_html = "Kluczowe zagadnienia:" + 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"{term}: {desc.strip()}"
- )
+ answer_parts.append(f"{term}: {desc.strip()}")
else:
answer_parts.append(f"{term}")
@@ -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 = (
- "
| Aspekt | Wartość |
"
- )
+ comparison_html = "| Aspekt | Wartość |
"
for aspect, value in items[:MAX_COMPARISON_ITEMS]:
comparison_html += (
- f"| {clean_text(aspect)} | "
- f"{clean_text(value)} |
"
+ f"| {clean_text(aspect)} | " f"{clean_text(value)} |
"
)
comparison_html += "
"
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", "
"
- ),
+ "back": clean_text(a_short).replace("\n", "
"),
"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:")
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py
index fe4425b..45fff9d 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py
@@ -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__":
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py
index f86effd..3a16f94 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py
@@ -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"{name} ({abbrev}): "
- f"{match.group(1).strip()}"
- )
+ parts.append(f"{name} ({abbrev}): " 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 = "
".join(
- clean_text(p) for p in answer_parts
- )
+ answer = "
".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"• {term}: {desc.strip()}"
- if desc
- else f"• {term}"
+ f"• {term}: {desc.strip()}" if desc else f"• {term}"
)
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"• {term}: {desc.strip()}"
- if desc
- else f"• {term}"
+ f"• {term}: {desc.strip()}" if desc else f"• {term}"
)
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 = "
".join(
- clean_text(line) for line in answer_lines
- )
+ question = header if header.endswith("?") else f"Wyjaśnij: {header}"
+ answer = "
".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": "
".join(
- clean_text(line) for line in clean_answer
- ),
+ "back": "
".join(clean_text(line) for line in clean_answer),
"tags": f"{base_tags} qa",
}
)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py
index 7745ed7..c8aee23 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py
@@ -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,
)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py
index 54353f8..d840e41 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py
@@ -11,1113 +11,38 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly.
from __future__ import annotations
-from dataclasses import dataclass
import logging
-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
+from python_pkg.praca_magisterska_video.generate_images._automata_common import (
+ OUTPUT_DIR,
+)
+from python_pkg.praca_magisterska_video.generate_images._automata_fa import (
+ draw_fa_recognition,
+)
+from python_pkg.praca_magisterska_video.generate_images._automata_lba import (
+ draw_lba_recognition,
+)
+from python_pkg.praca_magisterska_video.generate_images._automata_pda import (
+ draw_pda_recognition,
+)
+from python_pkg.praca_magisterska_video.generate_images._automata_tm import (
+ draw_tm_recognition,
+)
logger = logging.getLogger(__name__)
-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,
- )
-
-
-# ============================================================
-# 1. FA Recognition Example — DFA for strings ending in "ab"
-# ============================================================
-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")
-
-
-# ============================================================
-# 2. PDA Recognition Example — PDA for aⁿbⁿ
-# ============================================================
-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")
-
-
-# ============================================================
-# 3. LBA Recognition Example — LBA for aⁿbⁿcⁿ
-# ============================================================
-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")
-
-
-# ============================================================
-# 4. TM Recognition Example — TM for 0ⁿ1ⁿ
-# ============================================================
-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")
-
+__all__ = [
+ "draw_fa_recognition",
+ "draw_lba_recognition",
+ "draw_pda_recognition",
+ "draw_tm_recognition",
+]
# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
- logger.info(
- "Generating automata diagrams for PYTANIE 1..."
- )
+ logger.info("Generating automata diagrams for PYTANIE 1...")
draw_fa_recognition()
draw_pda_recognition()
draw_lba_recognition()
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_bf_negative_diagram.py b/python_pkg/praca_magisterska_video/generate_images/generate_bf_negative_diagram.py
index fd2faa4..009e4e8 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_bf_negative_diagram.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_bf_negative_diagram.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py
index 0ab8e5d..d56f0aa 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py
@@ -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()
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_pattern_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_pattern_diagrams.py
index c7cf596..1c9b96b 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_pattern_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_pattern_diagrams.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_process_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_process_diagrams.py
index b96cef1..8ac01bf 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_process_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_process_diagrams.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q9_q12_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q9_q12_diagrams.py
index f5cddb0..50c3d84 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_q9_q12_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_q9_q12_diagrams.py
@@ -12,1138 +12,25 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly.
from __future__ import annotations
import logging
-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")
-from pathlib import Path
-
-import matplotlib.patches as mpatches
-from matplotlib.patches import FancyBboxPatch
-import matplotlib.pyplot as plt
-import numpy as np
+from python_pkg.praca_magisterska_video.generate_images._q9q12_network_flow import (
+ gen_ford_fulkerson,
+ gen_hungarian,
+ gen_min_cost_flow,
+)
+from python_pkg.praca_magisterska_video.generate_images._q9q12_network_graph import (
+ gen_cpm,
+ gen_kruskal,
+ gen_tsp,
+)
+from python_pkg.praca_magisterska_video.generate_images._q9q12_processes import (
+ gen_deadlock_illustration,
+ gen_ipc_mechanisms,
+ gen_producer_consumer,
+)
_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)
-
-
-# ============================================================
-# PYTANIE 9 DIAGRAMS
-# ============================================================
-
-
-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")
-
-
-# ============================================================
-# PYTANIE 12 DIAGRAMS
-# ============================================================
-
-
-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,
- )
-
-
-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_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")
-
-
-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")
-
-
# ============================================================
# MAIN
# ============================================================
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_robot_lang_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_robot_lang_diagrams.py
index e9b21b7..f226d79 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_robot_lang_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_robot_lang_diagrams.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py
index b68a5b1..fe3a9a6 100644
--- a/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py
@@ -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,
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_shortest_path_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_shortest_path_diagrams.py
index a352028..12e1f5d 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_shortest_path_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_shortest_path_diagrams.py
@@ -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)
diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_study_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_study_diagrams.py
index d91b331..10ce7e8 100755
--- a/python_pkg/praca_magisterska_video/generate_images/generate_study_diagrams.py
+++ b/python_pkg/praca_magisterska_video/generate_images/generate_study_diagrams.py
@@ -22,9 +22,6 @@ from pathlib import Path
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
-import matplotlib.pyplot as plt
-import numpy as np
-from scipy.stats import norm
if TYPE_CHECKING:
from matplotlib.axes import Axes
@@ -102,983 +99,22 @@ def draw_arrow(
)
-# ============================================================
-# PYTANIE 12: Network Optimization Models (Mnemonic Overview)
-# ============================================================
-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")
-
-
-# ============================================================
-# PYTANIE 21: Vector Clock Timeline
-# ============================================================
-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=LN, 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=LN, 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)
- # P1:A → P2:C (msg sent after A, received at C)
- 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")
-
- # P1:E → P2:F
- 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")
-
-
-# ============================================================
-# PYTANIE 22: Linearizability vs Sequential Consistency
-# ============================================================
-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")
-
-
-# ============================================================
-# PYTANIE 22: Paxos Protocol Flow
-# ============================================================
-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")
-
-
-# ============================================================
-# PYTANIE 24: HOG Pipeline Overview
-# ============================================================
-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")
-
-
-# ============================================================
-# PYTANIE 24: R-CNN Evolution
-# ============================================================
-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\u00d3\u017bNE 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",
- )
-
-
-# ============================================================
-# PYTANIE 23: Segmentation types comparison
-# ============================================================
-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)
-
- # Image: sky (top), two cars (bottom), road
- # Semantic: all sky=one color, all cars=one color, road=one color
-
- # Original image (stylized)
- ax = axes[0]
- ax.add_patch(
- mpatches.Rectangle((0, 4), 6, 2, facecolor="#DDDDDD", edgecolor=LN, lw=0.5)
- ) # sky
- 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)
- ) # road
- 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)
- ) # car1
- 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)
- ) # car2
- 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",
- )
-
- # Instance: different labels for cars
- _draw_instance_panel(axes[2])
-
- # Panoptic: both semantic labels AND instance IDs
- _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")
-
-
-# ============================================================
-# PYTANIE 32: FSD and SSD visualization
-# ============================================================
-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)
- # A ~ shifted right (better), B ~ shifted left
- 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: CDFs can cross, but integral is less
- 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) # same mean, more spread
- 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")
-
-
-# ============================================================
-# Main
-# ============================================================
if __name__ == "__main__":
+ from python_pkg.praca_magisterska_video.generate_images._study_consensus import (
+ draw_linearizability_vs_sequential,
+ draw_paxos_flow,
+ )
+ from python_pkg.praca_magisterska_video.generate_images._study_network import (
+ draw_network_models,
+ draw_vector_clock_timeline,
+ )
+ from python_pkg.praca_magisterska_video.generate_images._study_vision import (
+ draw_fsd_ssd,
+ draw_hog_pipeline,
+ draw_rcnn_evolution,
+ draw_segmentation_types,
+ )
+
logging.basicConfig(level=logging.INFO)
_logger.info("Generating study diagrams...")
draw_network_models()
diff --git a/python_pkg/praca_magisterska_video/visualize_q02.py b/python_pkg/praca_magisterska_video/visualize_q02.py
index 717cac0..23cc579 100644
--- a/python_pkg/praca_magisterska_video/visualize_q02.py
+++ b/python_pkg/praca_magisterska_video/visualize_q02.py
@@ -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(
diff --git a/python_pkg/repo_explorer/_discovery.py b/python_pkg/repo_explorer/_discovery.py
new file mode 100644
index 0000000..0f745bf
--- /dev/null
+++ b/python_pkg/repo_explorer/_discovery.py
@@ -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)"
diff --git a/python_pkg/repo_explorer/_execution.py b/python_pkg/repo_explorer/_execution.py
new file mode 100644
index 0000000..01294de
--- /dev/null
+++ b/python_pkg/repo_explorer/_execution.py
@@ -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)
diff --git a/python_pkg/repo_explorer/repo_explorer.py b/python_pkg/repo_explorer/repo_explorer.py
index 9f0a2b3..e1473c2 100755
--- a/python_pkg/repo_explorer/repo_explorer.py
+++ b/python_pkg/repo_explorer/repo_explorer.py
@@ -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)
-
# ---------------------------------------------------------------------------
diff --git a/python_pkg/stockfish_analysis/_move_analysis.py b/python_pkg/stockfish_analysis/_move_analysis.py
new file mode 100644
index 0000000..cebcaee
--- /dev/null
+++ b/python_pkg/stockfish_analysis/_move_analysis.py
@@ -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,
+ )
diff --git a/python_pkg/stockfish_analysis/analyze_chess_game.py b/python_pkg/stockfish_analysis/analyze_chess_game.py
index 1f56c70..2d3a8c5 100755
--- a/python_pkg/stockfish_analysis/analyze_chess_game.py
+++ b/python_pkg/stockfish_analysis/analyze_chess_game.py
@@ -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"
diff --git a/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py
index 5a16718..01da2a4 100644
--- a/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py
+++ b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py
@@ -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()
diff --git a/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part2.py b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part2.py
new file mode 100644
index 0000000..b16da04
--- /dev/null
+++ b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part2.py
@@ -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()
diff --git a/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part3.py b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part3.py
new file mode 100644
index 0000000..620b93f
--- /dev/null
+++ b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game_part3.py
@@ -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()
diff --git a/python_pkg/word_frequency/_cache_decks.py b/python_pkg/word_frequency/_cache_decks.py
new file mode 100644
index 0000000..e278185
--- /dev/null
+++ b/python_pkg/word_frequency/_cache_decks.py
@@ -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,
+ }
diff --git a/python_pkg/word_frequency/_deck_builder.py b/python_pkg/word_frequency/_deck_builder.py
new file mode 100644
index 0000000..0c06103
--- /dev/null
+++ b/python_pkg/word_frequency/_deck_builder.py
@@ -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"\1", excerpt_escaped
+ )
+ pattern_freq = re.compile(
+ rf"\b({re.escape(most_frequent)})\b",
+ re.IGNORECASE,
+ )
+ excerpt_escaped = pattern_freq.sub(
+ r"\1", excerpt_escaped
+ )
+ else:
+ pattern = re.compile(
+ rf"\b({re.escape(most_frequent)})\b",
+ re.IGNORECASE,
+ )
+ excerpt_escaped = pattern.sub(
+ r"\1", 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"{word}", 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)
diff --git a/python_pkg/word_frequency/_generation.py b/python_pkg/word_frequency/_generation.py
new file mode 100644
index 0000000..241dfdf
--- /dev/null
+++ b/python_pkg/word_frequency/_generation.py
@@ -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,
+ )
diff --git a/python_pkg/word_frequency/_learning_batch.py b/python_pkg/word_frequency/_learning_batch.py
new file mode 100644
index 0000000..0a8524f
--- /dev/null
+++ b/python_pkg/word_frequency/_learning_batch.py
@@ -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
diff --git a/python_pkg/word_frequency/_learning_constants.py b/python_pkg/word_frequency/_learning_constants.py
new file mode 100644
index 0000000..0ca4321
--- /dev/null
+++ b/python_pkg/word_frequency/_learning_constants.py
@@ -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())
diff --git a/python_pkg/word_frequency/_parsing.py b/python_pkg/word_frequency/_parsing.py
new file mode 100644
index 0000000..16a0ba2
--- /dev/null
+++ b/python_pkg/word_frequency/_parsing.py
@@ -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
diff --git a/python_pkg/word_frequency/_translator_cli.py b/python_pkg/word_frequency/_translator_cli.py
new file mode 100644
index 0000000..2e98dcc
--- /dev/null
+++ b/python_pkg/word_frequency/_translator_cli.py
@@ -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)
diff --git a/python_pkg/word_frequency/_translator_helpers.py b/python_pkg/word_frequency/_translator_helpers.py
new file mode 100644
index 0000000..909ab11
--- /dev/null
+++ b/python_pkg/word_frequency/_translator_helpers.py
@@ -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,
+ )
diff --git a/python_pkg/word_frequency/_types.py b/python_pkg/word_frequency/_types.py
new file mode 100644
index 0000000..3d972d8
--- /dev/null
+++ b/python_pkg/word_frequency/_types.py
@@ -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
diff --git a/python_pkg/word_frequency/anki_generator.py b/python_pkg/word_frequency/anki_generator.py
index 7251c47..282de8d 100755
--- a/python_pkg/word_frequency/anki_generator.py
+++ b/python_pkg/word_frequency/anki_generator.py
@@ -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"\1", excerpt_escaped
- )
- pattern_freq = re.compile(
- rf"\b({re.escape(most_frequent)})\b",
- re.IGNORECASE,
- )
- excerpt_escaped = pattern_freq.sub(
- r"\1", excerpt_escaped
- )
- else:
- pattern = re.compile(
- rf"\b({re.escape(most_frequent)})\b",
- re.IGNORECASE,
- )
- excerpt_escaped = pattern.sub(
- r"\1", 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"{word}", 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)
diff --git a/python_pkg/word_frequency/cache.py b/python_pkg/word_frequency/cache.py
index 67e03fc..3320d21 100755
--- a/python_pkg/word_frequency/cache.py
+++ b/python_pkg/word_frequency/cache.py
@@ -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."""
diff --git a/python_pkg/word_frequency/cache.py.bak b/python_pkg/word_frequency/cache.py.bak
deleted file mode 100755
index 75f4002..0000000
--- a/python_pkg/word_frequency/cache.py.bak
+++ /dev/null
@@ -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())
diff --git a/python_pkg/word_frequency/excerpt_finder.py b/python_pkg/word_frequency/excerpt_finder.py
index fcbd765..1dae19a 100755
--- a/python_pkg/word_frequency/excerpt_finder.py
+++ b/python_pkg/word_frequency/excerpt_finder.py
@@ -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
diff --git a/python_pkg/word_frequency/learning_pipe.py b/python_pkg/word_frequency/learning_pipe.py
index 2d788a2..c474da2 100755
--- a/python_pkg/word_frequency/learning_pipe.py
+++ b/python_pkg/word_frequency/learning_pipe.py
@@ -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)
diff --git a/python_pkg/word_frequency/tests/_translator_helpers.py b/python_pkg/word_frequency/tests/_translator_helpers.py
new file mode 100644
index 0000000..294197a
--- /dev/null
+++ b/python_pkg/word_frequency/tests/_translator_helpers.py
@@ -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()
diff --git a/python_pkg/word_frequency/tests/conftest.py b/python_pkg/word_frequency/tests/conftest.py
new file mode 100644
index 0000000..3aefac0
--- /dev/null
+++ b/python_pkg/word_frequency/tests/conftest.py
@@ -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
diff --git a/python_pkg/word_frequency/tests/test_analyzer.py b/python_pkg/word_frequency/tests/test_analyzer.py
index 4b01593..7091038 100644
--- a/python_pkg/word_frequency/tests/test_analyzer.py
+++ b/python_pkg/word_frequency/tests/test_analyzer.py
@@ -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
diff --git a/python_pkg/word_frequency/tests/test_anki_generator.py b/python_pkg/word_frequency/tests/test_anki_generator.py
index ff421a9..ddec5b9 100755
--- a/python_pkg/word_frequency/tests/test_anki_generator.py
+++ b/python_pkg/word_frequency/tests/test_anki_generator.py
@@ -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 = [
diff --git a/python_pkg/word_frequency/tests/test_excerpt_finder.py b/python_pkg/word_frequency/tests/test_excerpt_finder.py
index 2cdaea3..c0d6492 100644
--- a/python_pkg/word_frequency/tests/test_excerpt_finder.py
+++ b/python_pkg/word_frequency/tests/test_excerpt_finder.py
@@ -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")
diff --git a/python_pkg/word_frequency/tests/test_learning_pipe.py b/python_pkg/word_frequency/tests/test_learning_pipe.py
index 1444c32..0757d3e 100644
--- a/python_pkg/word_frequency/tests/test_learning_pipe.py
+++ b/python_pkg/word_frequency/tests/test_learning_pipe.py
@@ -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(
diff --git a/python_pkg/word_frequency/tests/test_translator.py b/python_pkg/word_frequency/tests/test_translator.py
index d3678f2..b20189c 100644
--- a/python_pkg/word_frequency/tests/test_translator.py
+++ b/python_pkg/word_frequency/tests/test_translator.py
@@ -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
diff --git a/python_pkg/word_frequency/tests/test_translator_part2.py b/python_pkg/word_frequency/tests/test_translator_part2.py
new file mode 100644
index 0000000..7a43aa4
--- /dev/null
+++ b/python_pkg/word_frequency/tests/test_translator_part2.py
@@ -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
diff --git a/python_pkg/word_frequency/translator.py b/python_pkg/word_frequency/translator.py
index 354571a..e1dea3f 100755
--- a/python_pkg/word_frequency/translator.py
+++ b/python_pkg/word_frequency/translator.py
@@ -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())
diff --git a/python_pkg/word_frequency/vocabulary_curve.py b/python_pkg/word_frequency/vocabulary_curve.py
index 54ca7e5..4682846 100755
--- a/python_pkg/word_frequency/vocabulary_curve.py
+++ b/python_pkg/word_frequency/vocabulary_curve.py
@@ -23,11 +23,7 @@ from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from collections.abc import Sequence
-try:
- from python_pkg.word_frequency.analyzer import analyze_text, read_file
-except ImportError:
- from analyzer import analyze_text, read_file
-
+from python_pkg.word_frequency.analyzer import analyze_text, read_file
logger = logging.getLogger(__name__)