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 = ( - "" - ) + comparison_html = "
AspektWartość
" for aspect, value in items[:MAX_COMPARISON_ITEMS]: comparison_html += ( - f"" - f"" + f"" f"" ) comparison_html += "
AspektWartość
{clean_text(aspect)}{clean_text(value)}
{clean_text(aspect)}{clean_text(value)}
" 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__)