mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:43:02 +02:00
refactor: enforce 500-line limit on all Python source files
Split 18+ Python files that exceeded 500 lines into smaller modules with helper files (prefixed with _). All functions are re-exported from the original modules to maintain backward compatibility with test patches and external imports. Files split: - moviepy_showcase.py (1212 -> 302 + 3 helpers) - anki_generator.py (1174 -> 473 + 4 helpers) - test_analyze_chess_game.py (1152 -> 361 + 2 parts) - poker_modifier_app.py (1024 -> 263 + 2 helpers) - transcribe_fw.py (1007 -> 342 + 3 helpers) - music_generator.py (1002 -> 319 + 2 helpers) - translator.py (951 -> 442 + 2 helpers) - cinema_planner.py (893 -> 369 + 2 helpers) - lichess_bot/main.py (757 -> 495 + _game_logic.py) - test_translator.py (725 -> 289 + part2 + conftest) - test_lichess_api.py (680 -> 475 + part2) - learning_pipe.py (668 -> 375 + 2 helpers) - cache.py (655 -> 360 + _cache_decks.py) - analyze_chess_game.py (632 -> 463 + _move_analysis.py) - visualize_q02.py (609 -> 371 + helper) - repo_explorer.py (602 -> 347 + 2 helpers) - keyboard_coop/main.py (515 -> 416 + _dictionary.py) - scanning.py (501 -> 314 + _enforce_loop.py) All tests pass: 144 lichess_bot (100% branch coverage), 243 others. No new lint errors introduced.
This commit is contained in:
parent
27a1ef634c
commit
8f2fbd2311
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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
|
||||
|
||||
342
python_pkg/cinema_planner/_cinema_parsing.py
Normal file
342
python_pkg/cinema_planner/_cinema_parsing.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""Parsing functions for Cinema City schedules and manual input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, TextIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants for validation and parsing
|
||||
_MIN_MANUAL_LINE_PARTS = 3
|
||||
_MIN_TITLE_LENGTH = 3
|
||||
_DEFAULT_MOVIE_DURATION = 120
|
||||
_TITLE_LOOKAHEAD_LINES = 5
|
||||
|
||||
|
||||
def _try_import(name: str) -> types.ModuleType | None:
|
||||
"""Attempt to import a module, returning None if unavailable."""
|
||||
try:
|
||||
return importlib.import_module(name)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
_pdfplumber = _try_import("pdfplumber")
|
||||
_fitz = _try_import("fitz")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Movie:
|
||||
"""A movie with screening times and metadata."""
|
||||
|
||||
name: str
|
||||
start_times: list[int]
|
||||
duration: int
|
||||
genres: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_time(time_str: str) -> int:
|
||||
"""Parse time string like '18:20' to minutes from midnight."""
|
||||
time_str = time_str.strip().replace(".", ":")
|
||||
match = re.match(r"(\d{1,2}):(\d{2})", time_str)
|
||||
if not match:
|
||||
msg = f"Invalid time format: {time_str}"
|
||||
raise ValueError(msg)
|
||||
hours, minutes = int(match.group(1)), int(match.group(2))
|
||||
return hours * 60 + minutes
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> int:
|
||||
"""Parse duration like '1h 46m', '1:46', '106m', '110 min', etc."""
|
||||
duration_str = duration_str.strip().lower()
|
||||
|
||||
# Try "X min" format (from Cinema City)
|
||||
match = re.search(r"(\d+)\s*min", duration_str)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
hours = 0
|
||||
minutes = 0
|
||||
|
||||
h_match = re.search(r"(\d+)\s*h", duration_str)
|
||||
m_match = re.search(r"(\d+)\s*m(?!in)", duration_str)
|
||||
|
||||
if h_match or m_match:
|
||||
if h_match:
|
||||
hours = int(h_match.group(1))
|
||||
if m_match:
|
||||
minutes = int(m_match.group(1))
|
||||
return hours * 60 + minutes
|
||||
|
||||
# Try "H:MM" format
|
||||
match = re.match(r"(\d+):(\d{2})", duration_str)
|
||||
if match:
|
||||
return int(match.group(1)) * 60 + int(match.group(2))
|
||||
|
||||
# Try pure minutes
|
||||
match = re.match(r"(\d+)", duration_str)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
msg = f"Invalid duration format: {duration_str}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def parse_manual_line(line: str) -> Movie | None:
|
||||
"""Parse a manual format line like 'Movie A, 18:20 or 20:50, 1h 46m'."""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
|
||||
parts = line.split(",")
|
||||
if len(parts) < _MIN_MANUAL_LINE_PARTS:
|
||||
msg = f"Invalid line format: {line}"
|
||||
raise ValueError(msg)
|
||||
|
||||
movie = parts[0].strip()
|
||||
times_str = parts[1].strip()
|
||||
duration_str = ",".join(parts[2:]).strip()
|
||||
|
||||
start_times = [
|
||||
parse_time(time_part)
|
||||
for time_part in re.split(r"\s+or\s+", times_str, flags=re.IGNORECASE)
|
||||
]
|
||||
|
||||
duration = parse_duration(duration_str)
|
||||
|
||||
return Movie(movie, start_times, duration)
|
||||
|
||||
|
||||
def _try_parse_time(time_str: str) -> int | None:
|
||||
"""Try to parse a time string, returning None on failure."""
|
||||
try:
|
||||
return parse_time(time_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _try_parse_manual_line(
|
||||
line: str,
|
||||
error_stream: TextIO | None = None,
|
||||
) -> Movie | None:
|
||||
"""Try to parse a manual line, writing errors to error_stream."""
|
||||
try:
|
||||
return parse_manual_line(line)
|
||||
except ValueError as e:
|
||||
if error_stream is not None:
|
||||
error_stream.write(f"Warning: {e}\n")
|
||||
return None
|
||||
|
||||
|
||||
def _try_parse_interactive_line(line: str) -> Movie | None:
|
||||
"""Try to parse a line in interactive mode, logging errors."""
|
||||
try:
|
||||
result = parse_manual_line(line)
|
||||
except ValueError:
|
||||
logger.exception(" Error parsing input")
|
||||
return None
|
||||
if result:
|
||||
logger.info(" Added: %s", result.name)
|
||||
return result
|
||||
|
||||
|
||||
def extract_date_from_html(content: str) -> str | None:
|
||||
"""Extract schedule date from Cinema City HTML."""
|
||||
# Look for date in YYYY-MM-DD format
|
||||
match = re.search(r"(202\d-\d{2}-\d{2})", content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def parse_cinema_city_html(
|
||||
filepath: str,
|
||||
) -> tuple[list[Movie], str | None]:
|
||||
"""Parse Cinema City HTML schedule.
|
||||
|
||||
Returns:
|
||||
Tuple of (movies, date).
|
||||
"""
|
||||
with Path(filepath).open(encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
movies: list[Movie] = []
|
||||
schedule_date = extract_date_from_html(content)
|
||||
|
||||
# Split content by movie sections
|
||||
sections = re.split(r'class="row movie-row', content)
|
||||
|
||||
for section in sections[1:]: # Skip first (before any movie)
|
||||
# Get movie name
|
||||
name_match = re.search(r'qb-movie-name">([^<]+)<', section)
|
||||
if not name_match:
|
||||
continue
|
||||
movie_name = name_match.group(1).strip()
|
||||
|
||||
# Get genres
|
||||
genre_match = re.search(
|
||||
r'class="mr-sm"[^>]*>([^<]+)<\s*span', section
|
||||
)
|
||||
genres: list[str] = []
|
||||
if genre_match:
|
||||
genre_text = genre_match.group(1).strip()
|
||||
genres = [
|
||||
g.strip() for g in genre_text.split(",") if g.strip()
|
||||
]
|
||||
|
||||
# Get duration
|
||||
duration_match = re.search(r"(\d+)\s*min", section)
|
||||
if not duration_match:
|
||||
continue
|
||||
duration = int(duration_match.group(1))
|
||||
|
||||
# Get screening times - look for time buttons
|
||||
times = re.findall(
|
||||
r'btn btn-primary btn-lg">\s*(\d{2}:\d{2})\s*<', section
|
||||
)
|
||||
if not times:
|
||||
# Try alternate pattern
|
||||
times = re.findall(
|
||||
r">\s*(\d{2}:\d{2})\s*\(HTTPS://", section
|
||||
)
|
||||
|
||||
if times:
|
||||
start_times = list(dict.fromkeys(
|
||||
parse_time(t) for t in times
|
||||
))
|
||||
movies.append(
|
||||
Movie(movie_name, start_times, duration, genres),
|
||||
)
|
||||
|
||||
# Deduplicate movies (same movie might appear multiple times)
|
||||
seen: set[str] = set()
|
||||
unique_movies: list[Movie] = []
|
||||
for movie in movies:
|
||||
if movie.name not in seen:
|
||||
seen.add(movie.name)
|
||||
unique_movies.append(movie)
|
||||
|
||||
return unique_movies, schedule_date
|
||||
|
||||
|
||||
def parse_cinema_city_pdf(filepath: str) -> list[Movie]:
|
||||
"""Parse Cinema City PDF schedule by extracting text."""
|
||||
if _pdfplumber is not None:
|
||||
with _pdfplumber.open(filepath) as pdf:
|
||||
full_text = ""
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
full_text += text + "\n"
|
||||
return parse_cinema_city_text(full_text)
|
||||
|
||||
return _parse_cinema_city_pdf_basic(filepath)
|
||||
|
||||
|
||||
def _parse_cinema_city_pdf_basic(filepath: str) -> list[Movie]:
|
||||
"""Basic PDF parsing using PyMuPDF or falling back to subprocess."""
|
||||
if _fitz is not None:
|
||||
doc = _fitz.open(filepath)
|
||||
full_text = ""
|
||||
for page in doc:
|
||||
full_text += page.get_text() + "\n"
|
||||
doc.close()
|
||||
return parse_cinema_city_text(full_text)
|
||||
|
||||
pdftotext_path = shutil.which("pdftotext")
|
||||
if pdftotext_path is None:
|
||||
_exit_no_pdf_support()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[pdftotext_path, "-layout", filepath, "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
_exit_no_pdf_support()
|
||||
|
||||
return parse_cinema_city_text(result.stdout)
|
||||
|
||||
|
||||
def _exit_no_pdf_support() -> None:
|
||||
"""Log PDF support error and exit."""
|
||||
logger.error(
|
||||
"Install pdfplumber, PyMuPDF, or poppler-utils for PDF support"
|
||||
)
|
||||
logger.error(" pip install pdfplumber")
|
||||
logger.error(" pip install pymupdf")
|
||||
logger.error(" pacman -S poppler")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_cinema_city_text(text: str) -> list[Movie]:
|
||||
"""Parse Cinema City schedule from extracted text."""
|
||||
movies: list[Movie] = []
|
||||
lines = text.split("\n")
|
||||
|
||||
current_movie: str | None = None
|
||||
current_duration: int | None = None
|
||||
current_times: list[int] = []
|
||||
|
||||
# Patterns for movie titles (all caps, usually)
|
||||
movie_title_pattern = re.compile(
|
||||
r"^([A-ZĄĆĘŁŃÓŚŹŻ][A-ZĄĆĘŁŃÓŚŹŻ0-9\s:,\.\-\!\?\(\)]+)$"
|
||||
)
|
||||
duration_pattern = re.compile(r"(\d+)\s*min")
|
||||
time_pattern = re.compile(r"\b(\d{1,2}:\d{2})\b")
|
||||
|
||||
for i, raw_line in enumerate(lines):
|
||||
line = raw_line.strip()
|
||||
|
||||
if (
|
||||
movie_title_pattern.match(line)
|
||||
and len(line) > _MIN_TITLE_LENGTH
|
||||
):
|
||||
if current_movie and current_times:
|
||||
movies.append(Movie(
|
||||
current_movie,
|
||||
list(dict.fromkeys(current_times)),
|
||||
current_duration or _DEFAULT_MOVIE_DURATION,
|
||||
))
|
||||
|
||||
current_movie = line.title()
|
||||
current_times = []
|
||||
current_duration = None
|
||||
|
||||
# Look ahead for duration
|
||||
end = min(i + _TITLE_LOOKAHEAD_LINES, len(lines))
|
||||
for j in range(i + 1, end):
|
||||
dur_match = duration_pattern.search(lines[j])
|
||||
if dur_match:
|
||||
current_duration = int(dur_match.group(1))
|
||||
break
|
||||
|
||||
if current_movie:
|
||||
times_in_line = time_pattern.findall(line)
|
||||
for t in times_in_line:
|
||||
parsed = _try_parse_time(t)
|
||||
if parsed is not None:
|
||||
current_times.append(parsed)
|
||||
|
||||
# Save last movie
|
||||
if current_movie and current_times:
|
||||
movies.append(Movie(
|
||||
current_movie,
|
||||
list(dict.fromkeys(current_times)),
|
||||
current_duration or _DEFAULT_MOVIE_DURATION,
|
||||
))
|
||||
|
||||
return movies
|
||||
222
python_pkg/cinema_planner/_cinema_scheduling.py
Normal file
222
python_pkg/cinema_planner/_cinema_scheduling.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Scheduling algorithm and display formatting for cinema plans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, TextIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.cinema_planner._cinema_parsing import Movie
|
||||
|
||||
# Ads duration before movie starts (Cinema City shows ~15 min of ads)
|
||||
ADS_DURATION = 15
|
||||
|
||||
_SEPARATOR_WIDTH = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class Screening:
|
||||
"""A specific screening of a movie at a particular time."""
|
||||
|
||||
movie: str
|
||||
start: int # minutes from midnight
|
||||
end: int # minutes from midnight
|
||||
|
||||
def overlaps(self, other: Screening, buffer: int = 0) -> bool:
|
||||
"""Check if this screening overlaps with another, considering buffer."""
|
||||
# Account for ADS_DURATION grace period
|
||||
return not (
|
||||
self.end + buffer <= other.start + ADS_DURATION
|
||||
or other.end + buffer <= self.start + ADS_DURATION
|
||||
)
|
||||
|
||||
def start_str(self) -> str:
|
||||
"""Format start time as HH:MM."""
|
||||
return f"{self.start // 60:02d}:{self.start % 60:02d}"
|
||||
|
||||
def end_str(self) -> str:
|
||||
"""Format end time as HH:MM."""
|
||||
return f"{self.end // 60:02d}:{self.end % 60:02d}"
|
||||
|
||||
|
||||
def find_best_schedule(
|
||||
movies: list[Movie],
|
||||
buffer: int,
|
||||
) -> list[list[Screening]]:
|
||||
"""Find ALL schedules that maximize number of movies watched."""
|
||||
movie_screenings: list[list[Screening]] = [
|
||||
[
|
||||
Screening(movie.name, start, start + movie.duration)
|
||||
for start in movie.start_times
|
||||
]
|
||||
for movie in movies
|
||||
]
|
||||
|
||||
best_count = 0
|
||||
all_best_schedules: list[list[Screening]] = []
|
||||
|
||||
def _backtrack(
|
||||
movie_idx: int,
|
||||
current_schedule: list[Screening],
|
||||
) -> None:
|
||||
nonlocal best_count, all_best_schedules
|
||||
|
||||
if movie_idx == len(movie_screenings):
|
||||
if len(current_schedule) > best_count:
|
||||
best_count = len(current_schedule)
|
||||
all_best_schedules = [current_schedule.copy()]
|
||||
elif (
|
||||
len(current_schedule) == best_count
|
||||
and best_count > 0
|
||||
):
|
||||
all_best_schedules.append(current_schedule.copy())
|
||||
return
|
||||
|
||||
# Pruning: can't beat the best
|
||||
remaining = len(movie_screenings) - movie_idx
|
||||
if len(current_schedule) + remaining < best_count:
|
||||
return
|
||||
|
||||
# Try each screening of current movie
|
||||
for screening in movie_screenings[movie_idx]:
|
||||
conflicts = any(
|
||||
screening.overlaps(s, buffer)
|
||||
for s in current_schedule
|
||||
)
|
||||
if not conflicts:
|
||||
current_schedule.append(screening)
|
||||
_backtrack(movie_idx + 1, current_schedule)
|
||||
current_schedule.pop()
|
||||
|
||||
# Also try skipping this movie
|
||||
_backtrack(movie_idx + 1, current_schedule)
|
||||
|
||||
_backtrack(0, [])
|
||||
|
||||
# Sort each schedule by start time and return
|
||||
return [
|
||||
sorted(schedule, key=lambda s: s.start)
|
||||
for schedule in all_best_schedules
|
||||
]
|
||||
|
||||
|
||||
def _format_single_schedule(
|
||||
schedule: list[Screening],
|
||||
output: TextIO,
|
||||
) -> None:
|
||||
"""Format a single schedule to the output stream."""
|
||||
for i, screening in enumerate(schedule, 1):
|
||||
duration = screening.end - screening.start
|
||||
hours, mins = divmod(duration, 60)
|
||||
actual_start = screening.start + ADS_DURATION
|
||||
actual_start_str = (
|
||||
f"{actual_start // 60:02d}:{actual_start % 60:02d}"
|
||||
)
|
||||
output.write(
|
||||
f" {i}. {screening.start_str()} - "
|
||||
f"{screening.end_str()} {screening.movie}\n"
|
||||
)
|
||||
output.write(
|
||||
f" Duration: {hours}h {mins}m "
|
||||
f"(movie starts ~{actual_start_str})\n"
|
||||
)
|
||||
if i < len(schedule):
|
||||
gap = schedule[i].start - screening.end
|
||||
if gap > 0:
|
||||
output.write(f" [{gap} min break]\n")
|
||||
output.write("\n")
|
||||
|
||||
|
||||
def _format_schedules(
|
||||
schedules: list[list[Screening]],
|
||||
all_movies: list[str],
|
||||
date: str | None = None,
|
||||
max_display: int = 5,
|
||||
*,
|
||||
output: TextIO | None = None,
|
||||
) -> None:
|
||||
"""Format optimal schedules to the output stream."""
|
||||
if output is None:
|
||||
output = sys.stdout
|
||||
|
||||
sep = "=" * _SEPARATOR_WIDTH
|
||||
thin_sep = "\u2500" * _SEPARATOR_WIDTH
|
||||
|
||||
if not schedules or not schedules[0]:
|
||||
output.write("No movies can be scheduled!\n")
|
||||
return
|
||||
|
||||
num_movies = len(schedules[0])
|
||||
num_schedules = len(schedules)
|
||||
|
||||
output.write(f"\n{sep}\n")
|
||||
if date:
|
||||
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
|
||||
else:
|
||||
output.write(" OPTIMAL CINEMA SCHEDULES\n")
|
||||
output.write(
|
||||
f" {num_movies} movies, "
|
||||
f"{num_schedules} possible combination(s)\n"
|
||||
)
|
||||
output.write(f"{sep}\n\n")
|
||||
|
||||
display_count = min(num_schedules, max_display)
|
||||
for idx, schedule in enumerate(schedules[:display_count], 1):
|
||||
if num_schedules > 1:
|
||||
output.write(f"{thin_sep}\n")
|
||||
output.write(f" OPTION {idx}:\n")
|
||||
output.write(f"{thin_sep}\n\n")
|
||||
_format_single_schedule(schedule, output)
|
||||
|
||||
if num_schedules > display_count:
|
||||
output.write(f"{thin_sep}\n")
|
||||
output.write(
|
||||
f" ... and {num_schedules - display_count} "
|
||||
"more combinations\n"
|
||||
)
|
||||
output.write(" (use -n to show more, e.g., -n 10)\n")
|
||||
output.write("\n")
|
||||
|
||||
# Show skipped movies (from first schedule as reference)
|
||||
scheduled_movies = {s.movie for s in schedules[0]}
|
||||
skipped = [m for m in all_movies if m not in scheduled_movies]
|
||||
if skipped and num_schedules == 1:
|
||||
output.write(f"{thin_sep}\n")
|
||||
output.write(f" Skipped movies ({len(skipped)}):\n")
|
||||
for movie in skipped:
|
||||
output.write(f" - {movie}\n")
|
||||
output.write("\n")
|
||||
|
||||
|
||||
def _format_all_movies(
|
||||
movies: list[Movie],
|
||||
date: str | None = None,
|
||||
*,
|
||||
output: TextIO | None = None,
|
||||
) -> None:
|
||||
"""Format all parsed movies to the output stream."""
|
||||
if output is None:
|
||||
output = sys.stdout
|
||||
|
||||
thin_sep = "\u2500" * _SEPARATOR_WIDTH
|
||||
|
||||
output.write(f"\n{thin_sep}\n")
|
||||
if date:
|
||||
output.write(f" Parsed {len(movies)} movies for {date}:\n")
|
||||
else:
|
||||
output.write(f" Parsed {len(movies)} movies:\n")
|
||||
output.write(f"{thin_sep}\n")
|
||||
for movie in movies:
|
||||
times_str = ", ".join(
|
||||
f"{t // 60:02d}:{t % 60:02d}"
|
||||
for t in sorted(movie.start_times)
|
||||
)
|
||||
genre_str = (
|
||||
f" [{', '.join(movie.genres)}]" if movie.genres else ""
|
||||
)
|
||||
output.write(
|
||||
f" {movie.name} ({movie.duration} min){genre_str}\n"
|
||||
)
|
||||
output.write(f" Times: {times_str}\n")
|
||||
output.write("\n")
|
||||
@ -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,
|
||||
|
||||
88
python_pkg/keyboard_coop/_dictionary.py
Normal file
88
python_pkg/keyboard_coop/_dictionary.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Dictionary loading for the keyboard cooperative word game."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_FALLBACK_DICTIONARY = {
|
||||
"cat",
|
||||
"dog",
|
||||
"car",
|
||||
"bat",
|
||||
"rat",
|
||||
"hat",
|
||||
"mat",
|
||||
"sat",
|
||||
"fat",
|
||||
"pat",
|
||||
"the",
|
||||
"and",
|
||||
"for",
|
||||
"are",
|
||||
"but",
|
||||
"not",
|
||||
"you",
|
||||
"all",
|
||||
"can",
|
||||
"had",
|
||||
"her",
|
||||
"was",
|
||||
"one",
|
||||
"our",
|
||||
"out",
|
||||
"day",
|
||||
"get",
|
||||
"has",
|
||||
"him",
|
||||
"his",
|
||||
"how",
|
||||
"man",
|
||||
"new",
|
||||
"now",
|
||||
"old",
|
||||
"see",
|
||||
"two",
|
||||
"way",
|
||||
"who",
|
||||
"boy",
|
||||
"work",
|
||||
"know",
|
||||
"place",
|
||||
"year",
|
||||
"live",
|
||||
"me",
|
||||
"back",
|
||||
"give",
|
||||
"good",
|
||||
}
|
||||
|
||||
|
||||
def load_dictionary(dictionary_dir: Path) -> set[str]:
|
||||
"""Load dictionary from words_dictionary.json file.
|
||||
|
||||
Args:
|
||||
dictionary_dir: Directory containing words_dictionary.json.
|
||||
|
||||
Returns:
|
||||
Set of valid English words.
|
||||
"""
|
||||
try:
|
||||
dictionary_path = dictionary_dir / "words_dictionary.json"
|
||||
with dictionary_path.open(encoding="utf-8") as f:
|
||||
dictionary_data = json.load(f)
|
||||
# Convert to set for faster lookup (we only need the keys)
|
||||
return set(dictionary_data.keys())
|
||||
except FileNotFoundError:
|
||||
_logger.warning(
|
||||
"words_dictionary.json not found, using fallback dictionary"
|
||||
)
|
||||
return set(_FALLBACK_DICTIONARY)
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning(
|
||||
"Error reading words_dictionary.json, using fallback dictionary"
|
||||
)
|
||||
return set(_FALLBACK_DICTIONARY)
|
||||
@ -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
|
||||
|
||||
293
python_pkg/lichess_bot/_game_logic.py
Normal file
293
python_pkg/lichess_bot/_game_logic.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""Game logic and challenge handling helpers for the Lichess bot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import chess
|
||||
import chess.pgn
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_pkg.lichess_bot.lichess_api import LichessAPI
|
||||
from python_pkg.lichess_bot.main import BotContext, GameMeta, GameState
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _update_clocks_from_state(state_data: dict[str, object], state: GameState) -> None:
|
||||
"""Update clock values from state data."""
|
||||
wtime = state_data.get("wtime")
|
||||
btime = state_data.get("btime")
|
||||
if state.color == "white":
|
||||
state.my_ms = int(wtime) if isinstance(wtime, int | float) else None
|
||||
state.opp_ms = int(btime) if isinstance(btime, int | float) else None
|
||||
else:
|
||||
state.my_ms = int(btime) if isinstance(btime, int | float) else None
|
||||
state.opp_ms = int(wtime) if isinstance(wtime, int | float) else None
|
||||
inc = state_data.get("winc") or state_data.get("binc")
|
||||
state.inc_ms = int(inc) if isinstance(inc, int | float) else 0
|
||||
|
||||
|
||||
def _extract_player_info(
|
||||
event: dict[str, object], state: GameState, meta: GameMeta, api: LichessAPI
|
||||
) -> None:
|
||||
"""Extract player info and determine color."""
|
||||
white_data = event.get("white", {})
|
||||
black_data = event.get("black", {})
|
||||
if not isinstance(white_data, dict) or not isinstance(black_data, dict):
|
||||
return
|
||||
white_id = white_data.get("id")
|
||||
black_id = black_data.get("id")
|
||||
meta.white_name = str(white_data.get("name") or white_id or "?")
|
||||
meta.black_name = str(black_data.get("name") or black_id or "?")
|
||||
me = api.get_my_user_id()
|
||||
if me == white_id:
|
||||
state.color = "white"
|
||||
elif me == black_id:
|
||||
state.color = "black"
|
||||
|
||||
|
||||
def _extract_game_full_data(
|
||||
event: dict[str, object],
|
||||
state: GameState,
|
||||
meta: GameMeta,
|
||||
api: LichessAPI,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Extract data from a gameFull event.
|
||||
|
||||
Returns:
|
||||
Tuple of (moves_string, status).
|
||||
"""
|
||||
state_data = event.get("state", {})
|
||||
if not isinstance(state_data, dict):
|
||||
state_data = {}
|
||||
moves = str(state_data.get("moves", ""))
|
||||
status = state_data.get("status")
|
||||
|
||||
_update_clocks_from_state(state_data, state)
|
||||
_extract_player_info(event, state, meta, api)
|
||||
|
||||
# Extract date
|
||||
with contextlib.suppress(Exception):
|
||||
created_ms = event.get("createdAt") or event.get("createdAtDate")
|
||||
if created_ms is not None:
|
||||
meta.date_iso = datetime.datetime.fromtimestamp(
|
||||
int(str(created_ms)) / 1000,
|
||||
tz=datetime.timezone.utc,
|
||||
).strftime("%Y.%m.%d")
|
||||
|
||||
meta.site_url = f"https://lichess.org/{meta.game_id}"
|
||||
|
||||
return moves, str(status) if status else None
|
||||
|
||||
|
||||
def _extract_game_state_data(
|
||||
event: dict[str, object], state: GameState
|
||||
) -> tuple[str, str | None]:
|
||||
"""Extract data from a gameState event.
|
||||
|
||||
Returns:
|
||||
Tuple of (moves_string, status).
|
||||
"""
|
||||
moves = str(event.get("moves", ""))
|
||||
status = event.get("status")
|
||||
|
||||
# Update clocks based on color
|
||||
if state.color == "white":
|
||||
state.my_ms = event.get("wtime", state.my_ms) # type: ignore[assignment]
|
||||
state.opp_ms = event.get("btime", state.opp_ms) # type: ignore[assignment]
|
||||
state.inc_ms = event.get("winc", state.inc_ms) # type: ignore[assignment]
|
||||
elif state.color == "black":
|
||||
state.my_ms = event.get("btime", state.my_ms) # type: ignore[assignment]
|
||||
state.opp_ms = event.get("wtime", state.opp_ms) # type: ignore[assignment]
|
||||
state.inc_ms = event.get("binc", state.inc_ms) # type: ignore[assignment]
|
||||
|
||||
return moves, str(status) if status else None
|
||||
|
||||
|
||||
def _calculate_time_budget(
|
||||
state: GameState, board: chess.Board, max_time_sec: float
|
||||
) -> float:
|
||||
"""Calculate time budget for the next move."""
|
||||
est_moves_left = max(10, min(60, 30 - board.fullmove_number // 2))
|
||||
time_left_sec = (state.my_ms or 0) / 1000.0
|
||||
inc_sec = (state.inc_ms or 0) / 1000.0
|
||||
budget = 0.6 * (time_left_sec / max(1, est_moves_left)) + 0.5 * inc_sec
|
||||
# Double the budget for more thoughtful moves
|
||||
budget *= 2.0
|
||||
return max(0.05, min(max_time_sec, budget))
|
||||
|
||||
|
||||
def _log_move_to_file(
|
||||
log_path: Path | None, ply: int, move: chess.Move, reason: str
|
||||
) -> None:
|
||||
"""Log a move to the game log file."""
|
||||
if log_path:
|
||||
with log_path.open("a") as lf:
|
||||
lf.write(f"ply {ply}: {move.uci()}\n{reason}\n\n")
|
||||
|
||||
|
||||
def _attempt_move(
|
||||
ctx: BotContext,
|
||||
state: GameState,
|
||||
meta: GameMeta,
|
||||
board: chess.Board,
|
||||
) -> bool:
|
||||
"""Attempt to make a move. Returns True if game should continue."""
|
||||
budget = _calculate_time_budget(state, board, ctx.engine.max_time_sec)
|
||||
move, reason = ctx.engine.choose_move_with_explanation(
|
||||
board, time_budget_sec=budget
|
||||
)
|
||||
|
||||
if move is None:
|
||||
_logger.info("Game %s: no legal moves (game likely over)", meta.game_id)
|
||||
return False
|
||||
|
||||
time_left_sec = (state.my_ms or 0) / 1000.0
|
||||
inc_sec = (state.inc_ms or 0) / 1000.0
|
||||
|
||||
try:
|
||||
if move not in board.legal_moves:
|
||||
_logger.info(
|
||||
"Game %s: selected move no longer legal; skipping send", meta.game_id
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
"Game %s: playing %s (budget=%.2fs, my_time_left=%.1fs, inc=%.2fs)",
|
||||
meta.game_id,
|
||||
move.uci(),
|
||||
budget,
|
||||
time_left_sec,
|
||||
inc_sec,
|
||||
)
|
||||
_log_move_to_file(state.log_path, state.last_handled_len + 1, move, reason)
|
||||
ctx.api.make_move(meta.game_id, move)
|
||||
except requests.RequestException as e:
|
||||
_logger.warning("Game %s: move %s failed: %s", meta.game_id, move.uci(), e)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _is_my_turn(board: chess.Board, color: str | None) -> bool:
|
||||
"""Check if it's our turn to move."""
|
||||
is_white_turn = board.turn
|
||||
return (is_white_turn and color == "white") or (
|
||||
(not is_white_turn) and color == "black"
|
||||
)
|
||||
|
||||
|
||||
def _handle_move_if_needed(
|
||||
ctx: BotContext,
|
||||
state: GameState,
|
||||
meta: GameMeta,
|
||||
et: str,
|
||||
new_len: int,
|
||||
) -> bool:
|
||||
"""Handle making a move if it's our turn. Returns False if game ends."""
|
||||
my_turn = _is_my_turn(state.board, state.color)
|
||||
turn_str = "white" if state.board.turn else "black"
|
||||
_logger.info("Game %s: turn=%s, my_turn=%s", meta.game_id, turn_str, my_turn)
|
||||
|
||||
# Move policy
|
||||
allow_move = (et == "gameState") or (et == "gameFull" and not new_len)
|
||||
|
||||
if my_turn and allow_move and not _attempt_move(ctx, state, meta, state.board):
|
||||
return False
|
||||
|
||||
# Mark position as handled
|
||||
if et == "gameState" or (my_turn and allow_move):
|
||||
state.last_handled_len = new_len
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_challenge(
|
||||
challenge: dict[str, object], api: LichessAPI, *, decline_correspondence: bool
|
||||
) -> None:
|
||||
"""Handle an incoming challenge."""
|
||||
ch_id = challenge.get("id", "")
|
||||
variant_data = challenge.get("variant", {})
|
||||
variant = (
|
||||
variant_data.get("key", "standard")
|
||||
if isinstance(variant_data, dict)
|
||||
else "standard"
|
||||
)
|
||||
speed = challenge.get("speed")
|
||||
|
||||
perf_ok = speed in {"bullet", "blitz", "rapid", "classical"}
|
||||
not_corr = speed != "correspondence" or not decline_correspondence
|
||||
|
||||
if variant == "standard" and perf_ok and not_corr:
|
||||
_logger.info("Accepting challenge %s (%s)", ch_id, speed)
|
||||
api.accept_challenge(str(ch_id))
|
||||
else:
|
||||
_logger.info(
|
||||
"Declining challenge %s (variant=%s, speed=%s)", ch_id, variant, speed
|
||||
)
|
||||
api.decline_challenge(str(ch_id))
|
||||
|
||||
|
||||
def _write_pgn_to_log(log_path: Path, board: chess.Board, meta: GameMeta) -> None:
|
||||
"""Write PGN to the game log file."""
|
||||
game = chess.pgn.Game.from_board(board)
|
||||
with contextlib.suppress(Exception):
|
||||
game.headers["BotVersion"] = f"v{meta.bot_version}"
|
||||
if meta.site_url:
|
||||
game.headers["Site"] = meta.site_url
|
||||
if meta.date_iso:
|
||||
game.headers["Date"] = meta.date_iso
|
||||
if meta.white_name:
|
||||
game.headers["White"] = meta.white_name
|
||||
if meta.black_name:
|
||||
game.headers["Black"] = meta.black_name
|
||||
|
||||
with log_path.open("a") as lf:
|
||||
lf.write("\nPGN:\n")
|
||||
exporter = chess.pgn.StringExporter(
|
||||
headers=True, variations=False, comments=False
|
||||
)
|
||||
lf.write(game.accept(exporter))
|
||||
lf.write("\n")
|
||||
|
||||
|
||||
def _insert_analysis_into_log(
|
||||
log_path: Path, analysis_text: str, meta: GameMeta
|
||||
) -> None:
|
||||
"""Insert analysis text into the log file before PGN section."""
|
||||
try:
|
||||
with log_path.open(encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
|
||||
# Find insertion point (before PGN)
|
||||
insert_idx = 0
|
||||
p = content.find("\nPGN:\n")
|
||||
if p != -1:
|
||||
insert_idx = p + 1
|
||||
elif content.startswith("PGN:\n"):
|
||||
insert_idx = 0
|
||||
else:
|
||||
insert_idx = len(content)
|
||||
|
||||
# Build meta block
|
||||
meta_lines = []
|
||||
if meta.date_iso:
|
||||
meta_lines.append(f"Date: {meta.date_iso}")
|
||||
if meta.white_name or meta.black_name:
|
||||
meta_lines.append(
|
||||
f"Players: {meta.white_name or '?'} vs {meta.black_name or '?'}"
|
||||
)
|
||||
meta_block = "\n".join(meta_lines) + "\n" if meta_lines else ""
|
||||
|
||||
analysis_block = f"{meta_block}ANALYSIS:\n{analysis_text.rstrip()}\n\n"
|
||||
|
||||
new_content = content[:insert_idx] + analysis_block + content[insert_idx:]
|
||||
|
||||
with log_path.open("w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
except OSError as e:
|
||||
_logger.debug("Game %s: could not write analysis to log: %s", meta.game_id, e)
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
218
python_pkg/lichess_bot/tests/test_lichess_api_part2.py
Normal file
218
python_pkg/lichess_bot/tests/test_lichess_api_part2.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Unit tests for lichess_bot lichess_api module (edge cases)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import chess
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from python_pkg.lichess_bot.lichess_api import LichessAPI
|
||||
|
||||
|
||||
class TestRequestEdgeCases:
|
||||
"""Additional tests for _request edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def api(self) -> LichessAPI:
|
||||
"""Create API instance."""
|
||||
return LichessAPI("test_token")
|
||||
|
||||
def test_request_error_with_attribute_error_on_text(self, api: LichessAPI) -> None:
|
||||
"""Test error response when text property raises AttributeError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.BAD_REQUEST
|
||||
# Make text property raise AttributeError when accessed
|
||||
del mock_response.text # Remove the default mock
|
||||
type(mock_response).text = property(
|
||||
fget=lambda _self: (_ for _ in ()).throw(AttributeError("no text"))
|
||||
)
|
||||
|
||||
with patch.object(api.session, "request", return_value=mock_response):
|
||||
result = api._request("GET", "http://test.com")
|
||||
|
||||
assert result == mock_response
|
||||
|
||||
def test_request_error_with_type_error_on_text(self, api: LichessAPI) -> None:
|
||||
"""Test error response when text causes TypeError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.BAD_REQUEST
|
||||
# Make text return something that causes TypeError when sliced
|
||||
mock_response.text = 12345 # integer can't be sliced with [:200]
|
||||
|
||||
with patch.object(api.session, "request", return_value=mock_response):
|
||||
result = api._request("GET", "http://test.com")
|
||||
|
||||
assert result == mock_response
|
||||
|
||||
|
||||
class TestStreamEventsNon429Error:
|
||||
"""Test stream_events with non-429 HTTP errors."""
|
||||
|
||||
@pytest.fixture
|
||||
def api(self) -> LichessAPI:
|
||||
"""Create API instance."""
|
||||
return LichessAPI("test_token")
|
||||
|
||||
def test_stream_events_raises_non_429_error(self, api: LichessAPI) -> None:
|
||||
"""Test stream_events raises non-429 HTTP errors."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError(
|
||||
response=MagicMock(status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
)
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch.object(api, "_request", return_value=mock_response),
|
||||
pytest.raises(requests.HTTPError),
|
||||
):
|
||||
# Try to get the first event - should raise
|
||||
next(api.stream_events())
|
||||
|
||||
|
||||
class TestJoinGameStreamEdgeCases:
|
||||
"""Additional tests for join_game_stream edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def api(self) -> LichessAPI:
|
||||
"""Create API instance."""
|
||||
return LichessAPI("test_token")
|
||||
|
||||
def test_join_game_stream_skips_empty_lines(self, api: LichessAPI) -> None:
|
||||
"""Test join_game_stream skips empty lines."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
event = json.dumps(
|
||||
{
|
||||
"type": "gameFull",
|
||||
"white": {"id": "my_user"},
|
||||
"black": {"id": "opponent"},
|
||||
"state": {"moves": ""},
|
||||
}
|
||||
)
|
||||
mock_response.iter_lines.return_value = iter(["", "", event])
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch.object(api, "_request", return_value=mock_response),
|
||||
patch.object(api, "get_my_user_id", return_value="my_user"),
|
||||
):
|
||||
__board, color = api.join_game_stream("game123", None)
|
||||
|
||||
assert color == "white"
|
||||
|
||||
def test_join_game_stream_skips_invalid_json(self, api: LichessAPI) -> None:
|
||||
"""Test join_game_stream skips invalid JSON lines."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
event = json.dumps(
|
||||
{
|
||||
"type": "gameFull",
|
||||
"white": {"id": "my_user"},
|
||||
"black": {"id": "opponent"},
|
||||
"state": {"moves": ""},
|
||||
}
|
||||
)
|
||||
mock_response.iter_lines.return_value = iter(["not json", event])
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch.object(api, "_request", return_value=mock_response),
|
||||
patch.object(api, "get_my_user_id", return_value="my_user"),
|
||||
):
|
||||
__board, color = api.join_game_stream("game123", None)
|
||||
|
||||
assert color == "white"
|
||||
|
||||
def test_join_game_stream_skips_non_gamefull_events(self, api: LichessAPI) -> None:
|
||||
"""Test join_game_stream skips non-gameFull events before gameFull."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
# Emit a non-gameFull event first, then gameFull
|
||||
non_game_full = json.dumps({"type": "gameState", "moves": "e2e4"})
|
||||
game_full = json.dumps(
|
||||
{
|
||||
"type": "gameFull",
|
||||
"white": {"id": "my_user"},
|
||||
"black": {"id": "opponent"},
|
||||
"state": {"moves": ""},
|
||||
}
|
||||
)
|
||||
mock_response.iter_lines.return_value = iter([non_game_full, game_full])
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch.object(api, "_request", return_value=mock_response),
|
||||
patch.object(api, "get_my_user_id", return_value="my_user"),
|
||||
):
|
||||
__board, color = api.join_game_stream("game123", None)
|
||||
|
||||
assert color == "white"
|
||||
|
||||
def test_join_game_stream_no_gamefull_event(self, api: LichessAPI) -> None:
|
||||
"""Test join_game_stream when stream ends without gameFull event."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
# Only non-gameFull events, no gameFull - loop exhausts without break
|
||||
events = [
|
||||
json.dumps({"type": "gameState", "moves": "e2e4"}),
|
||||
json.dumps({"type": "chatLine", "text": "hello"}),
|
||||
]
|
||||
mock_response.iter_lines.return_value = iter(events)
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(api, "_request", return_value=mock_response):
|
||||
board, color = api.join_game_stream("game123", "black")
|
||||
|
||||
# When no gameFull is found, returns default/provided color
|
||||
assert color == "black"
|
||||
# Board should be empty since no moves were parsed
|
||||
assert board.fen() == chess.STARTING_FEN
|
||||
|
||||
|
||||
class TestStreamGameEventsEdgeCases:
|
||||
"""Additional tests for stream_game_events edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def api(self) -> LichessAPI:
|
||||
"""Create API instance."""
|
||||
return LichessAPI("test_token")
|
||||
|
||||
def test_stream_game_events_skips_empty_lines(self, api: LichessAPI) -> None:
|
||||
"""Test stream_game_events skips empty lines."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
mock_response.iter_lines.return_value = iter(
|
||||
["", '{"type": "gameFull"}', "", '{"type": "gameState"}']
|
||||
)
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(api, "_request", return_value=mock_response):
|
||||
events = list(api.stream_game_events("game123"))
|
||||
|
||||
assert len(events) == 2
|
||||
|
||||
def test_stream_game_events_skips_invalid_json(self, api: LichessAPI) -> None:
|
||||
"""Test stream_game_events skips invalid JSON lines."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = HTTPStatus.OK
|
||||
mock_response.iter_lines.return_value = iter(
|
||||
['{"type": "gameFull"}', "invalid json", '{"type": "gameState"}']
|
||||
)
|
||||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(api, "_request", return_value=mock_response):
|
||||
events = list(api.stream_game_events("game123"))
|
||||
|
||||
assert len(events) == 2
|
||||
378
python_pkg/music_gen/_music_generation.py
Normal file
378
python_pkg/music_gen/_music_generation.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""Core MusicGen model loading, device selection, and audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# VRAM thresholds for model selection (in GB)
|
||||
VRAM_THRESHOLD_LARGE = 12 # Use large model with 12GB+ VRAM
|
||||
VRAM_THRESHOLD_MEDIUM = 8 # Use medium model with 8GB+ VRAM
|
||||
|
||||
# Generation settings for segmented long audio
|
||||
SEGMENT_DURATION = 25 # Seconds per segment (under 30s MusicGen limit)
|
||||
CROSSFADE_DURATION = 2 # Seconds of crossfade between segments
|
||||
|
||||
|
||||
def get_device() -> str:
|
||||
"""Get the best available device (CUDA or MPS). No CPU fallback for NVIDIA.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If NVIDIA GPU is detected but CUDA is not available.
|
||||
"""
|
||||
import torch
|
||||
|
||||
# Check for NVIDIA GPU first
|
||||
nvidia_gpu_present = False
|
||||
try:
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
nvidia_smi_path = shutil.which("nvidia-smi")
|
||||
if nvidia_smi_path:
|
||||
result = subprocess.run(
|
||||
[nvidia_smi_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
nvidia_gpu_present = result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if nvidia_gpu_present:
|
||||
if not torch.cuda.is_available():
|
||||
msg = (
|
||||
"NVIDIA GPU detected but CUDA is not available!\n"
|
||||
"Please install PyTorch with CUDA support:\n"
|
||||
" pip install torch torchaudio --index-url "
|
||||
"https://download.pytorch.org/whl/cu121"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
device = "cuda"
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
vram = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
print(f"Using CUDA GPU: {gpu_name} ({vram:.1f}GB VRAM)")
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
print("Using Apple Silicon (MPS)")
|
||||
else:
|
||||
device = "cpu"
|
||||
print("Using CPU (this will be slow)")
|
||||
return device
|
||||
|
||||
|
||||
def get_vram_gb() -> float | None:
|
||||
"""Get available VRAM in GB. Returns None if no CUDA GPU."""
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
return None
|
||||
|
||||
|
||||
def select_model_size(user_choice: str | None = None) -> str:
|
||||
"""Select model size based on user choice or available VRAM.
|
||||
|
||||
Args:
|
||||
user_choice: User's explicit model choice, or None for auto-selection.
|
||||
|
||||
Returns:
|
||||
Model size: 'small', 'medium', or 'large'
|
||||
"""
|
||||
if user_choice is not None:
|
||||
return user_choice
|
||||
|
||||
vram = get_vram_gb()
|
||||
|
||||
if vram is None:
|
||||
# No GPU, use medium as a safe default
|
||||
print("No CUDA GPU detected, defaulting to medium model")
|
||||
return "medium"
|
||||
|
||||
# Select based on VRAM:
|
||||
# - large: needs ~10GB VRAM (safe with 12GB+)
|
||||
# - medium: needs ~6GB VRAM (safe with 8GB+)
|
||||
# - small: needs ~3GB VRAM
|
||||
if vram >= VRAM_THRESHOLD_LARGE:
|
||||
selected = "large"
|
||||
elif vram >= VRAM_THRESHOLD_MEDIUM:
|
||||
selected = "medium"
|
||||
else:
|
||||
selected = "small"
|
||||
|
||||
print(f"Auto-selected '{selected}' model based on {vram:.1f}GB VRAM")
|
||||
return selected
|
||||
|
||||
|
||||
def load_model(
|
||||
model_size: str = "medium",
|
||||
) -> tuple[Any, Any]:
|
||||
"""Load the MusicGen model.
|
||||
|
||||
Args:
|
||||
model_size: One of 'small', 'medium', or 'large'
|
||||
- small: ~500MB, fastest, lower quality
|
||||
- medium: ~3.3GB, good balance (recommended)
|
||||
- large: ~6.5GB, best quality, needs more VRAM
|
||||
|
||||
Returns:
|
||||
Tuple of (model, processor)
|
||||
"""
|
||||
from transformers import AutoProcessor, MusicgenForConditionalGeneration
|
||||
|
||||
model_name = f"facebook/musicgen-{model_size}"
|
||||
print(f"\nLoading MusicGen {model_size} model...")
|
||||
print("(First run will download the model, this may take a while)")
|
||||
|
||||
device = get_device()
|
||||
|
||||
processor = AutoProcessor.from_pretrained(model_name)
|
||||
# Use safetensors format to avoid torch.load security issues with older PyTorch
|
||||
model = MusicgenForConditionalGeneration.from_pretrained(
|
||||
model_name,
|
||||
use_safetensors=True,
|
||||
)
|
||||
model = model.to(device)
|
||||
|
||||
print(f"Model loaded successfully on {device}!")
|
||||
return model, processor
|
||||
|
||||
|
||||
def crossfade_audio(
|
||||
audio1: object,
|
||||
audio2: object,
|
||||
crossfade_samples: int,
|
||||
) -> object:
|
||||
"""Crossfade two audio segments together.
|
||||
|
||||
Args:
|
||||
audio1: First audio segment (numpy array)
|
||||
audio2: Second audio segment (numpy array)
|
||||
crossfade_samples: Number of samples to use for crossfade
|
||||
|
||||
Returns:
|
||||
Combined audio with crossfade applied (numpy array)
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if crossfade_samples <= 0 or len(audio1) < crossfade_samples:
|
||||
return np.concatenate([audio1, audio2])
|
||||
|
||||
# Create fade curves
|
||||
fade_out = np.linspace(1.0, 0.0, crossfade_samples)
|
||||
fade_in = np.linspace(0.0, 1.0, crossfade_samples)
|
||||
|
||||
# Apply fades
|
||||
audio1_end = audio1[-crossfade_samples:] * fade_out
|
||||
audio2_start = audio2[:crossfade_samples] * fade_in
|
||||
|
||||
# Combine
|
||||
crossfaded = audio1_end + audio2_start
|
||||
|
||||
# Build final audio
|
||||
return np.concatenate(
|
||||
[
|
||||
audio1[:-crossfade_samples],
|
||||
crossfaded,
|
||||
audio2[crossfade_samples:],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def generate_segment(
|
||||
prompt: str,
|
||||
model: object,
|
||||
processor: object,
|
||||
duration_seconds: int,
|
||||
device: str,
|
||||
) -> object:
|
||||
"""Generate a single audio segment.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the music
|
||||
model: The MusicGen model
|
||||
processor: The MusicGen processor
|
||||
duration_seconds: Length of segment to generate
|
||||
device: Device to generate on
|
||||
|
||||
Returns:
|
||||
Audio data as numpy array
|
||||
"""
|
||||
import torch
|
||||
|
||||
inputs = processor(
|
||||
text=[prompt],
|
||||
padding=True,
|
||||
return_tensors="pt",
|
||||
)
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
max_new_tokens = int(duration_seconds * 50)
|
||||
|
||||
with torch.no_grad():
|
||||
audio_values = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=max_new_tokens,
|
||||
do_sample=True,
|
||||
)
|
||||
|
||||
return audio_values[0, 0].cpu().numpy()
|
||||
|
||||
|
||||
def _calculate_segment_duration(
|
||||
segment_index: int,
|
||||
num_segments: int,
|
||||
generated_samples: int,
|
||||
sample_rate: int,
|
||||
total_duration: int,
|
||||
) -> int:
|
||||
"""Calculate duration for a specific segment.
|
||||
|
||||
Args:
|
||||
segment_index: Current segment index
|
||||
num_segments: Total number of segments
|
||||
generated_samples: Number of samples generated so far
|
||||
sample_rate: Audio sample rate
|
||||
total_duration: Target total duration
|
||||
|
||||
Returns:
|
||||
Duration in seconds for this segment
|
||||
"""
|
||||
if segment_index == num_segments - 1:
|
||||
# Last segment: calculate remaining time
|
||||
generated_so_far = generated_samples / sample_rate
|
||||
remaining = total_duration - generated_so_far
|
||||
min_duration = max(5, int(remaining) + CROSSFADE_DURATION)
|
||||
return min(SEGMENT_DURATION, min_duration)
|
||||
return SEGMENT_DURATION
|
||||
|
||||
|
||||
def _generate_long_audio(
|
||||
prompt: str,
|
||||
model: object,
|
||||
processor: object,
|
||||
duration_seconds: int,
|
||||
) -> object:
|
||||
"""Generate long audio by segmenting with crossfades.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the music
|
||||
model: The MusicGen model
|
||||
processor: The MusicGen processor
|
||||
duration_seconds: Total duration to generate
|
||||
|
||||
Returns:
|
||||
Audio data as numpy array
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
device = str(next(model.parameters()).device)
|
||||
sample_rate = model.config.audio_encoder.sampling_rate
|
||||
crossfade_samples = CROSSFADE_DURATION * sample_rate
|
||||
|
||||
effective_segment = SEGMENT_DURATION - CROSSFADE_DURATION
|
||||
total = duration_seconds + effective_segment - 1
|
||||
num_segments = max(1, total // effective_segment)
|
||||
|
||||
print(f"Generating {num_segments} segments of ~{SEGMENT_DURATION}s each...")
|
||||
|
||||
audio_data = np.array([], dtype=np.float32)
|
||||
|
||||
for i in range(num_segments):
|
||||
segment_duration = _calculate_segment_duration(
|
||||
i,
|
||||
num_segments,
|
||||
len(audio_data),
|
||||
sample_rate,
|
||||
duration_seconds,
|
||||
)
|
||||
|
||||
seg_num = i + 1
|
||||
msg = f" Segment {seg_num}/{num_segments} ({segment_duration}s)..."
|
||||
print(msg, end=" ", flush=True)
|
||||
|
||||
segment = generate_segment(
|
||||
prompt,
|
||||
model,
|
||||
processor,
|
||||
segment_duration,
|
||||
device,
|
||||
)
|
||||
|
||||
if len(audio_data) == 0:
|
||||
audio_data = segment
|
||||
else:
|
||||
audio_data = crossfade_audio(audio_data, segment, crossfade_samples)
|
||||
|
||||
print(f"done (total: {len(audio_data) / sample_rate:.1f}s)")
|
||||
|
||||
# Trim to exact duration if needed
|
||||
target_samples = int(duration_seconds * sample_rate)
|
||||
if len(audio_data) > target_samples:
|
||||
audio_data = audio_data[:target_samples]
|
||||
|
||||
return audio_data
|
||||
|
||||
|
||||
def generate_music(
|
||||
prompt: str,
|
||||
model: object,
|
||||
processor: object,
|
||||
duration_seconds: int = 10,
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Generate music from a text prompt.
|
||||
|
||||
For durations over 30 seconds, generates in segments with crossfading.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the music to generate
|
||||
model: The MusicGen model
|
||||
processor: The MusicGen processor
|
||||
duration_seconds: Length of audio to generate (any duration supported)
|
||||
output_dir: Directory to save output (defaults to ./output)
|
||||
|
||||
Returns:
|
||||
Path to the generated audio file
|
||||
"""
|
||||
import scipy.io.wavfile
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
sample_rate = model.config.audio_encoder.sampling_rate
|
||||
|
||||
# For short durations, generate directly
|
||||
if duration_seconds <= SEGMENT_DURATION:
|
||||
print(f"\nGenerating {duration_seconds}s of music...")
|
||||
print(f"Prompt: {prompt!r}")
|
||||
device = str(next(model.parameters()).device)
|
||||
audio_data = generate_segment(
|
||||
prompt,
|
||||
model,
|
||||
processor,
|
||||
duration_seconds,
|
||||
device,
|
||||
)
|
||||
else:
|
||||
# Long duration: generate in segments with crossfading
|
||||
print(f"\nGenerating {duration_seconds}s of music in segments...")
|
||||
print(f"Prompt: {prompt!r}")
|
||||
audio_data = _generate_long_audio(prompt, model, processor, duration_seconds)
|
||||
|
||||
# Create filename with timestamp and sanitized prompt
|
||||
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30])
|
||||
safe_prompt = safe_prompt.strip().replace(" ", "_")
|
||||
filename = f"{timestamp}_{safe_prompt}.wav"
|
||||
output_path = output_dir / filename
|
||||
|
||||
scipy.io.wavfile.write(output_path, sample_rate, audio_data)
|
||||
|
||||
print(f"\nSaved to: {output_path}")
|
||||
print(f"Duration: {len(audio_data) / sample_rate:.1f}s")
|
||||
|
||||
return output_path
|
||||
380
python_pkg/music_gen/_music_speech.py
Normal file
380
python_pkg/music_gen/_music_speech.py
Normal file
@ -0,0 +1,380 @@
|
||||
"""Bark speech synthesis, vocal generation, and song mixing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from python_pkg.music_gen._music_generation import (
|
||||
SEGMENT_DURATION,
|
||||
_generate_long_audio,
|
||||
generate_segment,
|
||||
load_model,
|
||||
select_model_size,
|
||||
)
|
||||
|
||||
BARK_MAX_CHARS = 200 # Max characters per Bark segment (~13s of speech)
|
||||
|
||||
# Available Bark voice presets
|
||||
BARK_VOICES = [
|
||||
"v2/en_speaker_0",
|
||||
"v2/en_speaker_1",
|
||||
"v2/en_speaker_2",
|
||||
"v2/en_speaker_3",
|
||||
"v2/en_speaker_4",
|
||||
"v2/en_speaker_5",
|
||||
"v2/en_speaker_6",
|
||||
"v2/en_speaker_7",
|
||||
"v2/en_speaker_8",
|
||||
"v2/en_speaker_9",
|
||||
]
|
||||
|
||||
|
||||
def generate_speech(
|
||||
text: str,
|
||||
voice: str = "v2/en_speaker_6",
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Generate speech audio from text using Bark.
|
||||
|
||||
Bark supports various speech patterns:
|
||||
- [laughter], [laughs], [sighs], [music]
|
||||
- [gasps], [clears throat], — or ... for hesitations
|
||||
- ♪ for singing
|
||||
|
||||
Args:
|
||||
text: Text to convert to speech (max ~13s per segment)
|
||||
voice: Voice preset to use (see BARK_VOICES)
|
||||
output_dir: Directory to save output (defaults to ./output)
|
||||
|
||||
Returns:
|
||||
Path to the generated audio file
|
||||
"""
|
||||
import functools
|
||||
|
||||
import numpy as np
|
||||
import scipy.io.wavfile
|
||||
import torch
|
||||
|
||||
# Bark uses older checkpoint format with pickle
|
||||
# Monkey-patch torch.load to allow unsafe loading for Bark models
|
||||
original_torch_load = torch.load
|
||||
|
||||
@functools.wraps(original_torch_load)
|
||||
def patched_load(*args: object, **kwargs: object) -> object:
|
||||
kwargs.setdefault("weights_only", False)
|
||||
return original_torch_load(*args, **kwargs)
|
||||
|
||||
torch.load = patched_load
|
||||
|
||||
try:
|
||||
from bark import SAMPLE_RATE, generate_audio, preload_models
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
print("\nLoading Bark model...")
|
||||
print("(First run will download models, ~5GB total)")
|
||||
preload_models()
|
||||
|
||||
print(f"\nGenerating speech with voice: {voice}")
|
||||
print(f"Text: {text!r}")
|
||||
|
||||
# Bark can only generate ~13s at a time
|
||||
# For longer text, we need to split into sentences
|
||||
audio_segments = []
|
||||
|
||||
# Split on sentence boundaries for longer texts
|
||||
sentences = _split_into_sentences(text)
|
||||
|
||||
for i, sentence in enumerate(sentences):
|
||||
if len(sentences) > 1:
|
||||
print(f" Generating segment {i + 1}/{len(sentences)}...")
|
||||
|
||||
audio = generate_audio(
|
||||
sentence.strip(),
|
||||
history_prompt=voice,
|
||||
)
|
||||
audio_segments.append(audio)
|
||||
|
||||
# Combine segments
|
||||
if len(audio_segments) > 1:
|
||||
audio_data = np.concatenate(audio_segments)
|
||||
else:
|
||||
audio_data = audio_segments[0]
|
||||
|
||||
# Create filename
|
||||
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
safe_text = "".join(c if c.isalnum() or c in " -_" else "" for c in text[:30])
|
||||
safe_text = safe_text.strip().replace(" ", "_")
|
||||
filename = f"{timestamp}_speech_{safe_text}.wav"
|
||||
output_path = output_dir / filename
|
||||
|
||||
scipy.io.wavfile.write(output_path, SAMPLE_RATE, audio_data)
|
||||
|
||||
print(f"\nSaved to: {output_path}")
|
||||
print(f"Duration: {len(audio_data) / SAMPLE_RATE:.1f}s")
|
||||
|
||||
return output_path
|
||||
finally:
|
||||
# Restore original torch.load
|
||||
torch.load = original_torch_load
|
||||
|
||||
|
||||
def _split_into_sentences(text: str) -> list[str]:
|
||||
"""Split text into sentences for Bark processing.
|
||||
|
||||
Args:
|
||||
text: Text to split
|
||||
|
||||
Returns:
|
||||
List of sentences
|
||||
"""
|
||||
import re
|
||||
|
||||
# Split on sentence-ending punctuation followed by space
|
||||
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
|
||||
|
||||
# Group very short sentences together
|
||||
result = []
|
||||
current = ""
|
||||
for sentence in sentences:
|
||||
if len(current) + len(sentence) < BARK_MAX_CHARS:
|
||||
current = f"{current} {sentence}".strip()
|
||||
else:
|
||||
if current:
|
||||
result.append(current)
|
||||
current = sentence
|
||||
if current:
|
||||
result.append(current)
|
||||
|
||||
return result or [text]
|
||||
|
||||
|
||||
def _resample_audio(
|
||||
audio: object,
|
||||
orig_sr: int,
|
||||
target_sr: int,
|
||||
) -> object:
|
||||
"""Resample audio to a different sample rate.
|
||||
|
||||
Args:
|
||||
audio: Audio data as numpy array
|
||||
orig_sr: Original sample rate
|
||||
target_sr: Target sample rate
|
||||
|
||||
Returns:
|
||||
Resampled audio data
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
|
||||
if orig_sr == target_sr:
|
||||
return audio
|
||||
|
||||
# Calculate the resampling ratio
|
||||
duration = len(audio) / orig_sr
|
||||
target_length = int(duration * target_sr)
|
||||
|
||||
return signal.resample(audio, target_length).astype(np.float32)
|
||||
|
||||
|
||||
def _mix_audio(
|
||||
instrumental: object,
|
||||
vocals: object,
|
||||
vocal_volume: float = 0.8,
|
||||
instrumental_volume: float = 0.6,
|
||||
) -> object:
|
||||
"""Mix vocals over instrumental track.
|
||||
|
||||
Args:
|
||||
instrumental: Instrumental audio (numpy array)
|
||||
vocals: Vocal audio (numpy array)
|
||||
vocal_volume: Volume multiplier for vocals (0.0-1.0)
|
||||
instrumental_volume: Volume multiplier for instrumental (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Mixed audio data
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# Ensure same length - pad or trim vocals to match instrumental
|
||||
if len(vocals) < len(instrumental):
|
||||
# Pad vocals with silence at the end
|
||||
vocals = np.pad(vocals, (0, len(instrumental) - len(vocals)))
|
||||
elif len(vocals) > len(instrumental):
|
||||
# Trim vocals to match instrumental
|
||||
vocals = vocals[: len(instrumental)]
|
||||
|
||||
# Mix the tracks
|
||||
mixed = (instrumental * instrumental_volume) + (vocals * vocal_volume)
|
||||
|
||||
# Normalize to prevent clipping
|
||||
max_val = np.max(np.abs(mixed))
|
||||
if max_val > 1.0:
|
||||
mixed = mixed / max_val
|
||||
|
||||
return mixed.astype(np.float32)
|
||||
|
||||
|
||||
def _generate_vocals_for_song(lyrics: str, voice: str) -> tuple[object, int]:
|
||||
"""Generate vocals using Bark for song mixing.
|
||||
|
||||
Args:
|
||||
lyrics: Text/lyrics to sing
|
||||
voice: Bark voice preset
|
||||
|
||||
Returns:
|
||||
Tuple of (vocal audio array, sample rate)
|
||||
"""
|
||||
import functools
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
# Patch torch.load for Bark compatibility
|
||||
original_torch_load = torch.load
|
||||
|
||||
@functools.wraps(original_torch_load)
|
||||
def patched_load(*args: object, **kwargs: object) -> object:
|
||||
kwargs.setdefault("weights_only", False)
|
||||
return original_torch_load(*args, **kwargs)
|
||||
|
||||
torch.load = patched_load
|
||||
|
||||
try:
|
||||
from bark import SAMPLE_RATE as BARK_SR
|
||||
from bark import generate_audio, preload_models
|
||||
|
||||
print("Loading Bark model...")
|
||||
preload_models()
|
||||
|
||||
print(f"Generating vocals with voice: {voice}")
|
||||
print(f"Lyrics: {lyrics!r}")
|
||||
|
||||
sentences = _split_into_sentences(lyrics)
|
||||
vocal_segments = []
|
||||
|
||||
for i, sentence in enumerate(sentences):
|
||||
if len(sentences) > 1:
|
||||
print(f" Vocal segment {i + 1}/{len(sentences)}...")
|
||||
audio = generate_audio(sentence.strip(), history_prompt=voice)
|
||||
vocal_segments.append(audio)
|
||||
|
||||
if len(vocal_segments) > 1:
|
||||
vocals = np.concatenate(vocal_segments)
|
||||
else:
|
||||
vocals = vocal_segments[0]
|
||||
|
||||
return vocals, BARK_SR
|
||||
|
||||
finally:
|
||||
torch.load = original_torch_load
|
||||
|
||||
|
||||
def _generate_instrumental_for_song(
|
||||
music_prompt: str,
|
||||
duration: int,
|
||||
) -> tuple[object, int]:
|
||||
"""Generate instrumental music using MusicGen for song mixing.
|
||||
|
||||
Args:
|
||||
music_prompt: Description of the music
|
||||
duration: Duration in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (instrumental audio array, sample rate)
|
||||
"""
|
||||
model_size = select_model_size(None)
|
||||
model, processor = load_model(model_size)
|
||||
|
||||
print(f"Music prompt: {music_prompt!r}")
|
||||
print(f"Duration: {duration}s")
|
||||
|
||||
device = str(next(model.parameters()).device)
|
||||
sample_rate = model.config.audio_encoder.sampling_rate
|
||||
|
||||
if duration <= SEGMENT_DURATION:
|
||||
instrumental = generate_segment(
|
||||
music_prompt,
|
||||
model,
|
||||
processor,
|
||||
duration,
|
||||
device,
|
||||
)
|
||||
else:
|
||||
instrumental = _generate_long_audio(
|
||||
music_prompt,
|
||||
model,
|
||||
processor,
|
||||
duration,
|
||||
)
|
||||
|
||||
return instrumental, sample_rate
|
||||
|
||||
|
||||
def generate_song(
|
||||
lyrics: str,
|
||||
music_prompt: str,
|
||||
voice: str = "v2/en_speaker_6",
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Generate a complete song with vocals over instrumental music.
|
||||
|
||||
This combines Bark for vocals and MusicGen for instrumental backing.
|
||||
|
||||
Args:
|
||||
lyrics: The lyrics/text to sing (use ♪ for singing style)
|
||||
music_prompt: Description of the instrumental music
|
||||
voice: Bark voice preset (default: v2/en_speaker_6)
|
||||
output_dir: Directory to save output
|
||||
|
||||
Returns:
|
||||
Path to the generated song file
|
||||
"""
|
||||
import scipy.io.wavfile
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
print("=" * 60)
|
||||
print("GENERATING SONG WITH VOCALS")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Generate vocals
|
||||
print("\n[1/3] Generating vocals...")
|
||||
vocals, bark_sr = _generate_vocals_for_song(lyrics, voice)
|
||||
vocal_duration = len(vocals) / bark_sr
|
||||
print(f"Vocals generated: {vocal_duration:.1f}s")
|
||||
|
||||
# Step 2: Generate instrumental (match vocal duration + buffer)
|
||||
print("\n[2/3] Generating instrumental music...")
|
||||
music_duration = int(vocal_duration) + 2
|
||||
instrumental, musicgen_sr = _generate_instrumental_for_song(
|
||||
music_prompt,
|
||||
music_duration,
|
||||
)
|
||||
print(f"Instrumental generated: {len(instrumental) / musicgen_sr:.1f}s")
|
||||
|
||||
# Step 3: Mix vocals and instrumental
|
||||
print("\n[3/3] Mixing vocals and instrumental...")
|
||||
vocals_resampled = _resample_audio(vocals, bark_sr, musicgen_sr)
|
||||
mixed = _mix_audio(instrumental, vocals_resampled)
|
||||
|
||||
# Save the song
|
||||
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
safe_lyrics = "".join(c if c.isalnum() or c in " -_" else "" for c in lyrics[:20])
|
||||
safe_lyrics = safe_lyrics.strip().replace(" ", "_")
|
||||
filename = f"{timestamp}_song_{safe_lyrics}.wav"
|
||||
output_path = output_dir / filename
|
||||
|
||||
scipy.io.wavfile.write(output_path, musicgen_sr, mixed)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Song saved to: {output_path}")
|
||||
print(f"Duration: {len(mixed) / musicgen_sr:.1f}s")
|
||||
print("=" * 60)
|
||||
|
||||
return output_path
|
||||
@ -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)
|
||||
|
||||
274
python_pkg/praca_magisterska_video/_q02_algorithm_steps.py
Normal file
274
python_pkg/praca_magisterska_video/_q02_algorithm_steps.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""Algorithm step definitions for Q02 shortest path visualization.
|
||||
|
||||
Contains step sequences for Dijkstra, Bellman-Ford, A*, and the
|
||||
comparison slide used in the final video.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from moviepy import (
|
||||
ColorClip,
|
||||
CompositeVideoClip,
|
||||
VideoClip,
|
||||
)
|
||||
from moviepy.video.fx import FadeIn, FadeOut
|
||||
|
||||
from python_pkg.praca_magisterska_video.visualize_q02 import (
|
||||
BG,
|
||||
EDGES_BF,
|
||||
EDGES_DIJKSTRA,
|
||||
FONT_B,
|
||||
FONT_R,
|
||||
INF,
|
||||
NODE_POS,
|
||||
H,
|
||||
W,
|
||||
_make_step,
|
||||
_StepConfig,
|
||||
_tc,
|
||||
)
|
||||
|
||||
|
||||
def _dijkstra_steps() -> list[CompositeVideoClip]:
|
||||
n = NODE_POS
|
||||
e = EDGES_DIJKSTRA
|
||||
return [
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": INF, "B": INF, "C": INF},
|
||||
current="S",
|
||||
step_text="Inicjalizacja: d[S]=0, reszta=∞. Wybierz S (min d).",
|
||||
algo_name="Algorytm Dijkstry",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": INF},
|
||||
current="S",
|
||||
active_edge=("S", "A"),
|
||||
step_text="Relaksacja S→A: d[A]=0+2=2. S→B: d[B]=0+5=5.",
|
||||
algo_name="Algorytm Dijkstry",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
current="A",
|
||||
visited={"S"},
|
||||
active_edge=("A", "C"),
|
||||
step_text="Zamknij S. Min=A(2). Relaksacja A→C: d[C]=2+3=5.",
|
||||
algo_name="Algorytm Dijkstry",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
current="B",
|
||||
visited={"S", "A"},
|
||||
active_edge=("B", "A"),
|
||||
step_text=(
|
||||
"Zamknij A. Min=B(5). B→A: 5+1=6>2, "
|
||||
"nie zmieniaj. B→C: 5+6=11>5."
|
||||
),
|
||||
algo_name="Algorytm Dijkstry",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
current="C",
|
||||
visited={"S", "A", "B"},
|
||||
step_text=(
|
||||
"Zamknij B. Min=C(5). Koniec! "
|
||||
"Wynik: d={S:0, A:2, B:5, C:5}."
|
||||
),
|
||||
algo_name="Dijkstra -- WYNIK",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _bellman_ford_steps() -> list[CompositeVideoClip]:
|
||||
n = NODE_POS
|
||||
e = EDGES_BF
|
||||
return [
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": INF, "B": INF, "C": INF},
|
||||
step_text=(
|
||||
"Bellman-Ford: relaksuj WSZYSTKIE "
|
||||
"krawędzie V-1=3 razy. Ujemne wagi OK!"
|
||||
),
|
||||
algo_name="Algorytm Bellmana-Forda",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
active_edge=("S", "A"),
|
||||
step_text=(
|
||||
"Iteracja 1: S→A:2, A→C:5, S→B:5. "
|
||||
"Potem B→A: 5+(-4)=1 < 2 → A=1!"
|
||||
),
|
||||
algo_name="Bellman-Ford -- iteracja 1",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "1", "B": "5", "C": "5"},
|
||||
active_edge=("B", "A"),
|
||||
step_text=(
|
||||
"B→A z ujemną wagą -4: d[A] poprawione "
|
||||
"z 2 na 1! (Dijkstra by to pominął!)"
|
||||
),
|
||||
algo_name="Bellman-Ford -- ujemna waga",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "1", "B": "5", "C": "4"},
|
||||
active_edge=("A", "C"),
|
||||
step_text=(
|
||||
"Iteracja 2: A→C: 1+3=4 < 5 → C=4. "
|
||||
"Propagacja poprawionego A."
|
||||
),
|
||||
algo_name="Bellman-Ford -- iteracja 2",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "1", "B": "5", "C": "4"},
|
||||
step_text=(
|
||||
"Iteracja 3: brak zmian. V-ta iteracja: "
|
||||
"brak popraw → brak cyklu ujemnego."
|
||||
),
|
||||
algo_name="Bellman-Ford -- WYNIK, O(V*E)",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _astar_steps() -> list[CompositeVideoClip]:
|
||||
n = NODE_POS
|
||||
e = EDGES_DIJKSTRA
|
||||
return [
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": INF, "B": INF, "C": INF},
|
||||
current="S",
|
||||
step_text=(
|
||||
"A*: f(n)=g(n)+h(n). Cel=C. "
|
||||
"h(S)=5, h(A)=3, h(B)=4, h(C)=0. f(S)=0+5=5."
|
||||
),
|
||||
algo_name="Algorytm A*",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": INF},
|
||||
current="S",
|
||||
active_edge=("S", "A"),
|
||||
step_text=(
|
||||
"Relaksuj S: A(g=2,f=2+3=5), "
|
||||
"B(g=5,f=5+4=9). Min f → A(5)."
|
||||
),
|
||||
algo_name="A* -- rozwijanie S",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
current="A",
|
||||
visited={"S"},
|
||||
active_edge=("A", "C"),
|
||||
step_text=(
|
||||
"Rozwiń A(f=5): A→C: g=2+3=5, "
|
||||
"f=5+0=5. Min f → C(5) = CEL!"
|
||||
),
|
||||
algo_name="A* -- rozwijanie A",
|
||||
),
|
||||
),
|
||||
_make_step(
|
||||
_StepConfig(
|
||||
n,
|
||||
e,
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
current="C",
|
||||
visited={"S", "A"},
|
||||
step_text=(
|
||||
"Dotarliśmy do C! Koszt=5. "
|
||||
"A* NIE przetwarza B (3 vs 4 w Dijkstrze)."
|
||||
),
|
||||
algo_name="A* -- cel osiągnięty!",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _comparison_slide() -> CompositeVideoClip:
|
||||
bg = ColorClip(size=(W, H), color=BG).with_duration(12.0)
|
||||
title = (
|
||||
_tc(
|
||||
text="Porównanie algorytmów",
|
||||
font_size=40,
|
||||
color="white",
|
||||
font=FONT_B,
|
||||
)
|
||||
.with_duration(12.0)
|
||||
.with_position(("center", 40))
|
||||
)
|
||||
rows = [
|
||||
("Cecha", "Dijkstra", "Bellman-Ford", "A*"),
|
||||
("Typ", "Zachłanny", "Prog. dynamiczne", "Heurystyczny"),
|
||||
("Problem", "SSSP", "SSSP", "Single-pair"),
|
||||
("Ujemne wagi", "NIE", "TAK", "NIE"),
|
||||
("Cykl ujemny", "NIE wykrywa", "TAK wykrywa", "NIE"),
|
||||
("Złożoność", "O((V+E)log V)", "O(V*E)", "Zależy od h(n)"),
|
||||
]
|
||||
clips: list[VideoClip] = [bg, title]
|
||||
for i, row in enumerate(rows):
|
||||
y_pos = 120 + i * 85
|
||||
for j, cell in enumerate(row):
|
||||
x_pos = 60 + j * 300
|
||||
fs = 18 if i > 0 else 22
|
||||
color = "#64B5F6" if i == 0 else "#CFD8DC"
|
||||
tc = (
|
||||
_tc(
|
||||
text=cell,
|
||||
font_size=fs,
|
||||
color=color,
|
||||
font=FONT_B if i == 0 else FONT_R,
|
||||
)
|
||||
.with_duration(12.0)
|
||||
.with_position((x_pos, y_pos))
|
||||
)
|
||||
clips.append(tc)
|
||||
return CompositeVideoClip(clips, size=(W, H)).with_effects(
|
||||
[FadeIn(0.5), FadeOut(0.5)]
|
||||
)
|
||||
@ -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 ---
|
||||
@ -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)
|
||||
294
python_pkg/praca_magisterska_video/generate_images/_arch_c4.py
Normal file
294
python_pkg/praca_magisterska_video/generate_images/_arch_c4.py
Normal file
@ -0,0 +1,294 @@
|
||||
"""C4 Model diagram generation (4 zoom levels)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import (
|
||||
BG,
|
||||
DPI,
|
||||
FS_TITLE,
|
||||
GRAY1,
|
||||
GRAY2,
|
||||
GRAY3,
|
||||
GRAY4,
|
||||
LN,
|
||||
OUTPUT_DIR,
|
||||
_draw_class,
|
||||
draw_arrow,
|
||||
draw_box,
|
||||
draw_line,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _draw_c4_system_context(ax1: Axes) -> None:
|
||||
"""Draw C4 Level 1: System Context."""
|
||||
# Person
|
||||
ax1.add_patch(
|
||||
plt.Circle(
|
||||
(20, 55), 4, lw=1.5,
|
||||
edgecolor=LN, facecolor=GRAY1,
|
||||
)
|
||||
)
|
||||
# Head
|
||||
ax1.add_patch(
|
||||
plt.Circle(
|
||||
(20, 57.5), 1.5, lw=1.2,
|
||||
edgecolor=LN, facecolor="white",
|
||||
)
|
||||
)
|
||||
# Body
|
||||
draw_line(ax1, 20, 56, 20, 52.5, lw=1.2)
|
||||
draw_line(ax1, 17, 55, 23, 55, lw=1.2)
|
||||
ax1.text(
|
||||
20, 48, "Klient",
|
||||
ha="center", fontsize=8, fontweight="bold",
|
||||
)
|
||||
|
||||
draw_box(
|
||||
ax1, 38, 43, 24, 18,
|
||||
"System\nE-commerce",
|
||||
fill=GRAY2, lw=2, fontsize=9,
|
||||
fontweight="bold", rounded=True,
|
||||
)
|
||||
|
||||
draw_box(
|
||||
ax1, 72, 48, 20, 12,
|
||||
"System\nP\u0142atno\u015bci\n(zewn.)",
|
||||
fill=GRAY4, lw=1.5, fontsize=7,
|
||||
rounded=True,
|
||||
)
|
||||
ax1.add_patch(
|
||||
plt.Rectangle(
|
||||
(72, 48), 20, 12, lw=1.5,
|
||||
edgecolor=LN, facecolor="none",
|
||||
linestyle="--",
|
||||
)
|
||||
)
|
||||
|
||||
draw_arrow(ax1, 24, 54, 38, 54)
|
||||
ax1.text(
|
||||
31, 56, "sk\u0142ada\nzam\u00f3wienia",
|
||||
fontsize=6, ha="center",
|
||||
)
|
||||
draw_arrow(ax1, 62, 54, 72, 54)
|
||||
ax1.text(67, 56, "API", fontsize=6, ha="center")
|
||||
|
||||
ax1.text(
|
||||
50, 20,
|
||||
"Kto u\u017cywa systemu?\nZ czym si\u0119 integruje?",
|
||||
ha="center", fontsize=7, fontstyle="italic",
|
||||
bbox={
|
||||
"boxstyle": "round",
|
||||
"facecolor": GRAY4,
|
||||
"edgecolor": LN,
|
||||
"lw": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _draw_c4_container(ax2: Axes) -> None:
|
||||
"""Draw C4 Level 2: Container."""
|
||||
ax2.add_patch(
|
||||
plt.Rectangle(
|
||||
(5, 15), 90, 58, lw=1.5,
|
||||
edgecolor=LN, facecolor="none",
|
||||
linestyle="--",
|
||||
)
|
||||
)
|
||||
ax2.text(
|
||||
50, 75, "System E-commerce",
|
||||
ha="center", fontsize=8,
|
||||
fontweight="bold", fontstyle="italic",
|
||||
)
|
||||
|
||||
containers = [
|
||||
("SPA\n(React)", 15, 50, 18, 12, GRAY1),
|
||||
("API\nServer\n(Node.js)", 42, 50, 18, 12, GRAY2),
|
||||
("Database\n(PostgreSQL)", 70, 50, 18, 12, GRAY3),
|
||||
("Worker\n(Python)", 42, 25, 18, 12, GRAY1),
|
||||
]
|
||||
for label, x, y, w, h, fill in containers:
|
||||
draw_box(
|
||||
ax2, x, y, w, h, label,
|
||||
fill=fill, lw=1.5, fontsize=7,
|
||||
fontweight="bold", rounded=True,
|
||||
)
|
||||
|
||||
draw_arrow(ax2, 33, 56, 42, 56)
|
||||
ax2.text(37.5, 58, "REST", fontsize=6, ha="center")
|
||||
draw_arrow(ax2, 60, 56, 70, 56)
|
||||
ax2.text(65, 58, "SQL", fontsize=6, ha="center")
|
||||
draw_arrow(ax2, 51, 50, 51, 37)
|
||||
ax2.text(53, 44, "async", fontsize=6)
|
||||
|
||||
ax2.text(
|
||||
50, 8,
|
||||
"Jakie kontenery techniczne\n"
|
||||
"sk\u0142adaj\u0105 si\u0119 na system?",
|
||||
ha="center", fontsize=7, fontstyle="italic",
|
||||
bbox={
|
||||
"boxstyle": "round",
|
||||
"facecolor": GRAY4,
|
||||
"edgecolor": LN,
|
||||
"lw": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _draw_c4_component(ax3: Axes) -> None:
|
||||
"""Draw C4 Level 3: Component."""
|
||||
ax3.add_patch(
|
||||
plt.Rectangle(
|
||||
(5, 15), 90, 58, lw=1.5,
|
||||
edgecolor=LN, facecolor="none",
|
||||
linestyle="--",
|
||||
)
|
||||
)
|
||||
ax3.text(
|
||||
50, 75, "API Server (Node.js)",
|
||||
ha="center", fontsize=8,
|
||||
fontweight="bold", fontstyle="italic",
|
||||
)
|
||||
|
||||
components = [
|
||||
("OrderController", 10, 50, 22, 10, GRAY1),
|
||||
("AuthService", 40, 50, 22, 10, GRAY2),
|
||||
("PaymentGateway\n(adapter)", 70, 50, 22, 10, GRAY1),
|
||||
("OrderRepository", 25, 25, 22, 10, GRAY2),
|
||||
("NotificationService", 57, 25, 22, 10, GRAY1),
|
||||
]
|
||||
for label, x, y, w, h, fill in components:
|
||||
draw_box(
|
||||
ax3, x, y, w, h, label,
|
||||
fill=fill, lw=1.5, fontsize=6.5,
|
||||
fontweight="bold", rounded=True,
|
||||
)
|
||||
|
||||
draw_arrow(ax3, 32, 55, 40, 55)
|
||||
draw_arrow(ax3, 62, 55, 70, 55)
|
||||
draw_arrow(ax3, 21, 50, 30, 35)
|
||||
draw_arrow(ax3, 51, 50, 62, 35)
|
||||
|
||||
ax3.text(
|
||||
50, 8,
|
||||
"Jakie modu\u0142y/komponenty\n"
|
||||
"wewn\u0105trz kontenera?",
|
||||
ha="center", fontsize=7, fontstyle="italic",
|
||||
bbox={
|
||||
"boxstyle": "round",
|
||||
"facecolor": GRAY4,
|
||||
"edgecolor": LN,
|
||||
"lw": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _draw_c4_code(ax4: Axes) -> None:
|
||||
"""Draw C4 Level 4: Code (UML)."""
|
||||
_draw_class(
|
||||
ax4, 5, 40,
|
||||
"\u00abinterface\u00bb\nIOrderRepository",
|
||||
[],
|
||||
["+save(order)", "+findById(id)"],
|
||||
w=32, fill=GRAY4,
|
||||
)
|
||||
_draw_class(
|
||||
ax4, 55, 40,
|
||||
"OrderRepository",
|
||||
["-db: Database"],
|
||||
["+save(order)", "+findById(id)"],
|
||||
w=32, fill=GRAY1,
|
||||
)
|
||||
_draw_class(
|
||||
ax4, 30, 10,
|
||||
"Order",
|
||||
["-id: UUID", "-items: List", "-total: Money"],
|
||||
["+addItem(item)", "+calculateTotal()"],
|
||||
w=32, fill=GRAY2,
|
||||
)
|
||||
|
||||
ax4.annotate(
|
||||
"",
|
||||
xy=(37, 46),
|
||||
xytext=(55, 50),
|
||||
arrowprops={
|
||||
"arrowstyle": "-|>",
|
||||
"color": LN,
|
||||
"lw": 1.2,
|
||||
"linestyle": "--",
|
||||
},
|
||||
)
|
||||
ax4.text(
|
||||
46, 52, "\u00abimplements\u00bb",
|
||||
fontsize=6, ha="center", fontstyle="italic",
|
||||
)
|
||||
|
||||
draw_arrow(ax4, 71, 40, 50, 24)
|
||||
ax4.text(64, 32, "uses", fontsize=6, fontstyle="italic")
|
||||
|
||||
ax4.text(
|
||||
50, 3,
|
||||
"Diagramy klas UML\n"
|
||||
"(opcjonalny poziom szczeg\u00f3\u0142owo\u015bci)",
|
||||
ha="center", fontsize=7, fontstyle="italic",
|
||||
bbox={
|
||||
"boxstyle": "round",
|
||||
"facecolor": GRAY4,
|
||||
"edgecolor": LN,
|
||||
"lw": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_c4() -> None:
|
||||
"""Generate c4."""
|
||||
fig, axes = plt.subplots(2, 2, figsize=(8.27, 10))
|
||||
fig.patch.set_facecolor(BG)
|
||||
fig.suptitle(
|
||||
"C4 Model (Simon Brown) \u2014 4 poziomy zoomu",
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
y=0.98,
|
||||
)
|
||||
|
||||
titles = [
|
||||
"Level 1: System Context",
|
||||
"Level 2: Container",
|
||||
"Level 3: Component",
|
||||
"Level 4: Code (UML)",
|
||||
]
|
||||
|
||||
for idx, ax_item in enumerate(axes.flat):
|
||||
ax_item.set_xlim(0, 100)
|
||||
ax_item.set_ylim(0, 80)
|
||||
ax_item.set_aspect("equal")
|
||||
ax_item.axis("off")
|
||||
ax_item.set_title(
|
||||
titles[idx], fontsize=10,
|
||||
fontweight="bold", pad=8,
|
||||
)
|
||||
|
||||
_draw_c4_system_context(axes[0, 0])
|
||||
_draw_c4_container(axes[0, 1])
|
||||
_draw_c4_component(axes[1, 0])
|
||||
_draw_c4_code(axes[1, 1])
|
||||
|
||||
fig.tight_layout(rect=[0, 0, 1, 0.96])
|
||||
fig.savefig(
|
||||
str(Path(OUTPUT_DIR) / "c4_model.png"),
|
||||
dpi=DPI,
|
||||
facecolor="white",
|
||||
bbox_inches="tight",
|
||||
)
|
||||
plt.close(fig)
|
||||
_logger.info(" OK C4 Model")
|
||||
@ -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")
|
||||
@ -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,
|
||||
)
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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
|
||||
# ============================================================
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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")
|
||||
@ -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")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@ -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,
|
||||
)
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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)
|
||||
# ============================================================
|
||||
@ -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")
|
||||
@ -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
|
||||
# ============================================================
|
||||
@ -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
|
||||
# ============================================================
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -64,8 +64,7 @@ def _extract_main_card(
|
||||
answer_parts: list[str] = []
|
||||
|
||||
main_answer = re.search(
|
||||
r"## 📚 Odpowiedź główna\s*\n(.+?)"
|
||||
r"(?=\n## |\n---\s*\n## |\Z)",
|
||||
r"## 📚 Odpowiedź główna\s*\n(.+?)" r"(?=\n## |\n---\s*\n## |\Z)",
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
@ -74,17 +73,13 @@ def _extract_main_card(
|
||||
headers = re.findall(r"### (.+)", answer_text)
|
||||
answer_parts.extend(f"• {h}" for h in headers[:5])
|
||||
|
||||
definitions = re.findall(
|
||||
r"\*\*([^*]+)\*\*\s*[--:]\s*([^*\n]+)", content
|
||||
)
|
||||
definitions = re.findall(r"\*\*([^*]+)\*\*\s*[--:]\s*([^*\n]+)", content)
|
||||
for term, definition in definitions[:3]:
|
||||
if (
|
||||
len(definition) > MIN_DEFINITION_LENGTH
|
||||
and len(definition) < MAX_DEFINITION_LENGTH
|
||||
):
|
||||
answer_parts.append(
|
||||
f"• {term}: {definition.strip()}"
|
||||
)
|
||||
answer_parts.append(f"• {term}: {definition.strip()}")
|
||||
|
||||
if not answer_parts:
|
||||
return []
|
||||
@ -94,19 +89,14 @@ def _extract_main_card(
|
||||
{
|
||||
"question": main_question,
|
||||
"answer": answer_html,
|
||||
"tags": (
|
||||
f"egzamin_magisterski pytanie_{num}"
|
||||
f" {subject} {topic}"
|
||||
),
|
||||
"tags": (f"egzamin_magisterski pytanie_{num}" f" {subject} {topic}"),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _extract_subsection_answer(body_clean: str) -> str | None:
|
||||
"""Extract answer text from a subsection body."""
|
||||
bullets = re.findall(
|
||||
r"[-•]\s*\*\*(.+?)\*\*[:\s]*([^\n]+)?", body_clean
|
||||
)
|
||||
bullets = re.findall(r"[-•]\s*\*\*(.+?)\*\*[:\s]*([^\n]+)?", body_clean)
|
||||
if bullets:
|
||||
return "<br>".join(
|
||||
f"• {b[0]}: {b[1].strip()}" if b[1] else f"• {b[0]}"
|
||||
@ -116,9 +106,7 @@ def _extract_subsection_answer(body_clean: str) -> str | None:
|
||||
paragraphs = [
|
||||
p.strip()
|
||||
for p in body_clean.split("\n\n")
|
||||
if p.strip()
|
||||
and not p.startswith("```")
|
||||
and not p.startswith("|")
|
||||
if p.strip() and not p.startswith("```") and not p.startswith("|")
|
||||
]
|
||||
if paragraphs:
|
||||
first_para = paragraphs[0]
|
||||
@ -139,17 +127,13 @@ def _extract_sub_cards(
|
||||
"""Extract sub-concept cards."""
|
||||
cards: list[dict[str, str]] = []
|
||||
subsections = re.findall(
|
||||
r"### (\d+\.\s+)?(.+?)\n\n(.+?)"
|
||||
r"(?=\n### |\n## |\n---|\Z)",
|
||||
r"### (\d+\.\s+)?(.+?)\n\n(.+?)" r"(?=\n### |\n## |\n---|\Z)",
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
for _, header, body in subsections:
|
||||
if (
|
||||
len(header) < MIN_SUBSECTION_LENGTH
|
||||
or header.startswith("Przykład")
|
||||
):
|
||||
if len(header) < MIN_SUBSECTION_LENGTH or header.startswith("Przykład"):
|
||||
continue
|
||||
|
||||
body_clean = body.strip()
|
||||
@ -160,19 +144,10 @@ def _extract_sub_cards(
|
||||
if not answer_text:
|
||||
continue
|
||||
|
||||
sub_question = (
|
||||
f"Co to jest {header}?"
|
||||
if not header.endswith("?")
|
||||
else header
|
||||
)
|
||||
sub_question = f"Co to jest {header}?" if not header.endswith("?") else header
|
||||
|
||||
if any(
|
||||
kw in header
|
||||
for kw in ("Charakterystyka", "Definicja", "Właściwości")
|
||||
):
|
||||
parent = title.replace("Pytanie", "").strip(
|
||||
": 0123456789"
|
||||
)
|
||||
if any(kw in header for kw in ("Charakterystyka", "Definicja", "Właściwości")):
|
||||
parent = title.replace("Pytanie", "").strip(": 0123456789")
|
||||
sub_question = f"{header} - {parent}"
|
||||
|
||||
cards.append(
|
||||
@ -180,8 +155,7 @@ def _extract_sub_cards(
|
||||
"question": sub_question,
|
||||
"answer": answer_text,
|
||||
"tags": (
|
||||
f"egzamin_magisterski pytanie_{num}"
|
||||
f" {subject} {topic} szczegoly"
|
||||
f"egzamin_magisterski pytanie_{num}" f" {subject} {topic} szczegoly"
|
||||
),
|
||||
}
|
||||
)
|
||||
@ -210,8 +184,7 @@ def _extract_formula_cards(
|
||||
"question": f"Podaj {formula_name.strip()}",
|
||||
"answer": formula_content.strip()[:300],
|
||||
"tags": (
|
||||
f"egzamin_magisterski pytanie_{num}"
|
||||
f" {subject} formuly"
|
||||
f"egzamin_magisterski pytanie_{num}" f" {subject} formuly"
|
||||
),
|
||||
}
|
||||
)
|
||||
@ -223,29 +196,15 @@ def extract_question_and_answer(
|
||||
filepath: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract main question and key answer points from a markdown file."""
|
||||
num, topic, title, main_question, content = _get_metadata(
|
||||
filepath
|
||||
)
|
||||
num, topic, title, main_question, content = _get_metadata(filepath)
|
||||
|
||||
subject_match = re.search(r"Przedmiot:\s*(\w+)", content)
|
||||
subject = (
|
||||
subject_match.group(1) if subject_match else "Ogólne"
|
||||
)
|
||||
subject = subject_match.group(1) if subject_match else "Ogólne"
|
||||
|
||||
cards: list[dict[str, str]] = []
|
||||
cards.extend(
|
||||
_extract_main_card(
|
||||
content, main_question, subject, num, topic
|
||||
)
|
||||
)
|
||||
cards.extend(
|
||||
_extract_sub_cards(
|
||||
content, title, subject, num, topic
|
||||
)
|
||||
)
|
||||
cards.extend(
|
||||
_extract_formula_cards(content, subject, num)
|
||||
)
|
||||
cards.extend(_extract_main_card(content, main_question, subject, num, topic))
|
||||
cards.extend(_extract_sub_cards(content, title, subject, num, topic))
|
||||
cards.extend(_extract_formula_cards(content, subject, num))
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
@ -72,7 +72,8 @@ def _get_file_metadata(
|
||||
|
||||
|
||||
def _extract_main_question_card(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract the main exam question card."""
|
||||
q_match = re.search(
|
||||
@ -85,8 +86,7 @@ def _extract_main_question_card(
|
||||
|
||||
main_q = re.sub(r"\s+", " ", q_match.group(1).strip())
|
||||
answer_match = re.search(
|
||||
r"## 📚 Odpowiedź główna\s*\n(.+?)"
|
||||
r"(?=\n## [📚🎯]|\n---\s*\n## |\Z)",
|
||||
r"## 📚 Odpowiedź główna\s*\n(.+?)" r"(?=\n## [📚🎯]|\n---\s*\n## |\Z)",
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
@ -99,18 +99,12 @@ def _extract_main_question_card(
|
||||
answer_section,
|
||||
re.MULTILINE,
|
||||
)
|
||||
headers = [
|
||||
h.strip()
|
||||
for h in headers
|
||||
if len(h.strip()) > MIN_HEADER_LENGTH
|
||||
][:6]
|
||||
headers = [h.strip() for h in headers if len(h.strip()) > MIN_HEADER_LENGTH][:6]
|
||||
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
answer_html = (
|
||||
"<b>Kluczowe zagadnienia:</b>" + format_list(headers)
|
||||
)
|
||||
answer_html = "<b>Kluczowe zagadnienia:</b>" + format_list(headers)
|
||||
return [
|
||||
{
|
||||
"front": clean_text(main_q),
|
||||
@ -123,10 +117,7 @@ def _extract_main_question_card(
|
||||
def _make_question_text(header: str) -> str:
|
||||
"""Generate a question from a section header."""
|
||||
if "Definicja" in header or "Co to" in header:
|
||||
return (
|
||||
f"Co to jest:"
|
||||
f" {header.replace('Definicja', '').strip()}?"
|
||||
)
|
||||
return f"Co to jest:" f" {header.replace('Definicja', '').strip()}?"
|
||||
if "Charakterystyka" in header:
|
||||
stripped = header.replace("Charakterystyka", "").strip()
|
||||
return f"Scharakteryzuj: {stripped}"
|
||||
@ -143,14 +134,10 @@ def _extract_body_parts(body: str) -> list[str]:
|
||||
if subheaders:
|
||||
answer_parts.extend(subheaders[:4])
|
||||
|
||||
bullets = re.findall(
|
||||
r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body
|
||||
)
|
||||
bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body)
|
||||
for term, desc in bullets[:5]:
|
||||
if desc:
|
||||
answer_parts.append(
|
||||
f"<b>{term}</b>: {desc.strip()}"
|
||||
)
|
||||
answer_parts.append(f"<b>{term}</b>: {desc.strip()}")
|
||||
else:
|
||||
answer_parts.append(f"<b>{term}</b>")
|
||||
|
||||
@ -172,7 +159,8 @@ def _extract_body_parts(body: str) -> list[str]:
|
||||
|
||||
|
||||
def _extract_subsection_cards(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract subsection detail cards."""
|
||||
cards: list[dict[str, str]] = []
|
||||
@ -186,10 +174,7 @@ def _extract_subsection_cards(
|
||||
header = raw_header.strip()
|
||||
body = raw_body.strip()
|
||||
|
||||
if (
|
||||
len(body) < MIN_BODY_LENGTH
|
||||
or header.lower().startswith("przykład")
|
||||
):
|
||||
if len(body) < MIN_BODY_LENGTH or header.lower().startswith("przykład"):
|
||||
continue
|
||||
|
||||
answer_parts = _extract_body_parts(body)
|
||||
@ -213,7 +198,8 @@ def _extract_subsection_cards(
|
||||
|
||||
|
||||
def _extract_algo_cards(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract algorithm/formula cards."""
|
||||
cards: list[dict[str, str]] = []
|
||||
@ -235,12 +221,9 @@ def _extract_algo_cards(
|
||||
cards.append(
|
||||
{
|
||||
"front": (
|
||||
"Jaka jest złożoność"
|
||||
f" algorytmu/metody: {algo_name}?"
|
||||
),
|
||||
"back": clean_text(
|
||||
algo_match.strip()[:200]
|
||||
"Jaka jest złożoność" f" algorytmu/metody: {algo_name}?"
|
||||
),
|
||||
"back": clean_text(algo_match.strip()[:200]),
|
||||
"tags": f"{base_tags} zlozonosc",
|
||||
}
|
||||
)
|
||||
@ -250,7 +233,9 @@ def _extract_algo_cards(
|
||||
|
||||
|
||||
def _extract_comparison_cards(
|
||||
content: str, base_tags: str, num: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
num: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract comparison cards."""
|
||||
compare_match = re.search(
|
||||
@ -269,19 +254,15 @@ def _extract_comparison_cards(
|
||||
if not items:
|
||||
return []
|
||||
|
||||
comparison_html = (
|
||||
"<table><tr><th>Aspekt</th><th>Wartość</th></tr>"
|
||||
)
|
||||
comparison_html = "<table><tr><th>Aspekt</th><th>Wartość</th></tr>"
|
||||
for aspect, value in items[:MAX_COMPARISON_ITEMS]:
|
||||
comparison_html += (
|
||||
f"<tr><td>{clean_text(aspect)}</td>"
|
||||
f"<td>{clean_text(value)}</td></tr>"
|
||||
f"<tr><td>{clean_text(aspect)}</td>" f"<td>{clean_text(value)}</td></tr>"
|
||||
)
|
||||
comparison_html += "</table>"
|
||||
|
||||
title_match = re.search(
|
||||
r"## .*(Porównanie|Zestawienie)"
|
||||
r".*?(\w+.*?(?:vs|i|oraz).*?\w+)",
|
||||
r"## .*(Porównanie|Zestawienie)" r".*?(\w+.*?(?:vs|i|oraz).*?\w+)",
|
||||
compare_match.group(0),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@ -290,10 +271,7 @@ def _extract_comparison_cards(
|
||||
|
||||
return [
|
||||
{
|
||||
"front": (
|
||||
"Porównaj kluczowe różnice"
|
||||
f" w temacie: pytanie {num}"
|
||||
),
|
||||
"front": ("Porównaj kluczowe różnice" f" w temacie: pytanie {num}"),
|
||||
"back": comparison_html,
|
||||
"tags": f"{base_tags} porownanie",
|
||||
}
|
||||
@ -301,7 +279,8 @@ def _extract_comparison_cards(
|
||||
|
||||
|
||||
def _extract_qa_cards(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Extract Q&A practice cards."""
|
||||
cards: list[dict[str, str]] = []
|
||||
@ -315,8 +294,7 @@ def _extract_qa_cards(
|
||||
|
||||
qa_content = qa_section.group(1)
|
||||
qas = re.findall(
|
||||
r"### Q\d+:?\s*[\"']?(.+?)[\"']?\s*\n"
|
||||
r".*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)",
|
||||
r"### Q\d+:?\s*[\"']?(.+?)[\"']?\s*\n" r".*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)",
|
||||
qa_content,
|
||||
re.DOTALL,
|
||||
)
|
||||
@ -332,9 +310,7 @@ def _extract_qa_cards(
|
||||
cards.append(
|
||||
{
|
||||
"front": clean_text(question),
|
||||
"back": clean_text(a_short).replace(
|
||||
"\n", "<br>"
|
||||
),
|
||||
"back": clean_text(a_short).replace("\n", "<br>"),
|
||||
"tags": f"{base_tags} egzamin_praktyka",
|
||||
}
|
||||
)
|
||||
@ -351,9 +327,7 @@ def extract_from_file(filepath: str) -> list[dict[str, str]]:
|
||||
cards.extend(_extract_main_question_card(content, base_tags))
|
||||
cards.extend(_extract_subsection_cards(content, base_tags))
|
||||
cards.extend(_extract_algo_cards(content, base_tags))
|
||||
cards.extend(
|
||||
_extract_comparison_cards(content, base_tags, num)
|
||||
)
|
||||
cards.extend(_extract_comparison_cards(content, base_tags, num))
|
||||
cards.extend(_extract_qa_cards(content, base_tags))
|
||||
|
||||
return cards
|
||||
@ -404,9 +378,7 @@ def main() -> None:
|
||||
f.write(f"{front}\t{back}\t{tags}\n")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(
|
||||
"Generated %d unique flashcards", len(unique_cards)
|
||||
)
|
||||
logger.info("Generated %d unique flashcards", len(unique_cards))
|
||||
logger.info("Saved to: %s", output_file)
|
||||
logger.info("=" * 50)
|
||||
logger.info("IMPORT INSTRUCTIONS:")
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -60,7 +60,8 @@ def extract_real_answer(content: str, section_name: str) -> str | None:
|
||||
if p.strip() and not p.startswith("```") and not p.startswith("|")
|
||||
]
|
||||
lines.extend(
|
||||
p for p in paras[:2]
|
||||
p
|
||||
for p in paras[:2]
|
||||
if len(p) > MIN_PARA_LENGTH and len(p) < MAX_PARA_LENGTH
|
||||
)
|
||||
|
||||
@ -87,9 +88,7 @@ def _read_file_metadata(
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
main_question = (
|
||||
re.sub(r"\s+", " ", q_match.group(1).strip()) if q_match else None
|
||||
)
|
||||
main_question = re.sub(r"\s+", " ", q_match.group(1).strip()) if q_match else None
|
||||
|
||||
return content, base_tags, main_question
|
||||
|
||||
@ -103,16 +102,10 @@ def _extract_automata_facts(content: str) -> list[str]:
|
||||
("Maszyna Turinga", "TM"),
|
||||
]
|
||||
for name, abbrev in automata:
|
||||
pattern = (
|
||||
rf"{name}.*?Rozpoznawana klasa języków"
|
||||
r"\s*\n\s*\*\*([^*]+)\*\*"
|
||||
)
|
||||
pattern = rf"{name}.*?Rozpoznawana klasa języków" r"\s*\n\s*\*\*([^*]+)\*\*"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
parts.append(
|
||||
f"<b>{name} ({abbrev})</b>: "
|
||||
f"{match.group(1).strip()}"
|
||||
)
|
||||
parts.append(f"<b>{name} ({abbrev})</b>: " f"{match.group(1).strip()}")
|
||||
return parts
|
||||
|
||||
|
||||
@ -157,10 +150,7 @@ def _build_main_card(
|
||||
return None
|
||||
|
||||
answer_parts: list[str] = []
|
||||
if (
|
||||
"automat" in main_question.lower()
|
||||
or "maszyn" in main_question.lower()
|
||||
):
|
||||
if "automat" in main_question.lower() or "maszyn" in main_question.lower():
|
||||
answer_parts = _extract_automata_facts(content)
|
||||
|
||||
if not answer_parts:
|
||||
@ -172,9 +162,7 @@ def _build_main_card(
|
||||
if not answer_parts:
|
||||
return None
|
||||
|
||||
answer = "<br><br>".join(
|
||||
clean_text(p) for p in answer_parts
|
||||
)
|
||||
answer = "<br><br>".join(clean_text(p) for p in answer_parts)
|
||||
return {
|
||||
"front": clean_text(main_question),
|
||||
"back": answer,
|
||||
@ -187,13 +175,15 @@ def _extract_section_content(body: str) -> list[str]:
|
||||
answer_lines: list[str] = []
|
||||
|
||||
def_match = re.search(
|
||||
r"#### Definicja[^\n]*\n([^\n#]+(?:\n[^\n#]+)?)", body,
|
||||
r"#### Definicja[^\n]*\n([^\n#]+(?:\n[^\n#]+)?)",
|
||||
body,
|
||||
)
|
||||
if def_match:
|
||||
answer_lines.append(def_match.group(1).strip())
|
||||
|
||||
char_match = re.search(
|
||||
r"#### Charakterystyka\s*\n((?:[-•][^\n]+\n?)+)", body,
|
||||
r"#### Charakterystyka\s*\n((?:[-•][^\n]+\n?)+)",
|
||||
body,
|
||||
)
|
||||
if char_match:
|
||||
bullets = re.findall(
|
||||
@ -202,25 +192,24 @@ def _extract_section_content(body: str) -> list[str]:
|
||||
)
|
||||
for term, desc in bullets[:4]:
|
||||
answer_lines.append(
|
||||
f"• <b>{term}</b>: {desc.strip()}"
|
||||
if desc
|
||||
else f"• <b>{term}</b>"
|
||||
f"• <b>{term}</b>: {desc.strip()}" if desc else f"• <b>{term}</b>"
|
||||
)
|
||||
|
||||
if not answer_lines:
|
||||
bullets = re.findall(
|
||||
r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", body,
|
||||
r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)",
|
||||
body,
|
||||
)
|
||||
for term, desc in bullets[:5]:
|
||||
answer_lines.append(
|
||||
f"• <b>{term}</b>: {desc.strip()}"
|
||||
if desc
|
||||
else f"• <b>{term}</b>"
|
||||
f"• <b>{term}</b>: {desc.strip()}" if desc else f"• <b>{term}</b>"
|
||||
)
|
||||
|
||||
if not answer_lines:
|
||||
first_para = re.search(
|
||||
r"^([^#\n\-•|`][^\n]{30,250})", body, re.MULTILINE,
|
||||
r"^([^#\n\-•|`][^\n]{30,250})",
|
||||
body,
|
||||
re.MULTILINE,
|
||||
)
|
||||
if first_para:
|
||||
answer_lines.append(first_para.group(1))
|
||||
@ -229,7 +218,8 @@ def _extract_section_content(body: str) -> list[str]:
|
||||
|
||||
|
||||
def _build_concept_cards(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Build concept cards from ### sections."""
|
||||
cards: list[dict[str, str]] = []
|
||||
@ -255,12 +245,8 @@ def _build_concept_cards(
|
||||
if not answer_lines:
|
||||
continue
|
||||
|
||||
question = (
|
||||
header if header.endswith("?") else f"Wyjaśnij: {header}"
|
||||
)
|
||||
answer = "<br>".join(
|
||||
clean_text(line) for line in answer_lines
|
||||
)
|
||||
question = header if header.endswith("?") else f"Wyjaśnij: {header}"
|
||||
answer = "<br>".join(clean_text(line) for line in answer_lines)
|
||||
cards.append(
|
||||
{
|
||||
"front": clean_text(question),
|
||||
@ -273,7 +259,8 @@ def _build_concept_cards(
|
||||
|
||||
|
||||
def _build_qa_cards(
|
||||
content: str, base_tags: str,
|
||||
content: str,
|
||||
base_tags: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Build Q&A practice cards."""
|
||||
cards: list[dict[str, str]] = []
|
||||
@ -301,9 +288,7 @@ def _build_qa_cards(
|
||||
cards.append(
|
||||
{
|
||||
"front": clean_text(question + "?"),
|
||||
"back": "<br>".join(
|
||||
clean_text(line) for line in clean_answer
|
||||
),
|
||||
"back": "<br>".join(clean_text(line) for line in clean_answer),
|
||||
"tags": f"{base_tags} qa",
|
||||
}
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
|
||||
95
python_pkg/repo_explorer/_discovery.py
Normal file
95
python_pkg/repo_explorer/_discovery.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Project discovery helpers and shared constants for Repo Explorer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import cast
|
||||
|
||||
# Strip ANSI/VT100 escape sequences so the Text widget shows plain text
|
||||
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return _ANSI_ESCAPE.sub("", text)
|
||||
|
||||
|
||||
def _find_terminal() -> list[str]:
|
||||
"""Return argv prefix for the first available terminal emulator."""
|
||||
candidates = [
|
||||
("kitty", ["kitty", "--"]),
|
||||
("alacritty", ["alacritty", "-e"]),
|
||||
("konsole", ["konsole", "-e"]),
|
||||
("gnome-terminal", ["gnome-terminal", "--"]),
|
||||
("xfce4-terminal", ["xfce4-terminal", "-x"]),
|
||||
("xterm", ["xterm", "-e"]),
|
||||
]
|
||||
for exe, args in candidates:
|
||||
if shutil.which(exe):
|
||||
return args
|
||||
return []
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
IGNORED_DIRS = {
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
"build",
|
||||
"target",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
|
||||
def _is_ignored(path: Path) -> bool:
|
||||
return any(part in IGNORED_DIRS for part in path.parts)
|
||||
|
||||
|
||||
def find_projects(root: Path) -> list[dict[str, object]]:
|
||||
"""Return every directory under *root* that contains a run.sh."""
|
||||
projects: list[dict[str, object]] = []
|
||||
for run_sh in sorted(root.rglob("run.sh")):
|
||||
if _is_ignored(run_sh):
|
||||
continue
|
||||
proj_dir = run_sh.parent
|
||||
rel = proj_dir.relative_to(root)
|
||||
projects.append({"path": proj_dir, "rel": rel, "name": proj_dir.name})
|
||||
return projects
|
||||
|
||||
|
||||
def _desc_from_run_sh(run_sh: Path) -> str:
|
||||
"""Extract leading comment block from run.sh as a description."""
|
||||
comments: list[str] = []
|
||||
for line in run_sh.read_text(errors="replace").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("#!"):
|
||||
continue
|
||||
if s.startswith("#"):
|
||||
comments.append(s[1:].strip())
|
||||
elif comments:
|
||||
break
|
||||
return " ".join(comments)[:300] if comments else ""
|
||||
|
||||
|
||||
def get_description(project_path: Path) -> str:
|
||||
"""Return a short description from README.md or leading run.sh comments."""
|
||||
for readme_name in ("README.md", "README.txt", "readme.md"):
|
||||
readme = project_path / readme_name
|
||||
if readme.exists():
|
||||
text = readme.read_text(errors="replace")
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip().lstrip("#").strip()
|
||||
if stripped:
|
||||
return cast("str", stripped[:300])
|
||||
|
||||
run_sh = project_path / "run.sh"
|
||||
if run_sh.exists():
|
||||
desc = _desc_from_run_sh(run_sh)
|
||||
if desc:
|
||||
return desc
|
||||
|
||||
return "(no description)"
|
||||
211
python_pkg/repo_explorer/_execution.py
Normal file
211
python_pkg/repo_explorer/_execution.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""Process execution mixin for Repo Explorer (embedded PTY and terminal)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.repo_explorer._discovery import REPO_ROOT, _strip_ansi
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ExecutionMixin:
|
||||
"""Mixin providing process launch, PTY streaming and stdin forwarding.
|
||||
|
||||
Expects the concrete class to define: ``_proc``, ``_master_fd``,
|
||||
``_terminal_args``, ``_args_var``, ``_stdin_var``, ``_status_var``,
|
||||
``_run_btn``, ``_stop_btn``, ``_output``, ``_IDLE_FLUSH_TICKS``,
|
||||
``_selected_path``, and the tkinter ``after`` method.
|
||||
"""
|
||||
|
||||
# Attributes provided by the concrete class (declared for type checkers)
|
||||
_proc: subprocess.Popen[bytes] | None
|
||||
_master_fd: int | None
|
||||
_terminal_args: list[str]
|
||||
_args_var: tk.StringVar
|
||||
_stdin_var: tk.StringVar
|
||||
_status_var: tk.StringVar
|
||||
_run_btn: ttk.Button # type: ignore[name-defined]
|
||||
_stop_btn: ttk.Button # type: ignore[name-defined]
|
||||
_output: tk.Text
|
||||
_IDLE_FLUSH_TICKS: int
|
||||
|
||||
def _selected_path(self) -> Path | None: ...
|
||||
def after(self, ms: int, *args: object) -> str: ...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run in external terminal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_in_terminal(self) -> None:
|
||||
path = self._selected_path()
|
||||
if path is None or not self._terminal_args:
|
||||
return
|
||||
args_str = self._args_var.get().strip()
|
||||
extra = args_str.split() if args_str else []
|
||||
subprocess.Popen(
|
||||
[*self._terminal_args, "bash", "run.sh", *extra], cwd=path
|
||||
)
|
||||
self._write_output(
|
||||
f"$ Launched in {self._terminal_args[0]}: "
|
||||
f"{path.relative_to(REPO_ROOT)}\n",
|
||||
"info",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Run embedded with PTY
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_embedded(self) -> None:
|
||||
path = self._selected_path()
|
||||
if path is None:
|
||||
return
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._stop()
|
||||
|
||||
self._clear()
|
||||
args_str = self._args_var.get().strip()
|
||||
extra = args_str.split() if args_str else []
|
||||
display_cmd = ("bash run.sh " + args_str).strip()
|
||||
self._write_output(
|
||||
f"$ {display_cmd} [{path.relative_to(REPO_ROOT)}]\n", "info"
|
||||
)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
self._master_fd = master_fd
|
||||
fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
["/usr/bin/bash", "run.sh", *extra],
|
||||
cwd=path,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
self._run_btn.configure(state=tk.DISABLED)
|
||||
self._stop_btn.configure(state=tk.NORMAL)
|
||||
self._status_var.set("● running")
|
||||
|
||||
threading.Thread(target=self._read_pty, daemon=True).start()
|
||||
threading.Thread(target=self._wait_proc, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def _decode_buf(buf: bytes) -> str:
|
||||
"""Decode a byte buffer, strip ANSI codes and carriage returns."""
|
||||
return _strip_ansi(buf.decode("utf-8", errors="replace").replace("\r", ""))
|
||||
|
||||
def _flush_partial_buf(self, buf: bytes) -> None:
|
||||
"""Flush a partial (no trailing newline) buffer to output."""
|
||||
text = self._decode_buf(buf)
|
||||
if text:
|
||||
self._write_output(text)
|
||||
|
||||
def _process_complete_lines(self, buf: bytes) -> bytes:
|
||||
"""Split buf on newlines, output complete lines, return remainder."""
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
text = self._decode_buf(line)
|
||||
if text:
|
||||
self._write_output(text + "\n")
|
||||
return buf
|
||||
|
||||
def _read_pty(self) -> None:
|
||||
"""Stream PTY output to the widget, stripping ANSI codes.
|
||||
|
||||
Partial lines (prompts without a trailing newline) are flushed after
|
||||
~100 ms of silence so interactive prompts like "Enter value: " appear.
|
||||
"""
|
||||
buf = b""
|
||||
idle_ticks = 0
|
||||
while self._proc and self._proc.poll() is None:
|
||||
mfd = self._master_fd
|
||||
if mfd is None:
|
||||
break
|
||||
ready, _, _ = select.select([mfd], [], [], 0.05)
|
||||
if not ready:
|
||||
if buf:
|
||||
idle_ticks += 1
|
||||
if idle_ticks >= self._IDLE_FLUSH_TICKS:
|
||||
self._flush_partial_buf(buf)
|
||||
buf = b""
|
||||
idle_ticks = 0
|
||||
continue
|
||||
idle_ticks = 0
|
||||
try:
|
||||
chunk = os.read(mfd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
buf = self._process_complete_lines(buf)
|
||||
if buf:
|
||||
self._flush_partial_buf(buf)
|
||||
if self._master_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self._master_fd)
|
||||
self._master_fd = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# stdin forwarding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send_stdin(self, _event: object = None) -> None:
|
||||
text = self._stdin_var.get()
|
||||
self._stdin_var.set("")
|
||||
payload = (text + "\n").encode()
|
||||
if self._master_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.write(self._master_fd, payload)
|
||||
|
||||
def _wait_proc(self) -> None:
|
||||
if self._proc:
|
||||
code = self._proc.wait()
|
||||
self.after(0, self._on_proc_done, code)
|
||||
|
||||
def _on_proc_done(self, code: int) -> None:
|
||||
if code == 0:
|
||||
self._write_output(f"\n[exited with code {code}]\n", "success")
|
||||
self._status_var.set("✓ done")
|
||||
else:
|
||||
self._write_output(f"\n[exited with code {code}]\n", "error")
|
||||
self._status_var.set(f"✗ exit {code}")
|
||||
self._run_btn.configure(state=tk.NORMAL)
|
||||
self._stop_btn.configure(state=tk.DISABLED)
|
||||
|
||||
def _stop(self) -> None:
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
self._status_var.set("stopped")
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._output.configure(state=tk.NORMAL)
|
||||
self._output.delete("1.0", tk.END)
|
||||
self._output.configure(state=tk.DISABLED)
|
||||
self._status_var.set("")
|
||||
|
||||
def _write_output(self, text: str, tag: str | None = None) -> None:
|
||||
"""Thread-safe output append via after()."""
|
||||
self.after(0, self._append_output, text, tag)
|
||||
|
||||
def _append_output(self, text: str, tag: str | None) -> None:
|
||||
self._output.configure(state=tk.NORMAL)
|
||||
if tag:
|
||||
self._output.insert(tk.END, text, tag)
|
||||
else:
|
||||
self._output.insert(tk.END, text)
|
||||
self._output.see(tk.END)
|
||||
self._output.configure(state=tk.DISABLED)
|
||||
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
188
python_pkg/stockfish_analysis/_move_analysis.py
Normal file
188
python_pkg/stockfish_analysis/_move_analysis.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""Move scoring, classification, and single-move analysis helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import chess
|
||||
import chess.engine
|
||||
|
||||
|
||||
def score_to_cp(
|
||||
score: chess.engine.PovScore, *, pov_white: bool
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""Return tuple (cp, mate_in) from a PovScore for the given POV color.
|
||||
|
||||
If it's a mate score, cp will be None and mate_in will be +/-N
|
||||
(positive means mate for POV side). If it's a cp score, mate_in will be None.
|
||||
"""
|
||||
pov = chess.WHITE if pov_white else chess.BLACK
|
||||
s = score.pov(pov)
|
||||
if s.is_mate():
|
||||
mi = s.mate()
|
||||
return None, mi
|
||||
return s.score(mate_score=None), None
|
||||
|
||||
|
||||
# Centipawn loss thresholds for move quality classification (Lichess-like bands)
|
||||
CP_LOSS_BEST = 10
|
||||
CP_LOSS_EXCELLENT = 20
|
||||
CP_LOSS_GOOD = 50
|
||||
CP_LOSS_INACCURACY = 99
|
||||
CP_LOSS_MISTAKE = 299
|
||||
|
||||
|
||||
# Centipawn loss thresholds for move classification
|
||||
_CP_LOSS_BANDS = [
|
||||
(CP_LOSS_BEST, "Best"),
|
||||
(CP_LOSS_EXCELLENT, "Excellent"),
|
||||
(CP_LOSS_GOOD, "Good"),
|
||||
(CP_LOSS_INACCURACY, "Inaccuracy"),
|
||||
(CP_LOSS_MISTAKE, "Mistake"),
|
||||
]
|
||||
|
||||
|
||||
def classify_cp_loss(cp_loss: int | None) -> str:
|
||||
"""Classify move quality using Lichess-like centipawn loss bands.
|
||||
|
||||
Loss is best_eval(cp) - played_eval(cp), from the mover's POV (positive is worse).
|
||||
Bands (approx, widely cited):
|
||||
- Best: 0..10 cp
|
||||
- Excellent: 11..20 cp
|
||||
- Good: 21..50 cp
|
||||
- Inaccuracy: 51..99 cp
|
||||
- Mistake: 100..299 cp
|
||||
- Blunder: >=300 cp
|
||||
"""
|
||||
if cp_loss is None:
|
||||
return "Unknown"
|
||||
for threshold, classification in _CP_LOSS_BANDS:
|
||||
if cp_loss <= threshold:
|
||||
return classification
|
||||
return "Blunder"
|
||||
|
||||
|
||||
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
|
||||
"""Format evaluation score as human-readable string."""
|
||||
if mate_in is not None:
|
||||
sign = "+" if mate_in > 0 else ""
|
||||
return f"M{sign}{mate_in}"
|
||||
if cp is None:
|
||||
return "?"
|
||||
# Convert cp to pawns with sign and 2 decimals
|
||||
return f"{cp / 100.0:+.2f}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveAnalysis:
|
||||
"""Container for single move analysis results."""
|
||||
|
||||
san: str
|
||||
best_san: str
|
||||
played_cp: int | None
|
||||
played_mate: int | None
|
||||
best_cp: int | None
|
||||
best_mate: int | None
|
||||
cp_loss: int | None
|
||||
classification: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisContext:
|
||||
"""Container for analysis parameters passed between functions."""
|
||||
|
||||
engine: chess.engine.SimpleEngine
|
||||
limit: chess.engine.Limit
|
||||
multipv: int
|
||||
|
||||
|
||||
def _get_best_move(
|
||||
engine: chess.engine.SimpleEngine,
|
||||
board: chess.Board,
|
||||
limit: chess.engine.Limit,
|
||||
multipv: int,
|
||||
) -> chess.Move | None:
|
||||
"""Get the engine's best move for a position."""
|
||||
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
|
||||
info = info_raw[0] if isinstance(info_raw, list) else info_raw
|
||||
if info is not None and "pv" in info and info["pv"]:
|
||||
return info["pv"][0]
|
||||
res = engine.play(board, limit)
|
||||
return res.move
|
||||
|
||||
|
||||
def _evaluate_position(
|
||||
engine: chess.engine.SimpleEngine,
|
||||
board: chess.Board,
|
||||
limit: chess.engine.Limit,
|
||||
multipv: int,
|
||||
*,
|
||||
pov_white: bool,
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""Evaluate a position and return (cp, mate_in) from POV."""
|
||||
info_raw = engine.analyse(board, limit=limit, multipv=multipv)
|
||||
info = info_raw[0] if isinstance(info_raw, list) else info_raw
|
||||
if info is None or "score" not in info:
|
||||
return None, None
|
||||
return score_to_cp(info["score"], pov_white=pov_white)
|
||||
|
||||
|
||||
def _classify_mate_move(best_mate: int | None, played_mate: int | None) -> str:
|
||||
"""Classify a move when mate scores are involved."""
|
||||
if best_mate is None or played_mate is None:
|
||||
return "Blunder"
|
||||
if (best_mate > 0) and (played_mate > 0):
|
||||
if abs(played_mate) > abs(best_mate):
|
||||
return "Inaccuracy"
|
||||
return "Best"
|
||||
if (best_mate < 0) and (played_mate < 0):
|
||||
if abs(played_mate) < abs(best_mate):
|
||||
return "Blunder"
|
||||
return "Best" if abs(played_mate) == abs(best_mate) else "Good"
|
||||
return "Blunder"
|
||||
|
||||
|
||||
def _analyze_single_move(
|
||||
ctx: AnalysisContext, board: chess.Board, move: chess.Move
|
||||
) -> MoveAnalysis:
|
||||
"""Analyze a single move and return analysis data."""
|
||||
mover_white = board.turn
|
||||
san = board.san(move)
|
||||
|
||||
best_move = _get_best_move(ctx.engine, board, ctx.limit, ctx.multipv)
|
||||
best_san = board.san(best_move) if best_move is not None else "?"
|
||||
|
||||
board_played = board.copy()
|
||||
board_played.push(move)
|
||||
played_cp, played_mate = _evaluate_position(
|
||||
ctx.engine, board_played, ctx.limit, ctx.multipv, pov_white=mover_white
|
||||
)
|
||||
|
||||
if best_move is not None:
|
||||
board_best = board.copy()
|
||||
board_best.push(best_move)
|
||||
best_cp, best_mate = _evaluate_position(
|
||||
ctx.engine, board_best, ctx.limit, ctx.multipv, pov_white=mover_white
|
||||
)
|
||||
else:
|
||||
best_cp, best_mate = None, None
|
||||
|
||||
cp_loss: int | None = None
|
||||
if best_mate is not None or played_mate is not None:
|
||||
classification = _classify_mate_move(best_mate, played_mate)
|
||||
elif best_cp is not None and played_cp is not None:
|
||||
cp_loss = max(0, best_cp - played_cp)
|
||||
classification = classify_cp_loss(cp_loss)
|
||||
else:
|
||||
classification = "Unknown"
|
||||
|
||||
return MoveAnalysis(
|
||||
san=san,
|
||||
best_san=best_san,
|
||||
played_cp=played_cp,
|
||||
played_mate=played_mate,
|
||||
best_cp=best_cp,
|
||||
best_mate=best_mate,
|
||||
cp_loss=cp_loss,
|
||||
classification=classification,
|
||||
)
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
309
python_pkg/word_frequency/_cache_decks.py
Normal file
309
python_pkg/word_frequency/_cache_decks.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""Cache classes for vocabulary curve excerpts and Anki decks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import python_pkg.word_frequency.cache as _cache_mod
|
||||
|
||||
# =============================================================================
|
||||
# Vocabulary Curve Cache
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class VocabCurveCache:
|
||||
"""Cache for vocabulary curve analysis results."""
|
||||
|
||||
def __init__(self, cache_dir: Path | None = None) -> None:
|
||||
"""Initialize vocabulary curve cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Optional custom cache directory.
|
||||
"""
|
||||
self.cache_dir = (cache_dir or _cache_mod.get_cache_dir()) / "excerpts"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_cache_path(self, file_hash: str, length: int) -> Path:
|
||||
"""Get path to cache file for given hash and length.
|
||||
|
||||
Args:
|
||||
file_hash: Hash of source file.
|
||||
length: Excerpt length.
|
||||
|
||||
Returns:
|
||||
Path to cache file.
|
||||
"""
|
||||
return self.cache_dir / f"{file_hash[:16]}_{length}.json"
|
||||
|
||||
def get(
|
||||
self, filepath: Path, length: int
|
||||
) -> tuple[str, list[tuple[str, int]]] | None:
|
||||
"""Get cached excerpt and words for a file and length.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt, words_with_ranks) or None if not cached.
|
||||
"""
|
||||
file_hash = _cache_mod.get_file_hash(filepath)
|
||||
cache_path = self._get_cache_path(file_hash, length)
|
||||
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
return None
|
||||
else:
|
||||
# Verify hash matches
|
||||
if data.get("file_hash") != file_hash:
|
||||
return None
|
||||
excerpt = data["excerpt"]
|
||||
words = [(w, r) for w, r in data["words"]]
|
||||
return excerpt, words
|
||||
|
||||
def set(
|
||||
self,
|
||||
filepath: Path,
|
||||
length: int,
|
||||
excerpt: str,
|
||||
words: list[tuple[str, int]],
|
||||
) -> None:
|
||||
"""Store excerpt and words in cache.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
excerpt: The excerpt text.
|
||||
words: List of (word, rank) tuples.
|
||||
"""
|
||||
file_hash = _cache_mod.get_file_hash(filepath)
|
||||
cache_path = self._get_cache_path(file_hash, length)
|
||||
|
||||
data = {
|
||||
"file_hash": file_hash,
|
||||
"filepath": str(filepath),
|
||||
"length": length,
|
||||
"excerpt": excerpt,
|
||||
"words": [[w, r] for w, r in words],
|
||||
}
|
||||
|
||||
cache_path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached excerpts."""
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
cache_file.unlink()
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with cache stats.
|
||||
"""
|
||||
cache_files = list(self.cache_dir.glob("*.json"))
|
||||
total_size = sum(f.stat().st_size for f in cache_files)
|
||||
return {
|
||||
"total_entries": len(cache_files),
|
||||
"cache_dir": str(self.cache_dir),
|
||||
"cache_size_bytes": total_size,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Anki Deck Cache
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnkiDeckKey:
|
||||
"""Key parameters for Anki deck cache lookups."""
|
||||
|
||||
filepath: Path
|
||||
length: int
|
||||
target_lang: str
|
||||
include_context: bool
|
||||
all_vocab: bool
|
||||
|
||||
|
||||
class AnkiDeckCache:
|
||||
"""Cache for generated Anki decks."""
|
||||
|
||||
def __init__(self, cache_dir: Path | None = None) -> None:
|
||||
"""Initialize Anki deck cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Optional custom cache directory.
|
||||
"""
|
||||
self.cache_dir = (cache_dir or _cache_mod.get_cache_dir()) / "anki_decks"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.metadata_file = self.cache_dir / "metadata.json"
|
||||
self._metadata: dict[str, Any] | None = None
|
||||
|
||||
def _load_metadata(self) -> dict[str, Any]:
|
||||
"""Load metadata from disk."""
|
||||
if self._metadata is None:
|
||||
if self.metadata_file.exists():
|
||||
try:
|
||||
self._metadata = json.loads(
|
||||
self.metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
self._metadata = {}
|
||||
else:
|
||||
self._metadata = {}
|
||||
return self._metadata
|
||||
|
||||
def _save_metadata(self) -> None:
|
||||
"""Save metadata to disk."""
|
||||
if self._metadata is not None:
|
||||
self.metadata_file.write_text(
|
||||
json.dumps(self._metadata, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_key(
|
||||
file_hash: str,
|
||||
length: int,
|
||||
target_lang: str,
|
||||
*,
|
||||
include_context: bool,
|
||||
all_vocab: bool,
|
||||
) -> str:
|
||||
"""Create cache key for an Anki deck.
|
||||
|
||||
Args:
|
||||
file_hash: Hash of source file.
|
||||
length: Excerpt length.
|
||||
target_lang: Target language.
|
||||
include_context: Whether context is included.
|
||||
all_vocab: Whether all vocab is included.
|
||||
|
||||
Returns:
|
||||
Cache key string.
|
||||
"""
|
||||
flags = f"ctx{int(include_context)}_all{int(all_vocab)}"
|
||||
return f"{file_hash[:16]}_{length}_{target_lang}_{flags}"
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: AnkiDeckKey,
|
||||
) -> tuple[str, str, int, int] | None:
|
||||
"""Get cached Anki deck.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
|
||||
Returns:
|
||||
Tuple of (anki_content, excerpt, num_words, max_rank)
|
||||
or None.
|
||||
"""
|
||||
file_hash = _cache_mod.get_file_hash(key.filepath)
|
||||
cache_key = self._make_key(
|
||||
file_hash,
|
||||
key.length,
|
||||
key.target_lang,
|
||||
include_context=key.include_context,
|
||||
all_vocab=key.all_vocab,
|
||||
)
|
||||
metadata = self._load_metadata()
|
||||
|
||||
if cache_key not in metadata:
|
||||
return None
|
||||
|
||||
entry = metadata[cache_key]
|
||||
if entry.get("file_hash") != file_hash:
|
||||
return None
|
||||
|
||||
deck_file = self.cache_dir / f"{cache_key}.txt"
|
||||
if not deck_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = deck_file.read_text(encoding="utf-8")
|
||||
return (
|
||||
content,
|
||||
entry["excerpt"],
|
||||
entry["num_words"],
|
||||
entry["max_rank"],
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: AnkiDeckKey,
|
||||
anki_content: str,
|
||||
excerpt: str,
|
||||
num_words: int,
|
||||
max_rank: int,
|
||||
) -> None:
|
||||
"""Store Anki deck in cache.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
anki_content: The Anki deck content.
|
||||
excerpt: The excerpt text.
|
||||
num_words: Number of words in deck.
|
||||
max_rank: Maximum word rank.
|
||||
"""
|
||||
file_hash = _cache_mod.get_file_hash(key.filepath)
|
||||
cache_key = self._make_key(
|
||||
file_hash,
|
||||
key.length,
|
||||
key.target_lang,
|
||||
include_context=key.include_context,
|
||||
all_vocab=key.all_vocab,
|
||||
)
|
||||
|
||||
# Save deck content
|
||||
deck_file = self.cache_dir / f"{cache_key}.txt"
|
||||
deck_file.write_text(anki_content, encoding="utf-8")
|
||||
|
||||
# Update metadata
|
||||
metadata = self._load_metadata()
|
||||
metadata[cache_key] = {
|
||||
"file_hash": file_hash,
|
||||
"filepath": str(key.filepath),
|
||||
"length": key.length,
|
||||
"target_lang": key.target_lang,
|
||||
"include_context": key.include_context,
|
||||
"all_vocab": key.all_vocab,
|
||||
"excerpt": excerpt,
|
||||
"num_words": num_words,
|
||||
"max_rank": max_rank,
|
||||
}
|
||||
self._save_metadata()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached decks."""
|
||||
self._metadata = {}
|
||||
for cache_file in self.cache_dir.glob("*.txt"):
|
||||
cache_file.unlink()
|
||||
if self.metadata_file.exists():
|
||||
self.metadata_file.unlink()
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with cache stats.
|
||||
"""
|
||||
metadata = self._load_metadata()
|
||||
cache_files = list(self.cache_dir.glob("*.txt"))
|
||||
total_size = sum(f.stat().st_size for f in cache_files)
|
||||
return {
|
||||
"total_entries": len(metadata),
|
||||
"cache_dir": str(self.cache_dir),
|
||||
"cache_size_bytes": total_size,
|
||||
}
|
||||
191
python_pkg/word_frequency/_deck_builder.py
Normal file
191
python_pkg/word_frequency/_deck_builder.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Anki deck building and card formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from python_pkg.word_frequency._types import DeckInput
|
||||
from python_pkg.word_frequency.translator import translate_words_batch
|
||||
|
||||
|
||||
def find_word_contexts(
|
||||
text: str,
|
||||
words: list[str],
|
||||
context_words: int = 5,
|
||||
) -> dict[str, str]:
|
||||
"""Find example contexts for each word in the text.
|
||||
|
||||
Args:
|
||||
text: The source text.
|
||||
words: List of words to find contexts for.
|
||||
context_words: Number of words of context on each side.
|
||||
|
||||
Returns:
|
||||
Dict mapping word to example context.
|
||||
"""
|
||||
# Extract all words preserving positions
|
||||
all_words = re.findall(r"\b[\w]+\b", text, re.UNICODE)
|
||||
all_words_lower = [w.lower() for w in all_words]
|
||||
|
||||
contexts: dict[str, str] = {}
|
||||
words_lower = {w.lower() for w in words}
|
||||
|
||||
for target in words_lower:
|
||||
# Find first occurrence
|
||||
for i, word in enumerate(all_words_lower):
|
||||
if word == target:
|
||||
start = max(0, i - context_words)
|
||||
end = min(len(all_words), i + context_words + 1)
|
||||
context = " ".join(all_words[start:end])
|
||||
contexts[target] = f"...{context}..."
|
||||
break
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _format_excerpt_card(
|
||||
excerpt: str,
|
||||
excerpt_words: list[tuple[str, int]] | None,
|
||||
) -> str:
|
||||
"""Format the excerpt as the first Anki card.
|
||||
|
||||
Args:
|
||||
excerpt: The target excerpt text.
|
||||
excerpt_words: Words in the excerpt with ranks.
|
||||
|
||||
Returns:
|
||||
Formatted excerpt card line.
|
||||
"""
|
||||
excerpt_escaped = excerpt.replace(";", ",")
|
||||
if excerpt_words:
|
||||
most_frequent = min(excerpt_words, key=lambda x: x[1])[0]
|
||||
rarest = max(excerpt_words, key=lambda x: x[1])[0]
|
||||
if most_frequent != rarest:
|
||||
pattern_rare = re.compile(
|
||||
rf"\b({re.escape(rarest)})\b", re.IGNORECASE
|
||||
)
|
||||
excerpt_escaped = pattern_rare.sub(
|
||||
r"<b>\1</b>", excerpt_escaped
|
||||
)
|
||||
pattern_freq = re.compile(
|
||||
rf"\b({re.escape(most_frequent)})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
excerpt_escaped = pattern_freq.sub(
|
||||
r"<i>\1</i>", excerpt_escaped
|
||||
)
|
||||
else:
|
||||
pattern = re.compile(
|
||||
rf"\b({re.escape(most_frequent)})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
excerpt_escaped = pattern.sub(
|
||||
r"<b><i>\1</i></b>", excerpt_escaped
|
||||
)
|
||||
return f"\U0001f4d6 TARGET EXCERPT;{excerpt_escaped};#0"
|
||||
|
||||
|
||||
def _build_translation_lookup(
|
||||
words_with_ranks: list[tuple[str, int]],
|
||||
source_lang: str,
|
||||
target_lang: str,
|
||||
*,
|
||||
no_translate: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""Build word-to-translation lookup dict.
|
||||
|
||||
Args:
|
||||
words_with_ranks: List of (word, rank) tuples.
|
||||
source_lang: Source language code.
|
||||
target_lang: Target language code.
|
||||
no_translate: If True, use placeholder translations.
|
||||
|
||||
Returns:
|
||||
Dict mapping lowercase word to translation.
|
||||
"""
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
if no_translate:
|
||||
return {w.lower(): "[TODO]" for w in words}
|
||||
translations = translate_words_batch(words, source_lang, target_lang)
|
||||
trans_lookup: dict[str, str] = {}
|
||||
for result in translations:
|
||||
if result.success:
|
||||
trans_lookup[result.source_word.lower()] = (
|
||||
result.translated_word
|
||||
)
|
||||
else:
|
||||
trans_lookup[result.source_word.lower()] = (
|
||||
f"[{result.source_word}]"
|
||||
)
|
||||
return trans_lookup
|
||||
|
||||
|
||||
def generate_anki_deck(
|
||||
deck_input: DeckInput,
|
||||
*,
|
||||
include_context: bool = False,
|
||||
no_translate: bool = False,
|
||||
excerpt: str = "",
|
||||
excerpt_words: list[tuple[str, int]] | None = None,
|
||||
) -> str:
|
||||
"""Generate Anki-compatible deck content.
|
||||
|
||||
Args:
|
||||
deck_input: Core deck data (words, langs, contexts, name).
|
||||
include_context: Whether to include context in cards.
|
||||
no_translate: If True, skip translation (use placeholder).
|
||||
excerpt: The target excerpt text to include in cards.
|
||||
excerpt_words: Words in the excerpt with ranks.
|
||||
|
||||
Returns:
|
||||
Semicolon-separated content ready for Anki import.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Add Anki headers
|
||||
lines.append("#separator:semicolon")
|
||||
lines.append("#html:true")
|
||||
lines.append(f"#deck:{deck_input.deck_name}")
|
||||
lines.append(f"#tags:vocabulary {deck_input.source_lang}")
|
||||
if include_context:
|
||||
lines.append("#columns:Front;Back;Rank;Context")
|
||||
else:
|
||||
lines.append("#columns:Front;Back;Rank")
|
||||
lines.append("") # Empty line before data
|
||||
|
||||
if excerpt:
|
||||
lines.append(_format_excerpt_card(excerpt, excerpt_words))
|
||||
|
||||
trans_lookup = _build_translation_lookup(
|
||||
deck_input.words_with_ranks,
|
||||
deck_input.source_lang,
|
||||
deck_input.target_lang,
|
||||
no_translate=no_translate,
|
||||
)
|
||||
|
||||
# Generate cards
|
||||
for word, rank in deck_input.words_with_ranks:
|
||||
translation = trans_lookup.get(word.lower(), f"[{word}]")
|
||||
|
||||
# Escape semicolons in fields
|
||||
word_escaped = word.replace(";", ",")
|
||||
translation_escaped = translation.replace(";", ",")
|
||||
|
||||
if include_context and deck_input.contexts:
|
||||
context = deck_input.contexts.get(word.lower(), "")
|
||||
if context:
|
||||
context_escaped = context.replace(";", ",")
|
||||
pattern = re.compile(re.escape(word), re.IGNORECASE)
|
||||
context_escaped = pattern.sub(
|
||||
f"<b>{word}</b>", context_escaped
|
||||
)
|
||||
else:
|
||||
context_escaped = ""
|
||||
lines.append(
|
||||
f"{word_escaped};{translation_escaped}"
|
||||
f";#{rank};{context_escaped}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{word_escaped};{translation_escaped};#{rank}")
|
||||
|
||||
return "\n".join(lines)
|
||||
391
python_pkg/word_frequency/_generation.py
Normal file
391
python_pkg/word_frequency/_generation.py
Normal file
@ -0,0 +1,391 @@
|
||||
"""Core flashcard generation logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from python_pkg.word_frequency._deck_builder import (
|
||||
find_word_contexts,
|
||||
generate_anki_deck,
|
||||
)
|
||||
from python_pkg.word_frequency._parsing import (
|
||||
parse_inverse_mode_output,
|
||||
parse_vocabulary_curve_output,
|
||||
)
|
||||
from python_pkg.word_frequency._types import (
|
||||
C_EXECUTABLE,
|
||||
DeckInput,
|
||||
FlashcardOptions,
|
||||
)
|
||||
from python_pkg.word_frequency.analyzer import read_file
|
||||
from python_pkg.word_frequency.cache import (
|
||||
AnkiDeckKey,
|
||||
get_anki_deck_cache,
|
||||
get_vocab_curve_cache,
|
||||
)
|
||||
from python_pkg.word_frequency.translator import detect_language
|
||||
|
||||
|
||||
def run_vocabulary_curve(
|
||||
filepath: Path, max_length: int, *, dump_vocab: bool = False
|
||||
) -> str:
|
||||
"""Run the C vocabulary_curve executable.
|
||||
|
||||
Args:
|
||||
filepath: Path to the text file.
|
||||
max_length: Maximum excerpt length.
|
||||
dump_vocab: If True, also dump all vocabulary up to max rank needed.
|
||||
|
||||
Returns:
|
||||
Output from the executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If executable not found.
|
||||
subprocess.CalledProcessError: If execution fails.
|
||||
"""
|
||||
if not C_EXECUTABLE.exists():
|
||||
msg = (
|
||||
f"C executable not found at {C_EXECUTABLE}. "
|
||||
"Please compile it first: cd C/vocabulary_curve && make"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cmd = [str(C_EXECUTABLE), str(filepath), str(max_length)]
|
||||
if dump_vocab:
|
||||
cmd.append("--dump-vocab")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def run_vocabulary_curve_inverse(
|
||||
filepath: Path, max_vocab: int, *, dump_vocab: bool = False
|
||||
) -> str:
|
||||
"""Run the C vocabulary_curve executable in inverse mode.
|
||||
|
||||
Args:
|
||||
filepath: Path to the text file.
|
||||
max_vocab: Maximum vocabulary size (top N words).
|
||||
dump_vocab: If True, also dump all vocabulary up to max_vocab.
|
||||
|
||||
Returns:
|
||||
Output from the executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If executable not found.
|
||||
subprocess.CalledProcessError: If execution fails.
|
||||
"""
|
||||
if not C_EXECUTABLE.exists():
|
||||
msg = (
|
||||
f"C executable not found at {C_EXECUTABLE}. "
|
||||
"Please compile it first: cd C/vocabulary_curve && make"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cmd = [str(C_EXECUTABLE), str(filepath), "--max-vocab", str(max_vocab)]
|
||||
if dump_vocab:
|
||||
cmd.append("--dump-vocab")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_cached_excerpt(
|
||||
filepath: Path, length: int, *, force: bool = False
|
||||
) -> tuple[str, list[tuple[str, int]]] | None:
|
||||
"""Get cached excerpt if available.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
force: If True, ignore cache.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt, words) or None if not cached.
|
||||
"""
|
||||
if force:
|
||||
return None
|
||||
return get_vocab_curve_cache().get(filepath, length)
|
||||
|
||||
|
||||
def cache_excerpt(
|
||||
filepath: Path, length: int, excerpt: str, words: list[tuple[str, int]]
|
||||
) -> None:
|
||||
"""Store excerpt in cache.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
excerpt: The excerpt text.
|
||||
words: List of (word, rank) tuples.
|
||||
"""
|
||||
get_vocab_curve_cache().set(filepath, length, excerpt, words)
|
||||
|
||||
|
||||
def get_cached_deck(
|
||||
key: AnkiDeckKey,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str, int, int] | None:
|
||||
"""Get cached Anki deck if available.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
force: If True, ignore cache.
|
||||
|
||||
Returns:
|
||||
Tuple of (content, excerpt, num_words, max_rank) or None.
|
||||
"""
|
||||
if force:
|
||||
return None
|
||||
return get_anki_deck_cache().get(key)
|
||||
|
||||
|
||||
def cache_deck(
|
||||
key: AnkiDeckKey,
|
||||
anki_content: str,
|
||||
excerpt: str,
|
||||
num_words: int,
|
||||
max_rank: int,
|
||||
) -> None:
|
||||
"""Store Anki deck in cache.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
anki_content: The deck content.
|
||||
excerpt: The excerpt text.
|
||||
num_words: Number of words.
|
||||
max_rank: Maximum rank.
|
||||
"""
|
||||
get_anki_deck_cache().set(
|
||||
key,
|
||||
anki_content,
|
||||
excerpt,
|
||||
num_words,
|
||||
max_rank,
|
||||
)
|
||||
|
||||
|
||||
def _detect_source_language(
|
||||
filepath: Path,
|
||||
text: str,
|
||||
) -> str:
|
||||
"""Auto-detect source language from file content.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
text: Already-read text (may be empty).
|
||||
|
||||
Returns:
|
||||
Detected language code.
|
||||
|
||||
Raises:
|
||||
ValueError: If language cannot be detected.
|
||||
"""
|
||||
sample_text = read_file(filepath)[:1000] if not text else text[:1000]
|
||||
detected = detect_language(sample_text)
|
||||
if detected is None:
|
||||
msg = (
|
||||
"Could not auto-detect source language. "
|
||||
"Please specify with --from (e.g., --from pl for Polish). "
|
||||
"Install langdetect for auto-detection: "
|
||||
"pip install langdetect"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return detected
|
||||
|
||||
|
||||
def generate_flashcards(
|
||||
filepath: str | Path,
|
||||
excerpt_length: int,
|
||||
options: FlashcardOptions | None = None,
|
||||
*,
|
||||
all_vocab: bool = True,
|
||||
) -> tuple[str, str, int, int]:
|
||||
"""Generate Anki flashcards for vocabulary needed for an excerpt.
|
||||
|
||||
Args:
|
||||
filepath: Path to the source text file.
|
||||
excerpt_length: Target excerpt length.
|
||||
options: Flashcard generation options.
|
||||
all_vocab: If True, include ALL words rank 1 to max rank.
|
||||
|
||||
Returns:
|
||||
Tuple of (anki_content, excerpt, num_words, max_rank).
|
||||
"""
|
||||
if options is None:
|
||||
options = FlashcardOptions()
|
||||
filepath = Path(filepath)
|
||||
deck_key = AnkiDeckKey(
|
||||
filepath=filepath,
|
||||
length=excerpt_length,
|
||||
target_lang=options.target_lang,
|
||||
include_context=options.include_context,
|
||||
all_vocab=all_vocab,
|
||||
)
|
||||
|
||||
# Check for cached full deck (if not using no_translate)
|
||||
if not options.no_translate and not options.force:
|
||||
cached = get_cached_deck(deck_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Read the text (only needed for context finding)
|
||||
text = read_file(filepath) if options.include_context else ""
|
||||
|
||||
# Auto-detect language if not provided
|
||||
source_lang = options.source_lang
|
||||
if source_lang is None:
|
||||
source_lang = _detect_source_language(filepath, text)
|
||||
|
||||
# Run vocabulary curve analysis with vocab dump for all words
|
||||
output = run_vocabulary_curve(
|
||||
filepath, excerpt_length, dump_vocab=all_vocab
|
||||
)
|
||||
excerpt, excerpt_words, all_vocab_words = parse_vocabulary_curve_output(
|
||||
output, excerpt_length
|
||||
)
|
||||
|
||||
if not excerpt_words:
|
||||
msg = f"No words found for excerpt length {excerpt_length}"
|
||||
raise ValueError(msg)
|
||||
|
||||
max_rank = max(rank for _, rank in excerpt_words)
|
||||
words_with_ranks = (
|
||||
all_vocab_words if all_vocab and all_vocab_words else excerpt_words
|
||||
)
|
||||
|
||||
contexts = None
|
||||
if options.include_context:
|
||||
if not text:
|
||||
text = read_file(filepath)
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
contexts = find_word_contexts(text, words)
|
||||
|
||||
deck_name = options.deck_name or f"{filepath.stem}_vocab_{excerpt_length}"
|
||||
|
||||
anki_content = generate_anki_deck(
|
||||
DeckInput(
|
||||
words_with_ranks=words_with_ranks,
|
||||
source_lang=source_lang,
|
||||
target_lang=options.target_lang,
|
||||
contexts=contexts,
|
||||
deck_name=deck_name,
|
||||
),
|
||||
include_context=options.include_context,
|
||||
no_translate=options.no_translate,
|
||||
excerpt=excerpt,
|
||||
excerpt_words=excerpt_words,
|
||||
)
|
||||
|
||||
if not options.no_translate:
|
||||
cache_deck(
|
||||
deck_key,
|
||||
anki_content,
|
||||
excerpt,
|
||||
len(words_with_ranks),
|
||||
max_rank,
|
||||
)
|
||||
|
||||
return anki_content, excerpt, len(words_with_ranks), max_rank
|
||||
|
||||
|
||||
def generate_flashcards_inverse(
|
||||
filepath: str | Path,
|
||||
max_vocab: int,
|
||||
options: FlashcardOptions | None = None,
|
||||
) -> tuple[str, str, int, int, int]:
|
||||
"""Generate Anki flashcards for the longest excerpt using top N words.
|
||||
|
||||
This is the inverse mode: given a vocabulary size, find the longest
|
||||
excerpt that can be understood with only those words.
|
||||
|
||||
Args:
|
||||
filepath: Path to the source text file.
|
||||
max_vocab: Maximum vocabulary size (top N words to learn).
|
||||
options: Flashcard generation options.
|
||||
|
||||
Returns:
|
||||
Tuple of (anki_content, excerpt, excerpt_length,
|
||||
num_words, max_rank_used).
|
||||
"""
|
||||
if options is None:
|
||||
options = FlashcardOptions()
|
||||
filepath = Path(filepath)
|
||||
|
||||
text = read_file(filepath) if options.include_context else ""
|
||||
|
||||
source_lang = options.source_lang
|
||||
if source_lang is None:
|
||||
source_lang = _detect_source_language(filepath, text)
|
||||
|
||||
output = run_vocabulary_curve_inverse(
|
||||
filepath, max_vocab, dump_vocab=True
|
||||
)
|
||||
excerpt, excerpt_length, max_rank_used, all_vocab_words = (
|
||||
parse_inverse_mode_output(output)
|
||||
)
|
||||
|
||||
if excerpt_length == 0:
|
||||
msg = (
|
||||
f"No valid excerpt found using only top {max_vocab} "
|
||||
"words. Try increasing the vocabulary limit."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if not all_vocab_words:
|
||||
msg = f"No vocabulary returned for max_vocab={max_vocab}"
|
||||
raise ValueError(msg)
|
||||
|
||||
words_with_ranks = all_vocab_words
|
||||
|
||||
excerpt_word_set = set(excerpt.lower().split())
|
||||
excerpt_words = [
|
||||
(w, r)
|
||||
for w, r in all_vocab_words
|
||||
if w.lower() in excerpt_word_set
|
||||
]
|
||||
|
||||
contexts = None
|
||||
if options.include_context:
|
||||
if not text:
|
||||
text = read_file(filepath)
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
contexts = find_word_contexts(text, words)
|
||||
|
||||
deck_name = options.deck_name or f"{filepath.stem}_top{max_vocab}"
|
||||
|
||||
anki_content = generate_anki_deck(
|
||||
DeckInput(
|
||||
words_with_ranks=words_with_ranks,
|
||||
source_lang=source_lang,
|
||||
target_lang=options.target_lang,
|
||||
contexts=contexts,
|
||||
deck_name=deck_name,
|
||||
),
|
||||
include_context=options.include_context,
|
||||
no_translate=options.no_translate,
|
||||
excerpt=excerpt,
|
||||
excerpt_words=excerpt_words or None,
|
||||
)
|
||||
|
||||
return (
|
||||
anki_content,
|
||||
excerpt,
|
||||
excerpt_length,
|
||||
len(words_with_ranks),
|
||||
max_rank_used,
|
||||
)
|
||||
163
python_pkg/word_frequency/_learning_batch.py
Normal file
163
python_pkg/word_frequency/_learning_batch.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""Batch generation helpers for the learning pipe module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from python_pkg.word_frequency._learning_constants import LessonConfig
|
||||
from python_pkg.word_frequency.excerpt_finder import find_best_excerpt
|
||||
import python_pkg.word_frequency.translator as _translator
|
||||
|
||||
|
||||
def _detect_translation_language(
|
||||
text: str,
|
||||
config: LessonConfig,
|
||||
lines: list[str],
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Detect translation settings and return (from, to) pair."""
|
||||
actual_from = config.translate_from
|
||||
actual_to = config.translate_to or "en"
|
||||
|
||||
if actual_from == "auto" or (
|
||||
config.translate_to and not config.translate_from
|
||||
):
|
||||
detected = _translator.detect_language(text)
|
||||
if detected:
|
||||
actual_from = detected
|
||||
lines.append(f"Detected language: {detected}")
|
||||
else:
|
||||
lines.append(
|
||||
"Warning: Could not detect language "
|
||||
"(install langdetect: "
|
||||
"pip install langdetect)"
|
||||
)
|
||||
actual_from = None
|
||||
|
||||
return actual_from, actual_to
|
||||
|
||||
|
||||
def _format_word_list(
|
||||
batch_words: list[tuple[str, int]],
|
||||
start_idx: int,
|
||||
total_words: int,
|
||||
translations: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Format the vocabulary word list for a batch."""
|
||||
lines: list[str] = []
|
||||
for i, (word, count) in enumerate(
|
||||
batch_words, start=start_idx + 1,
|
||||
):
|
||||
percentage = (count / total_words) * 100
|
||||
if translations:
|
||||
trans = translations.get(word, "?")
|
||||
lines.append(
|
||||
f" {i:3}. {word:<20} -> {trans:<20}"
|
||||
f" ({count:,} occurrences, "
|
||||
f"{percentage:.2f}%)"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" {i:3}. {word:<20}"
|
||||
f" ({count:,} occurrences, "
|
||||
f"{percentage:.2f}%)"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LessonContext:
|
||||
"""Shared context for batch generation."""
|
||||
|
||||
text: str
|
||||
word_counts: dict[str, int]
|
||||
config: LessonConfig
|
||||
|
||||
|
||||
def _generate_batch_section(
|
||||
ctx: _LessonContext,
|
||||
batch_num: int,
|
||||
batch_words: list[tuple[str, int]],
|
||||
cumulative_words: list[str],
|
||||
) -> list[str]:
|
||||
"""Generate lines for a single batch section."""
|
||||
config = ctx.config
|
||||
total_words = sum(ctx.word_counts.values())
|
||||
start_idx = batch_num * config.batch_size
|
||||
end_idx = start_idx + config.batch_size
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("-" * 70)
|
||||
lines.append(
|
||||
f"BATCH {batch_num + 1}: Words "
|
||||
f"{start_idx + 1} - "
|
||||
f"{min(end_idx, start_idx + len(batch_words))}"
|
||||
)
|
||||
lines.append("-" * 70)
|
||||
lines.append("")
|
||||
|
||||
# Get translations if requested
|
||||
translations: dict[str, str] = {}
|
||||
do_translate = (
|
||||
config.translate_from is not None
|
||||
and config.translate_to is not None
|
||||
)
|
||||
if do_translate:
|
||||
words_to_translate = [word for word, _ in batch_words]
|
||||
translation_results = _translator.translate_words_batch(
|
||||
words_to_translate,
|
||||
config.translate_from, # type: ignore[arg-type]
|
||||
config.translate_to, # type: ignore[arg-type]
|
||||
)
|
||||
translations = {
|
||||
r.source_word: r.translated_word
|
||||
for r in translation_results
|
||||
if r.success
|
||||
}
|
||||
|
||||
lines.append("VOCABULARY TO LEARN:")
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
_format_word_list(
|
||||
batch_words, start_idx, total_words, translations,
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Cumulative coverage
|
||||
cumulative_count = sum(
|
||||
ctx.word_counts[w]
|
||||
for w in cumulative_words
|
||||
if w in ctx.word_counts
|
||||
)
|
||||
coverage = (cumulative_count / total_words) * 100
|
||||
lines.append(
|
||||
"After learning these words, "
|
||||
f"you'll recognize ~{coverage:.1f}% of the text"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Excerpts
|
||||
lines.append("PRACTICE EXCERPTS:")
|
||||
lines.append(
|
||||
"(Excerpts where your learned vocabulary "
|
||||
"is most concentrated)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
excerpts = find_best_excerpt(
|
||||
ctx.text,
|
||||
cumulative_words,
|
||||
config.excerpt_length,
|
||||
case_sensitive=config.case_sensitive,
|
||||
top_n=config.excerpts_per_batch,
|
||||
)
|
||||
|
||||
for j, excerpt in enumerate(excerpts, 1):
|
||||
lines.append(
|
||||
f" Excerpt {j} "
|
||||
f"({excerpt.match_percentage:.1f}% known words):"
|
||||
)
|
||||
lines.append(f' "{excerpt.excerpt}"')
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
155
python_pkg/word_frequency/_learning_constants.py
Normal file
155
python_pkg/word_frequency/_learning_constants.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Constants and configuration for the learning pipe module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Common stopwords for various languages (can be overridden with --stopwords)
|
||||
DEFAULT_STOPWORDS_EN = frozenset(
|
||||
{
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"to",
|
||||
"for",
|
||||
"of",
|
||||
"with",
|
||||
"by",
|
||||
"from",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"shall",
|
||||
"can",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"i",
|
||||
"you",
|
||||
"he",
|
||||
"she",
|
||||
"it",
|
||||
"we",
|
||||
"they",
|
||||
"me",
|
||||
"him",
|
||||
"her",
|
||||
"us",
|
||||
"them",
|
||||
"my",
|
||||
"your",
|
||||
"his",
|
||||
"its",
|
||||
"our",
|
||||
"their",
|
||||
"what",
|
||||
"which",
|
||||
"who",
|
||||
"whom",
|
||||
"whose",
|
||||
"where",
|
||||
"when",
|
||||
"why",
|
||||
"how",
|
||||
"all",
|
||||
"each",
|
||||
"every",
|
||||
"both",
|
||||
"few",
|
||||
"more",
|
||||
"most",
|
||||
"other",
|
||||
"some",
|
||||
"such",
|
||||
"no",
|
||||
"nor",
|
||||
"not",
|
||||
"only",
|
||||
"own",
|
||||
"same",
|
||||
"so",
|
||||
"than",
|
||||
"too",
|
||||
"very",
|
||||
"just",
|
||||
"as",
|
||||
"if",
|
||||
"then",
|
||||
"because",
|
||||
"while",
|
||||
"although",
|
||||
"though",
|
||||
"after",
|
||||
"before",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def load_stopwords(filepath: str | Path | None) -> frozenset[str]:
|
||||
"""Load stopwords from a file (one word per line).
|
||||
|
||||
Args:
|
||||
filepath: Path to stopwords file, or None to use defaults.
|
||||
|
||||
Returns:
|
||||
Frozenset of stopwords.
|
||||
"""
|
||||
if filepath is None:
|
||||
return frozenset()
|
||||
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return frozenset()
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
return frozenset(
|
||||
word.strip().lower() for word in content.splitlines() if word.strip()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonConfig:
|
||||
"""Configuration for learning lesson generation."""
|
||||
|
||||
batch_size: int = 20
|
||||
num_batches: int = 1
|
||||
excerpt_length: int = 30
|
||||
excerpts_per_batch: int = 3
|
||||
stopwords: frozenset[str] | None = None
|
||||
skip_default_stopwords: bool = False
|
||||
skip_numbers: bool = True
|
||||
case_sensitive: bool = False
|
||||
translate_from: str | None = None
|
||||
translate_to: str | None = None
|
||||
|
||||
|
||||
def _resolve_stopwords(config: LessonConfig) -> frozenset[str]:
|
||||
"""Resolve combined stopwords from config."""
|
||||
if config.skip_default_stopwords:
|
||||
return config.stopwords or frozenset()
|
||||
return DEFAULT_STOPWORDS_EN | (config.stopwords or frozenset())
|
||||
173
python_pkg/word_frequency/_parsing.py
Normal file
173
python_pkg/word_frequency/_parsing.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Parsing functions for vocabulary curve output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import re
|
||||
|
||||
from python_pkg.word_frequency._types import (
|
||||
_MIN_EXCERPT_PARTS,
|
||||
_MIN_VOCAB_DUMP_PARTS,
|
||||
)
|
||||
|
||||
|
||||
def _parse_vocab_dump(lines: list[str]) -> list[tuple[str, int]]:
|
||||
"""Parse VOCAB_DUMP section from output lines.
|
||||
|
||||
Args:
|
||||
lines: Output lines from vocabulary_curve.
|
||||
|
||||
Returns:
|
||||
List of (word, rank) tuples.
|
||||
"""
|
||||
all_vocab: list[tuple[str, int]] = []
|
||||
in_vocab_dump = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped == "VOCAB_DUMP_START":
|
||||
in_vocab_dump = True
|
||||
continue
|
||||
if stripped == "VOCAB_DUMP_END":
|
||||
break
|
||||
if in_vocab_dump and ";" in stripped:
|
||||
parts = stripped.split(";")
|
||||
if len(parts) == _MIN_VOCAB_DUMP_PARTS:
|
||||
word, rank_str = parts
|
||||
with contextlib.suppress(ValueError):
|
||||
all_vocab.append((word, int(rank_str)))
|
||||
return all_vocab
|
||||
|
||||
|
||||
def _parse_excerpt_lines(lines: list[str], start: int) -> str:
|
||||
"""Parse excerpt text from output lines starting after 'Excerpt:'.
|
||||
|
||||
Args:
|
||||
lines: Output lines.
|
||||
start: Index of the line after 'Excerpt:'.
|
||||
|
||||
Returns:
|
||||
Joined excerpt text.
|
||||
"""
|
||||
excerpt_parts: list[str] = []
|
||||
idx = start
|
||||
while idx < len(lines):
|
||||
next_line = lines[idx].strip()
|
||||
next_line = next_line.removeprefix('"')
|
||||
if next_line.endswith('"'):
|
||||
next_line = next_line[:-1]
|
||||
excerpt_parts.append(next_line)
|
||||
break
|
||||
excerpt_parts.append(next_line)
|
||||
idx += 1
|
||||
return " ".join(excerpt_parts)
|
||||
|
||||
|
||||
def parse_inverse_mode_output(
|
||||
output: str,
|
||||
) -> tuple[str, int, int, list[tuple[str, int]]]:
|
||||
"""Parse output from vocabulary_curve inverse mode.
|
||||
|
||||
Args:
|
||||
output: Raw output from vocabulary_curve --max-vocab.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt_text, excerpt_length, max_rank_used, all_vocab_words).
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
excerpt = ""
|
||||
excerpt_length = 0
|
||||
max_rank_used = 0
|
||||
|
||||
for i, raw_line in enumerate(lines):
|
||||
line = raw_line.strip()
|
||||
|
||||
if line.startswith("LONGEST EXCERPT:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= _MIN_EXCERPT_PARTS:
|
||||
excerpt_length = int(parts[2])
|
||||
|
||||
elif line.startswith("Excerpt:"):
|
||||
excerpt = _parse_excerpt_lines(lines, i + 1)
|
||||
|
||||
elif line.startswith("Rarest word used:"):
|
||||
match = re.search(r"\(#(\d+)\)", line)
|
||||
if match:
|
||||
max_rank_used = int(match.group(1))
|
||||
|
||||
all_vocab = _parse_vocab_dump(lines)
|
||||
return excerpt, excerpt_length, max_rank_used, all_vocab
|
||||
|
||||
|
||||
def _parse_target_length_block(
|
||||
lines: list[str],
|
||||
target_length: int,
|
||||
) -> tuple[str, list[tuple[str, int]]]:
|
||||
"""Parse the [Length N] block from vocabulary curve output.
|
||||
|
||||
Args:
|
||||
lines: Output lines.
|
||||
target_length: Target excerpt length to find.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt, excerpt_words).
|
||||
"""
|
||||
excerpt = ""
|
||||
excerpt_words: list[tuple[str, int]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip().startswith(f"[Length {target_length}]"):
|
||||
i += 1
|
||||
# Find excerpt line
|
||||
while i < len(lines) and not lines[i].strip().startswith(
|
||||
"Excerpt:"
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
excerpt_line = lines[i].strip()
|
||||
if '"' in excerpt_line:
|
||||
start = excerpt_line.index('"') + 1
|
||||
end = excerpt_line.rindex('"')
|
||||
excerpt = excerpt_line[start:end]
|
||||
# Find words line
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].strip().startswith(
|
||||
"Words:"
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
words_line = lines[i].strip()
|
||||
if words_line.startswith("Words:"):
|
||||
words_part = words_line[6:].strip()
|
||||
pattern = r"(\S+)\(#(\d+)\)"
|
||||
matches = re.findall(pattern, words_part)
|
||||
excerpt_words = [
|
||||
(w, int(r)) for w, r in matches
|
||||
]
|
||||
break
|
||||
i += 1
|
||||
return excerpt, excerpt_words
|
||||
|
||||
|
||||
def parse_vocabulary_curve_output(
|
||||
output: str, target_length: int
|
||||
) -> tuple[str, list[tuple[str, int]], list[tuple[str, int]]]:
|
||||
"""Parse output from vocabulary_curve to get words needed.
|
||||
|
||||
Args:
|
||||
output: Raw output from vocabulary_curve.
|
||||
target_length: The target excerpt length.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt_text, excerpt_words, all_vocab_words).
|
||||
excerpt_words: words in the excerpt with their ranks.
|
||||
all_vocab_words: all words up to max rank
|
||||
(from VOCAB_DUMP if present).
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
|
||||
excerpt, excerpt_words = _parse_target_length_block(
|
||||
lines, target_length
|
||||
)
|
||||
all_vocab = _parse_vocab_dump(lines)
|
||||
|
||||
return excerpt, excerpt_words, all_vocab
|
||||
230
python_pkg/word_frequency/_translator_cli.py
Normal file
230
python_pkg/word_frequency/_translator_cli.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""Command-line interface for the translator module.
|
||||
|
||||
Provides argument parsing, CLI handlers, and the main entry point
|
||||
for the offline translator using Argos Translate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import python_pkg.word_frequency.translator as _trans
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for the translator CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Offline translator using Argos Translate.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
action_group = parser.add_mutually_exclusive_group()
|
||||
action_group.add_argument(
|
||||
"--list-languages",
|
||||
"-l",
|
||||
action="store_true",
|
||||
help="List installed languages",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--list-available",
|
||||
"-L",
|
||||
action="store_true",
|
||||
help="List available language packages for download",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--download",
|
||||
"-d",
|
||||
nargs="+",
|
||||
metavar="LANG",
|
||||
help=(
|
||||
"Download language packs "
|
||||
"(e.g., --download en es pl)"
|
||||
),
|
||||
)
|
||||
|
||||
input_group = parser.add_mutually_exclusive_group()
|
||||
input_group.add_argument(
|
||||
"--text",
|
||||
"-t",
|
||||
type=str,
|
||||
help="Single text/word to translate",
|
||||
)
|
||||
input_group.add_argument(
|
||||
"--words",
|
||||
"-w",
|
||||
nargs="+",
|
||||
help="Words to translate",
|
||||
)
|
||||
input_group.add_argument(
|
||||
"--words-file",
|
||||
"-W",
|
||||
type=str,
|
||||
help="File with words to translate (one per line)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--from",
|
||||
"-f",
|
||||
dest="from_lang",
|
||||
type=str,
|
||||
default="en",
|
||||
help="Source language code (default: en)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--to",
|
||||
"-T",
|
||||
dest="to_lang",
|
||||
type=str,
|
||||
default="en",
|
||||
help="Target language code (default: en)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
help="Output file path",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _handle_list_languages() -> int:
|
||||
"""Handle --list-languages command."""
|
||||
langs = _trans.get_installed_languages()
|
||||
if not langs:
|
||||
sys.stdout.write("No languages installed.\n")
|
||||
sys.stdout.write(
|
||||
"Download some with: --download en es pl de fr\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write("Installed languages:\n")
|
||||
for code, name in sorted(langs):
|
||||
sys.stdout.write(f" {code}: {name}\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_list_available() -> int:
|
||||
"""Handle --list-available command."""
|
||||
packages = _trans.get_available_packages()
|
||||
if not packages:
|
||||
sys.stdout.write(
|
||||
"No packages available "
|
||||
"(check internet connection).\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write("Available language packages:\n")
|
||||
for from_code, from_name, to_code, to_name in sorted(
|
||||
packages,
|
||||
):
|
||||
sys.stdout.write(
|
||||
f" {from_code} ({from_name})"
|
||||
f" -> {to_code} ({to_name})\n",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_download(lang_codes: list[str]) -> int:
|
||||
"""Handle --download command."""
|
||||
download_results = _trans.download_languages(lang_codes)
|
||||
success_count = sum(
|
||||
1 for v in download_results.values() if v
|
||||
)
|
||||
sys.stdout.write(
|
||||
f"\nDownloaded {success_count}/"
|
||||
f"{len(download_results)} language pairs.\n",
|
||||
)
|
||||
return 0 if success_count > 0 else 1
|
||||
|
||||
|
||||
def _collect_words(
|
||||
args: argparse.Namespace,
|
||||
) -> list[str] | None:
|
||||
"""Collect words from args. Returns None on error."""
|
||||
if args.text:
|
||||
return [args.text]
|
||||
if args.words:
|
||||
return args.words
|
||||
if args.words_file:
|
||||
try:
|
||||
content = _trans.read_file(args.words_file)
|
||||
except FileNotFoundError:
|
||||
sys.stderr.write(
|
||||
f"Error: File not found: {args.words_file}\n",
|
||||
)
|
||||
return None
|
||||
return [
|
||||
w.strip()
|
||||
for w in content.splitlines()
|
||||
if w.strip()
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _handle_translation(args: argparse.Namespace) -> int:
|
||||
"""Handle the translation action."""
|
||||
try:
|
||||
results = _trans.translate_words_batch(
|
||||
args.words, args.from_lang, args.to_lang,
|
||||
)
|
||||
except ImportError:
|
||||
logger.exception("Translation import error")
|
||||
return 1
|
||||
|
||||
output = _trans.format_translations(results)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output, encoding="utf-8")
|
||||
sys.stdout.write(
|
||||
f"Translations written to {args.output}\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(output + "\n")
|
||||
|
||||
if any(not r.success for r in results):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point for the translator.
|
||||
|
||||
Args:
|
||||
argv: Command line arguments.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not _trans._check_argos():
|
||||
sys.stderr.write(
|
||||
"Error: argostranslate is not installed.\n"
|
||||
"Install it with: pip install argostranslate\n",
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.list_languages:
|
||||
return _handle_list_languages()
|
||||
if args.list_available:
|
||||
return _handle_list_available()
|
||||
if args.download:
|
||||
return _handle_download(args.download)
|
||||
|
||||
words = _collect_words(args)
|
||||
if not words:
|
||||
if words is not None:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
args.words = words
|
||||
return _handle_translation(args)
|
||||
312
python_pkg/word_frequency/_translator_helpers.py
Normal file
312
python_pkg/word_frequency/_translator_helpers.py
Normal file
@ -0,0 +1,312 @@
|
||||
"""Helper utilities for the translator module.
|
||||
|
||||
Contains GPU initialization, backend availability checks, language detection,
|
||||
translation result types, formatting, and Argos Translate setup functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
torch = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import argostranslate.package
|
||||
import argostranslate.translate
|
||||
except ImportError:
|
||||
argostranslate = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from deep_translator import GoogleTranslator
|
||||
except ImportError:
|
||||
GoogleTranslator = None
|
||||
|
||||
try:
|
||||
import langdetect
|
||||
except ImportError:
|
||||
langdetect = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LANG_DETECT_SAMPLE_SIZE = 5000
|
||||
|
||||
|
||||
class _TranslatorState:
|
||||
"""Holds module-level state for lazy-initialized backends."""
|
||||
|
||||
gpu_initialized: bool = False
|
||||
|
||||
|
||||
def _check_cuda_available() -> bool:
|
||||
"""Check if CUDA is available for GPU acceleration."""
|
||||
return torch is not None and torch.cuda.is_available()
|
||||
|
||||
|
||||
def _validate_gpu_device() -> str:
|
||||
"""Validate GPU device availability and return device name.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no GPU devices are found.
|
||||
"""
|
||||
device_count = torch.cuda.device_count()
|
||||
if device_count == 0:
|
||||
msg = "CUDA reports available but no GPU devices found"
|
||||
raise RuntimeError(msg)
|
||||
return torch.cuda.get_device_name(0)
|
||||
|
||||
|
||||
def _init_gpu_if_available() -> None:
|
||||
"""Initialize GPU for argostranslate if CUDA is available.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If CUDA is available but GPU init fails.
|
||||
"""
|
||||
if _TranslatorState.gpu_initialized:
|
||||
return
|
||||
|
||||
if not _check_cuda_available():
|
||||
_TranslatorState.gpu_initialized = True
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"CUDA detected, initializing GPU acceleration..."
|
||||
)
|
||||
|
||||
try:
|
||||
device_name = _validate_gpu_device()
|
||||
logger.info(" Using GPU: %s", device_name)
|
||||
|
||||
os.environ["CT2_CUDA_ALLOW_FP16"] = "1"
|
||||
os.environ["CT2_USE_EXPERIMENTAL_PACKED_GEMM"] = "1"
|
||||
|
||||
_TranslatorState.gpu_initialized = True
|
||||
logger.info(" GPU acceleration enabled.")
|
||||
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"CUDA is available but GPU initialization failed: "
|
||||
f"{e}\nThis may be due to incompatible CUDA "
|
||||
"version or driver issues.\n"
|
||||
"To disable GPU and use CPU only, set "
|
||||
"environment variable: CT2_FORCE_CPU=1"
|
||||
)
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
|
||||
def _check_deep_translator() -> bool:
|
||||
"""Check if deep-translator is available."""
|
||||
return GoogleTranslator is not None
|
||||
|
||||
|
||||
def _check_langdetect() -> bool:
|
||||
"""Check if langdetect is available."""
|
||||
return langdetect is not None
|
||||
|
||||
|
||||
def detect_language(text: str) -> str | None:
|
||||
"""Detect the language of a text.
|
||||
|
||||
Args:
|
||||
text: The text to analyze.
|
||||
|
||||
Returns:
|
||||
ISO 639-1 language code (e.g., 'en', 'la', 'pl') or None if detection fails.
|
||||
"""
|
||||
if not _check_langdetect():
|
||||
return None
|
||||
|
||||
try:
|
||||
sample = (
|
||||
text[:_LANG_DETECT_SAMPLE_SIZE]
|
||||
if len(text) > _LANG_DETECT_SAMPLE_SIZE
|
||||
else text
|
||||
)
|
||||
return langdetect.detect(sample) # type: ignore[no-any-return,union-attr]
|
||||
except langdetect.LangDetectException: # type: ignore[attr-defined,union-attr]
|
||||
return None
|
||||
|
||||
|
||||
class TranslationResult(NamedTuple):
|
||||
"""Result of a translation."""
|
||||
|
||||
source_word: str
|
||||
translated_word: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def format_translations(
|
||||
results: list[TranslationResult],
|
||||
*,
|
||||
show_errors: bool = True,
|
||||
) -> str:
|
||||
"""Format translation results as a table.
|
||||
|
||||
Args:
|
||||
results: List of TranslationResult to format.
|
||||
show_errors: If True, show error messages for failed translations.
|
||||
|
||||
Returns:
|
||||
Formatted string with translations.
|
||||
"""
|
||||
if not results:
|
||||
return "No translations."
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# Find max widths
|
||||
max_source = max(len(r.source_word) for r in results)
|
||||
max_source = max(max_source, 6) # "Source" header
|
||||
|
||||
successful_lengths = [len(r.translated_word) for r in results if r.success]
|
||||
max_trans = max(successful_lengths) if successful_lengths else 0
|
||||
max_trans = max(max_trans, 11) # "Translation" header minimum
|
||||
|
||||
# Header
|
||||
from_lang = results[0].source_lang
|
||||
to_lang = results[0].target_lang
|
||||
lines.append(f"Translation: {from_lang} -> {to_lang}")
|
||||
lines.append("")
|
||||
lines.append(f"{'Source':<{max_source}} {'Translation':<{max_trans}}")
|
||||
lines.append("-" * (max_source + max_trans + 2))
|
||||
|
||||
# Data
|
||||
for r in results:
|
||||
if r.success:
|
||||
lines.append(
|
||||
f"{r.source_word:<{max_source}} {r.translated_word:<{max_trans}}"
|
||||
)
|
||||
elif show_errors:
|
||||
error_msg = f"[Error: {r.error}]" if r.error else "[Failed]"
|
||||
lines.append(f"{r.source_word:<{max_source}} {error_msg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_file(filepath: str | Path) -> str:
|
||||
"""Read text content from a file."""
|
||||
return Path(filepath).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ensure_argos_installed() -> None:
|
||||
"""Ensure argostranslate is installed, attempt installation if not.
|
||||
|
||||
Raises:
|
||||
ImportError: If argos cannot be installed.
|
||||
"""
|
||||
if argostranslate is not None:
|
||||
return
|
||||
|
||||
logger.info("argostranslate not found. Attempting to install...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "argostranslate"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Attempt runtime re-import
|
||||
importlib.import_module("argostranslate.package")
|
||||
importlib.import_module("argostranslate.translate")
|
||||
logger.info("argostranslate installed successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = e.stderr.decode() if e.stderr else str(e)
|
||||
msg = (
|
||||
"argostranslate is required for offline "
|
||||
"translation.\n\n"
|
||||
"Install manually with one of:\n"
|
||||
" pip install argostranslate"
|
||||
" # In a virtualenv\n"
|
||||
" pipx install argostranslate"
|
||||
" # System-wide via pipx\n"
|
||||
" pacman -S python-argostranslate"
|
||||
" # Arch Linux (if available)\n\n"
|
||||
f"Original error: {error_msg}"
|
||||
)
|
||||
raise ImportError(msg) from e
|
||||
except ImportError:
|
||||
msg = (
|
||||
"argostranslate installation succeeded but "
|
||||
"import failed"
|
||||
)
|
||||
raise ImportError(msg) from None
|
||||
|
||||
|
||||
def _ensure_language_pair(from_lang: str, to_lang: str) -> None:
|
||||
"""Ensure the language pair is available, download if needed.
|
||||
|
||||
Args:
|
||||
from_lang: Source language code.
|
||||
to_lang: Target language code.
|
||||
|
||||
Raises:
|
||||
ValueError: If language pair cannot be obtained.
|
||||
"""
|
||||
installed_languages = (
|
||||
argostranslate.translate.get_installed_languages()
|
||||
)
|
||||
from_lang_obj = None
|
||||
to_lang_obj = None
|
||||
|
||||
for lang in installed_languages:
|
||||
if lang.code == from_lang:
|
||||
from_lang_obj = lang
|
||||
if lang.code == to_lang:
|
||||
to_lang_obj = lang
|
||||
|
||||
if from_lang_obj and to_lang_obj:
|
||||
# Check if translation is available
|
||||
translation = from_lang_obj.get_translation(to_lang_obj)
|
||||
if translation:
|
||||
return # Already available
|
||||
|
||||
# Need to download
|
||||
logger.info(
|
||||
"Downloading language pack: %s -> %s...",
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
logger.info(" Fetching package index...")
|
||||
argostranslate.package.update_package_index()
|
||||
available = argostranslate.package.get_available_packages()
|
||||
|
||||
pkg = next(
|
||||
(
|
||||
p
|
||||
for p in available
|
||||
if p.from_code == from_lang and p.to_code == to_lang
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if pkg is None:
|
||||
msg = (
|
||||
f"No language pack available for "
|
||||
f"{from_lang} -> {to_lang}. "
|
||||
"Available pairs can be listed with "
|
||||
"--list-languages."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
logger.info(
|
||||
" Downloading package (~50-100MB, "
|
||||
"this may take a minute)...",
|
||||
)
|
||||
download_path = pkg.download()
|
||||
logger.info(" Installing language pack...")
|
||||
argostranslate.package.install_from_path(download_path)
|
||||
logger.info(
|
||||
"Language pack %s -> %s installed.",
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
50
python_pkg/word_frequency/_types.py
Normal file
50
python_pkg/word_frequency/_types.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Shared types and constants for the Anki generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
_MIN_VOCAB_DUMP_PARTS = 2
|
||||
_MIN_EXCERPT_PARTS = 3
|
||||
_ONE_KB = 1024
|
||||
_ONE_MB = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlashcardOptions:
|
||||
"""Options for flashcard generation."""
|
||||
|
||||
source_lang: str | None = None
|
||||
target_lang: str = "en"
|
||||
deck_name: str | None = None
|
||||
include_context: bool = False
|
||||
no_translate: bool = False
|
||||
force: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeckInput:
|
||||
"""Input data for Anki deck generation."""
|
||||
|
||||
words_with_ranks: list[tuple[str, int]]
|
||||
source_lang: str
|
||||
target_lang: str = "en"
|
||||
contexts: dict[str, str] | None = None
|
||||
deck_name: str = "Vocabulary"
|
||||
|
||||
|
||||
# Path to C vocabulary_curve executable
|
||||
C_EXECUTABLE = (
|
||||
Path(__file__).parent.parent.parent / "C" / "vocabulary_curve" / "vocabulary_curve"
|
||||
)
|
||||
|
||||
|
||||
class VocabWord(NamedTuple):
|
||||
"""A vocabulary word with its metadata."""
|
||||
|
||||
word: str
|
||||
rank: int
|
||||
translation: str
|
||||
context: str
|
||||
@ -30,795 +30,68 @@ Output:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
try:
|
||||
from python_pkg.word_frequency.analyzer import read_file
|
||||
from python_pkg.word_frequency.cache import (
|
||||
AnkiDeckKey,
|
||||
clear_all_caches,
|
||||
get_all_cache_stats,
|
||||
get_anki_deck_cache,
|
||||
get_vocab_curve_cache,
|
||||
)
|
||||
from python_pkg.word_frequency.translator import (
|
||||
detect_language,
|
||||
translate_words_batch,
|
||||
)
|
||||
except ImportError:
|
||||
from analyzer import read_file
|
||||
from cache import (
|
||||
AnkiDeckKey,
|
||||
clear_all_caches,
|
||||
get_all_cache_stats,
|
||||
get_anki_deck_cache,
|
||||
get_vocab_curve_cache,
|
||||
)
|
||||
from translator import detect_language, translate_words_batch
|
||||
from python_pkg.word_frequency._deck_builder import (
|
||||
find_word_contexts,
|
||||
generate_anki_deck,
|
||||
)
|
||||
from python_pkg.word_frequency._generation import (
|
||||
cache_deck,
|
||||
cache_excerpt,
|
||||
generate_flashcards,
|
||||
generate_flashcards_inverse,
|
||||
get_cached_deck,
|
||||
get_cached_excerpt,
|
||||
run_vocabulary_curve,
|
||||
run_vocabulary_curve_inverse,
|
||||
)
|
||||
from python_pkg.word_frequency._parsing import (
|
||||
parse_inverse_mode_output,
|
||||
parse_vocabulary_curve_output,
|
||||
)
|
||||
from python_pkg.word_frequency._types import (
|
||||
_ONE_KB,
|
||||
_ONE_MB,
|
||||
C_EXECUTABLE,
|
||||
DeckInput,
|
||||
FlashcardOptions,
|
||||
VocabWord,
|
||||
)
|
||||
from python_pkg.word_frequency.cache import (
|
||||
clear_all_caches,
|
||||
get_all_cache_stats,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_VOCAB_DUMP_PARTS = 2
|
||||
_MIN_EXCERPT_PARTS = 3
|
||||
_ONE_KB = 1024
|
||||
_ONE_MB = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlashcardOptions:
|
||||
"""Options for flashcard generation."""
|
||||
|
||||
source_lang: str | None = None
|
||||
target_lang: str = "en"
|
||||
deck_name: str | None = None
|
||||
include_context: bool = False
|
||||
no_translate: bool = False
|
||||
force: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeckInput:
|
||||
"""Input data for Anki deck generation."""
|
||||
|
||||
words_with_ranks: list[tuple[str, int]]
|
||||
source_lang: str
|
||||
target_lang: str = "en"
|
||||
contexts: dict[str, str] | None = None
|
||||
deck_name: str = "Vocabulary"
|
||||
|
||||
|
||||
# Path to C vocabulary_curve executable
|
||||
C_EXECUTABLE = (
|
||||
Path(__file__).parent.parent.parent / "C" / "vocabulary_curve" / "vocabulary_curve"
|
||||
)
|
||||
|
||||
|
||||
class VocabWord(NamedTuple):
|
||||
"""A vocabulary word with its metadata."""
|
||||
|
||||
word: str
|
||||
rank: int
|
||||
translation: str
|
||||
context: str
|
||||
|
||||
|
||||
def run_vocabulary_curve(
|
||||
filepath: Path, max_length: int, *, dump_vocab: bool = False
|
||||
) -> str:
|
||||
"""Run the C vocabulary_curve executable.
|
||||
|
||||
Args:
|
||||
filepath: Path to the text file.
|
||||
max_length: Maximum excerpt length.
|
||||
dump_vocab: If True, also dump all vocabulary up to max rank needed.
|
||||
|
||||
Returns:
|
||||
Output from the executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If executable not found.
|
||||
subprocess.CalledProcessError: If execution fails.
|
||||
"""
|
||||
if not C_EXECUTABLE.exists():
|
||||
msg = (
|
||||
f"C executable not found at {C_EXECUTABLE}. "
|
||||
"Please compile it first: cd C/vocabulary_curve && make"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cmd = [str(C_EXECUTABLE), str(filepath), str(max_length)]
|
||||
if dump_vocab:
|
||||
cmd.append("--dump-vocab")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def run_vocabulary_curve_inverse(
|
||||
filepath: Path, max_vocab: int, *, dump_vocab: bool = False
|
||||
) -> str:
|
||||
"""Run the C vocabulary_curve executable in inverse mode.
|
||||
|
||||
Args:
|
||||
filepath: Path to the text file.
|
||||
max_vocab: Maximum vocabulary size (top N words).
|
||||
dump_vocab: If True, also dump all vocabulary up to max_vocab.
|
||||
|
||||
Returns:
|
||||
Output from the executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If executable not found.
|
||||
subprocess.CalledProcessError: If execution fails.
|
||||
"""
|
||||
if not C_EXECUTABLE.exists():
|
||||
msg = (
|
||||
f"C executable not found at {C_EXECUTABLE}. "
|
||||
"Please compile it first: cd C/vocabulary_curve && make"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cmd = [str(C_EXECUTABLE), str(filepath), "--max-vocab", str(max_vocab)]
|
||||
if dump_vocab:
|
||||
cmd.append("--dump-vocab")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _parse_vocab_dump(lines: list[str]) -> list[tuple[str, int]]:
|
||||
"""Parse VOCAB_DUMP section from output lines.
|
||||
|
||||
Args:
|
||||
lines: Output lines from vocabulary_curve.
|
||||
|
||||
Returns:
|
||||
List of (word, rank) tuples.
|
||||
"""
|
||||
all_vocab: list[tuple[str, int]] = []
|
||||
in_vocab_dump = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped == "VOCAB_DUMP_START":
|
||||
in_vocab_dump = True
|
||||
continue
|
||||
if stripped == "VOCAB_DUMP_END":
|
||||
break
|
||||
if in_vocab_dump and ";" in stripped:
|
||||
parts = stripped.split(";")
|
||||
if len(parts) == _MIN_VOCAB_DUMP_PARTS:
|
||||
word, rank_str = parts
|
||||
with contextlib.suppress(ValueError):
|
||||
all_vocab.append((word, int(rank_str)))
|
||||
return all_vocab
|
||||
|
||||
|
||||
def _parse_excerpt_lines(lines: list[str], start: int) -> str:
|
||||
"""Parse excerpt text from output lines starting after 'Excerpt:'.
|
||||
|
||||
Args:
|
||||
lines: Output lines.
|
||||
start: Index of the line after 'Excerpt:'.
|
||||
|
||||
Returns:
|
||||
Joined excerpt text.
|
||||
"""
|
||||
excerpt_parts: list[str] = []
|
||||
idx = start
|
||||
while idx < len(lines):
|
||||
next_line = lines[idx].strip()
|
||||
next_line = next_line.removeprefix('"')
|
||||
if next_line.endswith('"'):
|
||||
next_line = next_line[:-1]
|
||||
excerpt_parts.append(next_line)
|
||||
break
|
||||
excerpt_parts.append(next_line)
|
||||
idx += 1
|
||||
return " ".join(excerpt_parts)
|
||||
|
||||
|
||||
def parse_inverse_mode_output(
|
||||
output: str,
|
||||
) -> tuple[str, int, int, list[tuple[str, int]]]:
|
||||
"""Parse output from vocabulary_curve inverse mode.
|
||||
|
||||
Args:
|
||||
output: Raw output from vocabulary_curve --max-vocab.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt_text, excerpt_length, max_rank_used, all_vocab_words).
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
excerpt = ""
|
||||
excerpt_length = 0
|
||||
max_rank_used = 0
|
||||
|
||||
for i, raw_line in enumerate(lines):
|
||||
line = raw_line.strip()
|
||||
|
||||
if line.startswith("LONGEST EXCERPT:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= _MIN_EXCERPT_PARTS:
|
||||
excerpt_length = int(parts[2])
|
||||
|
||||
elif line.startswith("Excerpt:"):
|
||||
excerpt = _parse_excerpt_lines(lines, i + 1)
|
||||
|
||||
elif line.startswith("Rarest word used:"):
|
||||
match = re.search(r"\(#(\d+)\)", line)
|
||||
if match:
|
||||
max_rank_used = int(match.group(1))
|
||||
|
||||
all_vocab = _parse_vocab_dump(lines)
|
||||
return excerpt, excerpt_length, max_rank_used, all_vocab
|
||||
|
||||
|
||||
def _parse_target_length_block(
|
||||
lines: list[str],
|
||||
target_length: int,
|
||||
) -> tuple[str, list[tuple[str, int]]]:
|
||||
"""Parse the [Length N] block from vocabulary curve output.
|
||||
|
||||
Args:
|
||||
lines: Output lines.
|
||||
target_length: Target excerpt length to find.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt, excerpt_words).
|
||||
"""
|
||||
excerpt = ""
|
||||
excerpt_words: list[tuple[str, int]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip().startswith(f"[Length {target_length}]"):
|
||||
i += 1
|
||||
# Find excerpt line
|
||||
while i < len(lines) and not lines[i].strip().startswith(
|
||||
"Excerpt:"
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
excerpt_line = lines[i].strip()
|
||||
if '"' in excerpt_line:
|
||||
start = excerpt_line.index('"') + 1
|
||||
end = excerpt_line.rindex('"')
|
||||
excerpt = excerpt_line[start:end]
|
||||
# Find words line
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].strip().startswith(
|
||||
"Words:"
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
words_line = lines[i].strip()
|
||||
if words_line.startswith("Words:"):
|
||||
words_part = words_line[6:].strip()
|
||||
pattern = r"(\S+)\(#(\d+)\)"
|
||||
matches = re.findall(pattern, words_part)
|
||||
excerpt_words = [
|
||||
(w, int(r)) for w, r in matches
|
||||
]
|
||||
break
|
||||
i += 1
|
||||
return excerpt, excerpt_words
|
||||
|
||||
|
||||
def parse_vocabulary_curve_output(
|
||||
output: str, target_length: int
|
||||
) -> tuple[str, list[tuple[str, int]], list[tuple[str, int]]]:
|
||||
"""Parse output from vocabulary_curve to get words needed.
|
||||
|
||||
Args:
|
||||
output: Raw output from vocabulary_curve.
|
||||
target_length: The target excerpt length.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt_text, excerpt_words, all_vocab_words).
|
||||
excerpt_words: words in the excerpt with their ranks.
|
||||
all_vocab_words: all words up to max rank
|
||||
(from VOCAB_DUMP if present).
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
|
||||
excerpt, excerpt_words = _parse_target_length_block(
|
||||
lines, target_length
|
||||
)
|
||||
all_vocab = _parse_vocab_dump(lines)
|
||||
|
||||
return excerpt, excerpt_words, all_vocab
|
||||
|
||||
|
||||
def find_word_contexts(
|
||||
text: str,
|
||||
words: list[str],
|
||||
context_words: int = 5,
|
||||
) -> dict[str, str]:
|
||||
"""Find example contexts for each word in the text.
|
||||
|
||||
Args:
|
||||
text: The source text.
|
||||
words: List of words to find contexts for.
|
||||
context_words: Number of words of context on each side.
|
||||
|
||||
Returns:
|
||||
Dict mapping word to example context.
|
||||
"""
|
||||
# Extract all words preserving positions
|
||||
all_words = re.findall(r"\b[\w]+\b", text, re.UNICODE)
|
||||
all_words_lower = [w.lower() for w in all_words]
|
||||
|
||||
contexts: dict[str, str] = {}
|
||||
words_lower = {w.lower() for w in words}
|
||||
|
||||
for target in words_lower:
|
||||
# Find first occurrence
|
||||
for i, word in enumerate(all_words_lower):
|
||||
if word == target:
|
||||
start = max(0, i - context_words)
|
||||
end = min(len(all_words), i + context_words + 1)
|
||||
context = " ".join(all_words[start:end])
|
||||
contexts[target] = f"...{context}..."
|
||||
break
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _format_excerpt_card(
|
||||
excerpt: str,
|
||||
excerpt_words: list[tuple[str, int]] | None,
|
||||
) -> str:
|
||||
"""Format the excerpt as the first Anki card.
|
||||
|
||||
Args:
|
||||
excerpt: The target excerpt text.
|
||||
excerpt_words: Words in the excerpt with ranks.
|
||||
|
||||
Returns:
|
||||
Formatted excerpt card line.
|
||||
"""
|
||||
excerpt_escaped = excerpt.replace(";", ",")
|
||||
if excerpt_words:
|
||||
most_frequent = min(excerpt_words, key=lambda x: x[1])[0]
|
||||
rarest = max(excerpt_words, key=lambda x: x[1])[0]
|
||||
if most_frequent != rarest:
|
||||
pattern_rare = re.compile(
|
||||
rf"\b({re.escape(rarest)})\b", re.IGNORECASE
|
||||
)
|
||||
excerpt_escaped = pattern_rare.sub(
|
||||
r"<b>\1</b>", excerpt_escaped
|
||||
)
|
||||
pattern_freq = re.compile(
|
||||
rf"\b({re.escape(most_frequent)})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
excerpt_escaped = pattern_freq.sub(
|
||||
r"<i>\1</i>", excerpt_escaped
|
||||
)
|
||||
else:
|
||||
pattern = re.compile(
|
||||
rf"\b({re.escape(most_frequent)})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
excerpt_escaped = pattern.sub(
|
||||
r"<b><i>\1</i></b>", excerpt_escaped
|
||||
)
|
||||
return f"\U0001f4d6 TARGET EXCERPT;{excerpt_escaped};#0"
|
||||
|
||||
|
||||
def _build_translation_lookup(
|
||||
words_with_ranks: list[tuple[str, int]],
|
||||
source_lang: str,
|
||||
target_lang: str,
|
||||
*,
|
||||
no_translate: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""Build word-to-translation lookup dict.
|
||||
|
||||
Args:
|
||||
words_with_ranks: List of (word, rank) tuples.
|
||||
source_lang: Source language code.
|
||||
target_lang: Target language code.
|
||||
no_translate: If True, use placeholder translations.
|
||||
|
||||
Returns:
|
||||
Dict mapping lowercase word to translation.
|
||||
"""
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
if no_translate:
|
||||
return {w.lower(): "[TODO]" for w in words}
|
||||
translations = translate_words_batch(words, source_lang, target_lang)
|
||||
trans_lookup: dict[str, str] = {}
|
||||
for result in translations:
|
||||
if result.success:
|
||||
trans_lookup[result.source_word.lower()] = (
|
||||
result.translated_word
|
||||
)
|
||||
else:
|
||||
trans_lookup[result.source_word.lower()] = (
|
||||
f"[{result.source_word}]"
|
||||
)
|
||||
return trans_lookup
|
||||
|
||||
|
||||
def generate_anki_deck(
|
||||
deck_input: DeckInput,
|
||||
*,
|
||||
include_context: bool = False,
|
||||
no_translate: bool = False,
|
||||
excerpt: str = "",
|
||||
excerpt_words: list[tuple[str, int]] | None = None,
|
||||
) -> str:
|
||||
"""Generate Anki-compatible deck content.
|
||||
|
||||
Args:
|
||||
deck_input: Core deck data (words, langs, contexts, name).
|
||||
include_context: Whether to include context in cards.
|
||||
no_translate: If True, skip translation (use placeholder).
|
||||
excerpt: The target excerpt text to include in cards.
|
||||
excerpt_words: Words in the excerpt with ranks.
|
||||
|
||||
Returns:
|
||||
Semicolon-separated content ready for Anki import.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Add Anki headers
|
||||
lines.append("#separator:semicolon")
|
||||
lines.append("#html:true")
|
||||
lines.append(f"#deck:{deck_input.deck_name}")
|
||||
lines.append(f"#tags:vocabulary {deck_input.source_lang}")
|
||||
if include_context:
|
||||
lines.append("#columns:Front;Back;Rank;Context")
|
||||
else:
|
||||
lines.append("#columns:Front;Back;Rank")
|
||||
lines.append("") # Empty line before data
|
||||
|
||||
if excerpt:
|
||||
lines.append(_format_excerpt_card(excerpt, excerpt_words))
|
||||
|
||||
trans_lookup = _build_translation_lookup(
|
||||
deck_input.words_with_ranks,
|
||||
deck_input.source_lang,
|
||||
deck_input.target_lang,
|
||||
no_translate=no_translate,
|
||||
)
|
||||
|
||||
# Generate cards
|
||||
for word, rank in deck_input.words_with_ranks:
|
||||
translation = trans_lookup.get(word.lower(), f"[{word}]")
|
||||
|
||||
# Escape semicolons in fields
|
||||
word_escaped = word.replace(";", ",")
|
||||
translation_escaped = translation.replace(";", ",")
|
||||
|
||||
if include_context and deck_input.contexts:
|
||||
context = deck_input.contexts.get(word.lower(), "")
|
||||
if context:
|
||||
context_escaped = context.replace(";", ",")
|
||||
pattern = re.compile(re.escape(word), re.IGNORECASE)
|
||||
context_escaped = pattern.sub(
|
||||
f"<b>{word}</b>", context_escaped
|
||||
)
|
||||
else:
|
||||
context_escaped = ""
|
||||
lines.append(
|
||||
f"{word_escaped};{translation_escaped}"
|
||||
f";#{rank};{context_escaped}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{word_escaped};{translation_escaped};#{rank}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_cached_excerpt(
|
||||
filepath: Path, length: int, *, force: bool = False
|
||||
) -> tuple[str, list[tuple[str, int]]] | None:
|
||||
"""Get cached excerpt if available.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
force: If True, ignore cache.
|
||||
|
||||
Returns:
|
||||
Tuple of (excerpt, words) or None if not cached.
|
||||
"""
|
||||
if force:
|
||||
return None
|
||||
return get_vocab_curve_cache().get(filepath, length)
|
||||
|
||||
|
||||
def cache_excerpt(
|
||||
filepath: Path, length: int, excerpt: str, words: list[tuple[str, int]]
|
||||
) -> None:
|
||||
"""Store excerpt in cache.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
length: Excerpt length.
|
||||
excerpt: The excerpt text.
|
||||
words: List of (word, rank) tuples.
|
||||
"""
|
||||
get_vocab_curve_cache().set(filepath, length, excerpt, words)
|
||||
|
||||
|
||||
def get_cached_deck(
|
||||
key: AnkiDeckKey,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[str, str, int, int] | None:
|
||||
"""Get cached Anki deck if available.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
force: If True, ignore cache.
|
||||
|
||||
Returns:
|
||||
Tuple of (content, excerpt, num_words, max_rank) or None.
|
||||
"""
|
||||
if force:
|
||||
return None
|
||||
return get_anki_deck_cache().get(key)
|
||||
|
||||
|
||||
def cache_deck(
|
||||
key: AnkiDeckKey,
|
||||
anki_content: str,
|
||||
excerpt: str,
|
||||
num_words: int,
|
||||
max_rank: int,
|
||||
) -> None:
|
||||
"""Store Anki deck in cache.
|
||||
|
||||
Args:
|
||||
key: Cache key parameters.
|
||||
anki_content: The deck content.
|
||||
excerpt: The excerpt text.
|
||||
num_words: Number of words.
|
||||
max_rank: Maximum rank.
|
||||
"""
|
||||
get_anki_deck_cache().set(
|
||||
key,
|
||||
anki_content,
|
||||
excerpt,
|
||||
num_words,
|
||||
max_rank,
|
||||
)
|
||||
|
||||
|
||||
def _detect_source_language(
|
||||
filepath: Path,
|
||||
text: str,
|
||||
) -> str:
|
||||
"""Auto-detect source language from file content.
|
||||
|
||||
Args:
|
||||
filepath: Path to source file.
|
||||
text: Already-read text (may be empty).
|
||||
|
||||
Returns:
|
||||
Detected language code.
|
||||
|
||||
Raises:
|
||||
ValueError: If language cannot be detected.
|
||||
"""
|
||||
sample_text = read_file(filepath)[:1000] if not text else text[:1000]
|
||||
detected = detect_language(sample_text)
|
||||
if detected is None:
|
||||
msg = (
|
||||
"Could not auto-detect source language. "
|
||||
"Please specify with --from (e.g., --from pl for Polish). "
|
||||
"Install langdetect for auto-detection: "
|
||||
"pip install langdetect"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return detected
|
||||
|
||||
|
||||
def generate_flashcards(
|
||||
filepath: str | Path,
|
||||
excerpt_length: int,
|
||||
options: FlashcardOptions | None = None,
|
||||
*,
|
||||
all_vocab: bool = True,
|
||||
) -> tuple[str, str, int, int]:
|
||||
"""Generate Anki flashcards for vocabulary needed for an excerpt.
|
||||
|
||||
Args:
|
||||
filepath: Path to the source text file.
|
||||
excerpt_length: Target excerpt length.
|
||||
options: Flashcard generation options.
|
||||
all_vocab: If True, include ALL words rank 1 to max rank.
|
||||
|
||||
Returns:
|
||||
Tuple of (anki_content, excerpt, num_words, max_rank).
|
||||
"""
|
||||
if options is None:
|
||||
options = FlashcardOptions()
|
||||
filepath = Path(filepath)
|
||||
deck_key = AnkiDeckKey(
|
||||
filepath=filepath,
|
||||
length=excerpt_length,
|
||||
target_lang=options.target_lang,
|
||||
include_context=options.include_context,
|
||||
all_vocab=all_vocab,
|
||||
)
|
||||
|
||||
# Check for cached full deck (if not using no_translate)
|
||||
if not options.no_translate and not options.force:
|
||||
cached = get_cached_deck(deck_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Read the text (only needed for context finding)
|
||||
text = read_file(filepath) if options.include_context else ""
|
||||
|
||||
# Auto-detect language if not provided
|
||||
source_lang = options.source_lang
|
||||
if source_lang is None:
|
||||
source_lang = _detect_source_language(filepath, text)
|
||||
|
||||
# Run vocabulary curve analysis with vocab dump for all words
|
||||
output = run_vocabulary_curve(
|
||||
filepath, excerpt_length, dump_vocab=all_vocab
|
||||
)
|
||||
excerpt, excerpt_words, all_vocab_words = parse_vocabulary_curve_output(
|
||||
output, excerpt_length
|
||||
)
|
||||
|
||||
if not excerpt_words:
|
||||
msg = f"No words found for excerpt length {excerpt_length}"
|
||||
raise ValueError(msg)
|
||||
|
||||
max_rank = max(rank for _, rank in excerpt_words)
|
||||
words_with_ranks = (
|
||||
all_vocab_words if all_vocab and all_vocab_words else excerpt_words
|
||||
)
|
||||
|
||||
contexts = None
|
||||
if options.include_context:
|
||||
if not text:
|
||||
text = read_file(filepath)
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
contexts = find_word_contexts(text, words)
|
||||
|
||||
deck_name = options.deck_name or f"{filepath.stem}_vocab_{excerpt_length}"
|
||||
|
||||
anki_content = generate_anki_deck(
|
||||
DeckInput(
|
||||
words_with_ranks=words_with_ranks,
|
||||
source_lang=source_lang,
|
||||
target_lang=options.target_lang,
|
||||
contexts=contexts,
|
||||
deck_name=deck_name,
|
||||
),
|
||||
include_context=options.include_context,
|
||||
no_translate=options.no_translate,
|
||||
excerpt=excerpt,
|
||||
excerpt_words=excerpt_words,
|
||||
)
|
||||
|
||||
if not options.no_translate:
|
||||
cache_deck(
|
||||
deck_key,
|
||||
anki_content,
|
||||
excerpt,
|
||||
len(words_with_ranks),
|
||||
max_rank,
|
||||
)
|
||||
|
||||
return anki_content, excerpt, len(words_with_ranks), max_rank
|
||||
|
||||
|
||||
def generate_flashcards_inverse(
|
||||
filepath: str | Path,
|
||||
max_vocab: int,
|
||||
options: FlashcardOptions | None = None,
|
||||
) -> tuple[str, str, int, int, int]:
|
||||
"""Generate Anki flashcards for the longest excerpt using top N words.
|
||||
|
||||
This is the inverse mode: given a vocabulary size, find the longest
|
||||
excerpt that can be understood with only those words.
|
||||
|
||||
Args:
|
||||
filepath: Path to the source text file.
|
||||
max_vocab: Maximum vocabulary size (top N words to learn).
|
||||
options: Flashcard generation options.
|
||||
|
||||
Returns:
|
||||
Tuple of (anki_content, excerpt, excerpt_length,
|
||||
num_words, max_rank_used).
|
||||
"""
|
||||
if options is None:
|
||||
options = FlashcardOptions()
|
||||
filepath = Path(filepath)
|
||||
|
||||
text = read_file(filepath) if options.include_context else ""
|
||||
|
||||
source_lang = options.source_lang
|
||||
if source_lang is None:
|
||||
source_lang = _detect_source_language(filepath, text)
|
||||
|
||||
output = run_vocabulary_curve_inverse(
|
||||
filepath, max_vocab, dump_vocab=True
|
||||
)
|
||||
excerpt, excerpt_length, max_rank_used, all_vocab_words = (
|
||||
parse_inverse_mode_output(output)
|
||||
)
|
||||
|
||||
if excerpt_length == 0:
|
||||
msg = (
|
||||
f"No valid excerpt found using only top {max_vocab} "
|
||||
"words. Try increasing the vocabulary limit."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if not all_vocab_words:
|
||||
msg = f"No vocabulary returned for max_vocab={max_vocab}"
|
||||
raise ValueError(msg)
|
||||
|
||||
words_with_ranks = all_vocab_words
|
||||
|
||||
excerpt_word_set = set(excerpt.lower().split())
|
||||
excerpt_words = [
|
||||
(w, r)
|
||||
for w, r in all_vocab_words
|
||||
if w.lower() in excerpt_word_set
|
||||
]
|
||||
|
||||
contexts = None
|
||||
if options.include_context:
|
||||
if not text:
|
||||
text = read_file(filepath)
|
||||
words = [w for w, _ in words_with_ranks]
|
||||
contexts = find_word_contexts(text, words)
|
||||
|
||||
deck_name = options.deck_name or f"{filepath.stem}_top{max_vocab}"
|
||||
|
||||
anki_content = generate_anki_deck(
|
||||
DeckInput(
|
||||
words_with_ranks=words_with_ranks,
|
||||
source_lang=source_lang,
|
||||
target_lang=options.target_lang,
|
||||
contexts=contexts,
|
||||
deck_name=deck_name,
|
||||
),
|
||||
include_context=options.include_context,
|
||||
no_translate=options.no_translate,
|
||||
excerpt=excerpt,
|
||||
excerpt_words=excerpt_words or None,
|
||||
)
|
||||
|
||||
return (
|
||||
anki_content,
|
||||
excerpt,
|
||||
excerpt_length,
|
||||
len(words_with_ranks),
|
||||
max_rank_used,
|
||||
)
|
||||
# Re-export public API from helper modules
|
||||
__all__ = [
|
||||
"C_EXECUTABLE",
|
||||
"DeckInput",
|
||||
"FlashcardOptions",
|
||||
"VocabWord",
|
||||
"cache_deck",
|
||||
"cache_excerpt",
|
||||
"find_word_contexts",
|
||||
"generate_anki_deck",
|
||||
"generate_flashcards",
|
||||
"generate_flashcards_inverse",
|
||||
"get_cached_deck",
|
||||
"get_cached_excerpt",
|
||||
"main",
|
||||
"parse_inverse_mode_output",
|
||||
"parse_vocabulary_curve_output",
|
||||
"run_vocabulary_curve",
|
||||
"run_vocabulary_curve_inverse",
|
||||
]
|
||||
|
||||
|
||||
def _format_cache_size(value: int) -> str:
|
||||
@ -900,8 +173,7 @@ def _handle_inverse_mode(
|
||||
output_path = (
|
||||
Path(args.output)
|
||||
if args.output
|
||||
else filepath.parent
|
||||
/ f"{filepath.stem}_anki_top{args.max_vocab}.txt"
|
||||
else filepath.parent / f"{filepath.stem}_anki_top{args.max_vocab}.txt"
|
||||
)
|
||||
output_path.write_text(anki_content, encoding="utf-8")
|
||||
|
||||
@ -942,9 +214,7 @@ def _handle_normal_mode(
|
||||
"""
|
||||
if not args.quiet:
|
||||
logger.info("Analyzing %s...", filepath.name)
|
||||
logger.info(
|
||||
"Finding vocabulary for %d-word excerpt...", args.length
|
||||
)
|
||||
logger.info("Finding vocabulary for %d-word excerpt...", args.length)
|
||||
|
||||
anki_content, excerpt, num_words, max_rank = generate_flashcards(
|
||||
filepath,
|
||||
@ -972,16 +242,12 @@ def _handle_normal_mode(
|
||||
logger.info("=" * 60)
|
||||
logger.info("FLASHCARD GENERATION COMPLETE")
|
||||
logger.info("=" * 60)
|
||||
logger.info(
|
||||
"Excerpt to understand (%d words):", args.length
|
||||
)
|
||||
logger.info("Excerpt to understand (%d words):", args.length)
|
||||
logger.info(' "%s"', excerpt)
|
||||
logger.info("")
|
||||
logger.info("Max word rank needed: #%d", max_rank)
|
||||
if args.excerpt_words_only:
|
||||
logger.info(
|
||||
"Flashcards: %d (excerpt words only)", num_words
|
||||
)
|
||||
logger.info("Flashcards: %d (excerpt words only)", num_words)
|
||||
else:
|
||||
logger.info(
|
||||
"Flashcards: %d (ALL words rank #1 to #%d)",
|
||||
@ -1020,10 +286,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
"-l",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"Target excerpt length "
|
||||
"(how many words you want to understand)"
|
||||
),
|
||||
help=("Target excerpt length " "(how many words you want to understand)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-vocab",
|
||||
@ -1155,9 +418,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.length is None and args.max_vocab is None:
|
||||
parser.error("Either --length/-l or --max-vocab/-v is required")
|
||||
if args.length is not None and args.max_vocab is not None:
|
||||
parser.error(
|
||||
"Cannot use both --length and --max-vocab. Choose one mode."
|
||||
)
|
||||
parser.error("Cannot use both --length and --max-vocab. Choose one mode.")
|
||||
|
||||
try:
|
||||
return _run_generation(args)
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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())
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
92
python_pkg/word_frequency/tests/_translator_helpers.py
Normal file
92
python_pkg/word_frequency/tests/_translator_helpers.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Shared test helpers for translator tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.word_frequency import translator
|
||||
|
||||
|
||||
class ArgosAvailableMock:
|
||||
"""Context manager to mock argostranslate being available and control its output.
|
||||
|
||||
Works whether argos is installed or not by patching sys.modules.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, translate_returns: str | list[str] | Exception | None = None
|
||||
) -> None:
|
||||
"""Initialize with return values for translate()."""
|
||||
self.translate_returns = translate_returns
|
||||
self.mock_translate_fn = MagicMock()
|
||||
self.mock_translate_module = MagicMock()
|
||||
self.mock_package_module = MagicMock()
|
||||
self.mock_parent = MagicMock()
|
||||
self._sys_modules_patcher: MagicMock | None = None
|
||||
self._ensure_patcher: MagicMock | None = None
|
||||
self._lang_patcher: MagicMock | None = None
|
||||
self._check_argos_patcher: MagicMock | None = None
|
||||
self._argos_module_patcher: MagicMock | None = None
|
||||
|
||||
def __enter__(self) -> MagicMock:
|
||||
"""Set up the mocks."""
|
||||
# Set up translate return value
|
||||
if isinstance(self.translate_returns, (Exception, list)):
|
||||
self.mock_translate_fn.side_effect = self.translate_returns
|
||||
elif self.translate_returns is not None:
|
||||
self.mock_translate_fn.return_value = self.translate_returns
|
||||
|
||||
# Wire up the mock modules
|
||||
self.mock_translate_module.translate = self.mock_translate_fn
|
||||
self.mock_translate_module.get_installed_languages = MagicMock(return_value=[])
|
||||
self.mock_package_module.update_package_index = MagicMock()
|
||||
self.mock_package_module.get_available_packages = MagicMock(return_value=[])
|
||||
self.mock_parent.translate = self.mock_translate_module
|
||||
self.mock_parent.package = self.mock_package_module
|
||||
|
||||
# Patch sys.modules to inject our mock (works even if argos not installed)
|
||||
self._sys_modules_patcher = patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"argostranslate": self.mock_parent,
|
||||
"argostranslate.translate": self.mock_translate_module,
|
||||
"argostranslate.package": self.mock_package_module,
|
||||
},
|
||||
)
|
||||
|
||||
# Patch the module-level argostranslate reference in translator
|
||||
self._argos_module_patcher = patch.object(
|
||||
translator, "argostranslate", self.mock_parent, create=True
|
||||
)
|
||||
|
||||
# Patch _ensure_argos_installed and _ensure_language_pair to no-op
|
||||
self._ensure_patcher = patch.object(
|
||||
translator, "_ensure_argos_installed", lambda: None
|
||||
)
|
||||
self._lang_patcher = patch.object(
|
||||
translator, "_ensure_language_pair", lambda _f, _t: None
|
||||
)
|
||||
self._check_argos_patcher = patch.object(
|
||||
translator, "_check_argos", return_value=True
|
||||
)
|
||||
|
||||
self._sys_modules_patcher.start() # type: ignore[union-attr]
|
||||
self._argos_module_patcher.start() # type: ignore[union-attr]
|
||||
self._ensure_patcher.start() # type: ignore[union-attr]
|
||||
self._lang_patcher.start() # type: ignore[union-attr]
|
||||
self._check_argos_patcher.start() # type: ignore[union-attr]
|
||||
|
||||
return self.mock_translate_fn
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
"""Restore original state."""
|
||||
if self._check_argos_patcher:
|
||||
self._check_argos_patcher.stop()
|
||||
if self._lang_patcher:
|
||||
self._lang_patcher.stop()
|
||||
if self._ensure_patcher:
|
||||
self._ensure_patcher.stop()
|
||||
if self._argos_module_patcher:
|
||||
self._argos_module_patcher.stop()
|
||||
if self._sys_modules_patcher:
|
||||
self._sys_modules_patcher.stop()
|
||||
29
python_pkg/word_frequency/tests/conftest.py
Normal file
29
python_pkg/word_frequency/tests/conftest.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Shared fixtures for translator tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from python_pkg.word_frequency import translator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_argos_unavailable() -> Generator[None, None, None]:
|
||||
"""Mock argostranslate being unavailable (for legacy tests)."""
|
||||
with patch.object(translator, "_check_argos", return_value=False):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_words_file(tmp_path: Path) -> Path:
|
||||
"""Create a temporary file with words."""
|
||||
words_file = tmp_path / "words.txt"
|
||||
words_file.write_text("hello\nworld\ngoodbye\n", encoding="utf-8")
|
||||
return words_file
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
325
python_pkg/word_frequency/tests/test_translator_part2.py
Normal file
325
python_pkg/word_frequency/tests/test_translator_part2.py
Normal file
@ -0,0 +1,325 @@
|
||||
"""Tests for translator module - part 2 (languages, file I/O, CLI, integration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.word_frequency import translator
|
||||
from python_pkg.word_frequency.tests._translator_helpers import ArgosAvailableMock
|
||||
from python_pkg.word_frequency.translator import (
|
||||
download_languages,
|
||||
format_translations,
|
||||
get_available_packages,
|
||||
get_installed_languages,
|
||||
main,
|
||||
read_file,
|
||||
translate_words,
|
||||
)
|
||||
|
||||
# get_installed_languages tests
|
||||
|
||||
|
||||
class TestGetInstalledLanguages:
|
||||
"""Tests for get_installed_languages function."""
|
||||
|
||||
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
|
||||
"""Test when argos is unavailable."""
|
||||
result = get_installed_languages()
|
||||
assert result == []
|
||||
|
||||
def test_returns_languages(self) -> None:
|
||||
"""Test returning installed languages."""
|
||||
mock_lang1 = MagicMock()
|
||||
mock_lang1.code = "en"
|
||||
mock_lang1.name = "English"
|
||||
mock_lang2 = MagicMock()
|
||||
mock_lang2.code = "es"
|
||||
mock_lang2.name = "Spanish"
|
||||
|
||||
# We need to mock the translate module's get_installed_languages
|
||||
mock_translate_module = MagicMock()
|
||||
mock_translate_module.get_installed_languages.return_value = [
|
||||
mock_lang1,
|
||||
mock_lang2,
|
||||
]
|
||||
mock_package_module = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
mock_parent.translate = mock_translate_module
|
||||
mock_parent.package = mock_package_module
|
||||
|
||||
with (
|
||||
patch.object(translator, "_check_argos", return_value=True),
|
||||
patch.object(
|
||||
translator, "argostranslate", mock_parent, create=True
|
||||
),
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"argostranslate": mock_parent,
|
||||
"argostranslate.translate": mock_translate_module,
|
||||
"argostranslate.package": mock_package_module,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = get_installed_languages()
|
||||
|
||||
assert ("en", "English") in result
|
||||
assert ("es", "Spanish") in result
|
||||
|
||||
|
||||
# get_available_packages tests
|
||||
|
||||
|
||||
class TestGetAvailablePackages:
|
||||
"""Tests for get_available_packages function."""
|
||||
|
||||
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
|
||||
"""Test when argos is unavailable."""
|
||||
result = get_available_packages()
|
||||
assert result == []
|
||||
|
||||
|
||||
# download_languages tests
|
||||
|
||||
|
||||
class TestDownloadLanguages:
|
||||
"""Tests for download_languages function."""
|
||||
|
||||
def test_argos_unavailable(self, _mock_argos_unavailable: None) -> None:
|
||||
"""Test when argos is unavailable."""
|
||||
result = download_languages(["en", "es"])
|
||||
assert result == {}
|
||||
|
||||
|
||||
# read_file tests
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
"""Tests for read_file function."""
|
||||
|
||||
def test_read_file(self, tmp_path: Path) -> None:
|
||||
"""Test reading a file."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("hello\nworld", encoding="utf-8")
|
||||
|
||||
content = read_file(test_file)
|
||||
|
||||
assert content == "hello\nworld"
|
||||
|
||||
def test_read_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""Test reading non-existent file."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_file(tmp_path / "nonexistent.txt")
|
||||
|
||||
|
||||
# main function tests
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main CLI function."""
|
||||
|
||||
def test_argos_unavailable_error(self, _mock_argos_unavailable: None) -> None:
|
||||
"""Test error when argos not installed."""
|
||||
result = main(["--text", "hello", "--from", "en", "--to", "es"])
|
||||
assert result == 1
|
||||
|
||||
def test_list_languages_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test listing languages when none installed."""
|
||||
mock_translate_module = MagicMock()
|
||||
mock_translate_module.get_installed_languages.return_value = []
|
||||
mock_package_module = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
mock_parent.translate = mock_translate_module
|
||||
mock_parent.package = mock_package_module
|
||||
|
||||
with (
|
||||
patch.object(translator, "_check_argos", return_value=True),
|
||||
patch.object(
|
||||
translator, "argostranslate", mock_parent, create=True
|
||||
),
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"argostranslate": mock_parent,
|
||||
"argostranslate.translate": mock_translate_module,
|
||||
"argostranslate.package": mock_package_module,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = main(["--list-languages"])
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "No languages installed" in captured.out
|
||||
|
||||
def test_list_languages_with_results(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test listing installed languages."""
|
||||
mock_lang = MagicMock()
|
||||
mock_lang.code = "en"
|
||||
mock_lang.name = "English"
|
||||
|
||||
mock_translate_module = MagicMock()
|
||||
mock_translate_module.get_installed_languages.return_value = [mock_lang]
|
||||
mock_package_module = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
mock_parent.translate = mock_translate_module
|
||||
mock_parent.package = mock_package_module
|
||||
|
||||
with (
|
||||
patch.object(translator, "_check_argos", return_value=True),
|
||||
patch.object(
|
||||
translator, "argostranslate", mock_parent, create=True
|
||||
),
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"argostranslate": mock_parent,
|
||||
"argostranslate.translate": mock_translate_module,
|
||||
"argostranslate.package": mock_package_module,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = main(["--list-languages"])
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "en" in captured.out
|
||||
assert "English" in captured.out
|
||||
|
||||
def test_translate_single_text(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test translating single text."""
|
||||
with ArgosAvailableMock("hola"):
|
||||
result = main(["--text", "hello", "--from", "en", "--to", "es"])
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "hello" in captured.out
|
||||
assert "hola" in captured.out
|
||||
|
||||
def test_translate_multiple_words(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test translating multiple words."""
|
||||
with ArgosAvailableMock(["hola", "mundo"]):
|
||||
result = main(["--words", "hello", "world", "--from", "en", "--to", "es"])
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "hello" in captured.out
|
||||
assert "world" in captured.out
|
||||
|
||||
def test_translate_from_file(
|
||||
self,
|
||||
temp_words_file: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Test translating words from file."""
|
||||
with ArgosAvailableMock(["hola", "mundo", "adios"]):
|
||||
result = main(
|
||||
["--words-file", str(temp_words_file), "--from", "en", "--to", "es"]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "hello" in captured.out
|
||||
assert "world" in captured.out
|
||||
assert "goodbye" in captured.out
|
||||
|
||||
def test_translate_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test error when words file not found."""
|
||||
with ArgosAvailableMock():
|
||||
result = main(
|
||||
["--words-file", "/nonexistent/file.txt", "--from", "en", "--to", "es"]
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "File not found" in captured.err
|
||||
|
||||
def test_translate_output_to_file(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test outputting translations to file."""
|
||||
output_file = tmp_path / "output.txt"
|
||||
|
||||
with ArgosAvailableMock("hola"):
|
||||
result = main(
|
||||
[
|
||||
"--text",
|
||||
"hello",
|
||||
"--from",
|
||||
"en",
|
||||
"--to",
|
||||
"es",
|
||||
"--output",
|
||||
str(output_file),
|
||||
]
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
assert output_file.exists()
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert "hello" in content
|
||||
assert "hola" in content
|
||||
|
||||
def test_no_input_shows_help(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test that no input shows help."""
|
||||
with ArgosAvailableMock():
|
||||
result = main([])
|
||||
|
||||
assert result == 1
|
||||
|
||||
def test_translation_failure_returns_error(self) -> None:
|
||||
"""Test that translation failure returns error code when argos unavailable."""
|
||||
with patch.object(
|
||||
translator,
|
||||
"_ensure_argos_installed",
|
||||
side_effect=ImportError("argostranslate not available"),
|
||||
):
|
||||
result = main(["--text", "hello", "--from", "en", "--to", "es"])
|
||||
assert result == 1
|
||||
|
||||
|
||||
# Integration-style tests (still mocked but testing more flow)
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration-style tests for translator."""
|
||||
|
||||
def test_full_translation_flow(self) -> None:
|
||||
"""Test complete translation flow."""
|
||||
with ArgosAvailableMock(["uno", "dos", "tres"]) as mock:
|
||||
mock.side_effect = ["uno", "dos", "tres"]
|
||||
words = ["one", "two", "three"]
|
||||
results = translate_words(words, "en", "es", use_cache=False)
|
||||
|
||||
assert all(r.success for r in results)
|
||||
assert [r.translated_word for r in results] == ["uno", "dos", "tres"]
|
||||
|
||||
output = format_translations(results)
|
||||
assert "en -> es" in output
|
||||
assert "one" in output
|
||||
assert "uno" in output
|
||||
|
||||
def test_mixed_success_failure(self) -> None:
|
||||
"""Test handling when argos raises exception for some translations."""
|
||||
# Simulate argos translating first word, then failing, then succeeding
|
||||
with ArgosAvailableMock() as mock:
|
||||
mock.side_effect = ["hola", RuntimeError("Unknown"), "mundo"]
|
||||
results = translate_words(
|
||||
["hello", "xyz", "world"], "en", "es", use_cache=False
|
||||
)
|
||||
|
||||
# First and third succeed, second fails
|
||||
assert results[0].success is True
|
||||
assert results[1].success is False
|
||||
assert results[2].success is True
|
||||
|
||||
output = format_translations(results)
|
||||
assert "Error" in output
|
||||
@ -1,75 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Translator - translates words/text between languages.
|
||||
|
||||
This module provides translation capabilities using either:
|
||||
|
||||
1. Argos Translate (offline, requires large downloads)
|
||||
2. deep-translator (online, uses Google Translate)
|
||||
This module provides translation capabilities using Argos Translate (offline).
|
||||
|
||||
Usage::
|
||||
|
||||
# Translate a single word
|
||||
python -m python_pkg.word_frequency.translator \\
|
||||
python -m python_pkg.word_frequency.translator \
|
||||
--text "hello" --from en --to es
|
||||
|
||||
# Translate multiple words
|
||||
python -m python_pkg.word_frequency.translator \\
|
||||
--words hello world goodbye --from en --to pl
|
||||
Dependencies::
|
||||
|
||||
# Translate words from a file (one word per line)
|
||||
python -m python_pkg.word_frequency.translator \\
|
||||
--words-file words.txt --from la --to en
|
||||
|
||||
# List available languages
|
||||
python -m python_pkg.word_frequency.translator \\
|
||||
--list-languages
|
||||
|
||||
# Output to file
|
||||
python -m python_pkg.word_frequency.translator \\
|
||||
--words-file vocab.txt --from pl --to en \\
|
||||
--output translations.txt
|
||||
|
||||
Dependencies (install one)::
|
||||
|
||||
pip install deep-translator
|
||||
pip install argostranslate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
torch = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import argostranslate.package
|
||||
import argostranslate.translate
|
||||
except ImportError:
|
||||
argostranslate = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from deep_translator import GoogleTranslator
|
||||
except ImportError:
|
||||
GoogleTranslator = None
|
||||
|
||||
try:
|
||||
import langdetect
|
||||
except ImportError:
|
||||
langdetect = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from python_pkg.word_frequency.cache import (
|
||||
get_translation_cache,
|
||||
@ -77,123 +34,28 @@ try:
|
||||
except ImportError:
|
||||
get_translation_cache = None
|
||||
|
||||
from python_pkg.word_frequency._translator_cli import main
|
||||
from python_pkg.word_frequency._translator_helpers import (
|
||||
TranslationResult,
|
||||
_check_cuda_available,
|
||||
_ensure_argos_installed,
|
||||
_ensure_language_pair,
|
||||
_init_gpu_if_available,
|
||||
detect_language,
|
||||
format_translations,
|
||||
read_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LANG_DETECT_SAMPLE_SIZE = 5000
|
||||
_BATCH_SIZE = 100
|
||||
|
||||
|
||||
class _TranslatorState:
|
||||
"""Holds module-level state for lazy-initialized backends."""
|
||||
|
||||
gpu_initialized: bool = False
|
||||
|
||||
|
||||
def _check_cuda_available() -> bool:
|
||||
"""Check if CUDA is available for GPU acceleration."""
|
||||
return torch is not None and torch.cuda.is_available()
|
||||
|
||||
|
||||
def _validate_gpu_device() -> str:
|
||||
"""Validate GPU device availability and return device name.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no GPU devices are found.
|
||||
"""
|
||||
device_count = torch.cuda.device_count()
|
||||
if device_count == 0:
|
||||
msg = "CUDA reports available but no GPU devices found"
|
||||
raise RuntimeError(msg)
|
||||
return torch.cuda.get_device_name(0)
|
||||
|
||||
|
||||
def _init_gpu_if_available() -> None:
|
||||
"""Initialize GPU for argostranslate if CUDA is available.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If CUDA is available but GPU init fails.
|
||||
"""
|
||||
if _TranslatorState.gpu_initialized:
|
||||
return
|
||||
|
||||
if not _check_cuda_available():
|
||||
_TranslatorState.gpu_initialized = True
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"CUDA detected, initializing GPU acceleration..."
|
||||
)
|
||||
|
||||
try:
|
||||
device_name = _validate_gpu_device()
|
||||
logger.info(" Using GPU: %s", device_name)
|
||||
|
||||
os.environ["CT2_CUDA_ALLOW_FP16"] = "1"
|
||||
os.environ["CT2_USE_EXPERIMENTAL_PACKED_GEMM"] = "1"
|
||||
|
||||
_TranslatorState.gpu_initialized = True
|
||||
logger.info(" GPU acceleration enabled.")
|
||||
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"CUDA is available but GPU initialization failed: "
|
||||
f"{e}\nThis may be due to incompatible CUDA "
|
||||
"version or driver issues.\n"
|
||||
"To disable GPU and use CPU only, set "
|
||||
"environment variable: CT2_FORCE_CPU=1"
|
||||
)
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
|
||||
def _check_argos() -> bool:
|
||||
"""Check if argostranslate is available."""
|
||||
return argostranslate is not None
|
||||
|
||||
|
||||
def _check_deep_translator() -> bool:
|
||||
"""Check if deep-translator is available."""
|
||||
return GoogleTranslator is not None
|
||||
|
||||
|
||||
def _check_langdetect() -> bool:
|
||||
"""Check if langdetect is available."""
|
||||
return langdetect is not None
|
||||
|
||||
|
||||
def detect_language(text: str) -> str | None:
|
||||
"""Detect the language of a text.
|
||||
|
||||
Args:
|
||||
text: The text to analyze.
|
||||
|
||||
Returns:
|
||||
ISO 639-1 language code (e.g., 'en', 'la', 'pl') or None if detection fails.
|
||||
"""
|
||||
if not _check_langdetect():
|
||||
return None
|
||||
|
||||
try:
|
||||
sample = (
|
||||
text[:_LANG_DETECT_SAMPLE_SIZE]
|
||||
if len(text) > _LANG_DETECT_SAMPLE_SIZE
|
||||
else text
|
||||
)
|
||||
return langdetect.detect(sample) # type: ignore[no-any-return,union-attr]
|
||||
except langdetect.LangDetectException: # type: ignore[attr-defined,union-attr]
|
||||
return None
|
||||
|
||||
|
||||
class TranslationResult(NamedTuple):
|
||||
"""Result of a translation."""
|
||||
|
||||
source_word: str
|
||||
translated_word: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def get_installed_languages() -> list[tuple[str, str]]:
|
||||
"""Get list of installed languages.
|
||||
|
||||
@ -291,119 +153,6 @@ def download_languages(lang_codes: Sequence[str]) -> dict[str, bool]:
|
||||
return results
|
||||
|
||||
|
||||
def _ensure_argos_installed() -> None:
|
||||
"""Ensure argostranslate is installed, attempt installation if not.
|
||||
|
||||
Raises:
|
||||
ImportError: If argos cannot be installed.
|
||||
"""
|
||||
if _check_argos():
|
||||
return
|
||||
|
||||
logger.info("argostranslate not found. Attempting to install...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "argostranslate"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Attempt runtime re-import
|
||||
importlib.import_module("argostranslate.package")
|
||||
importlib.import_module("argostranslate.translate")
|
||||
logger.info("argostranslate installed successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = e.stderr.decode() if e.stderr else str(e)
|
||||
msg = (
|
||||
"argostranslate is required for offline "
|
||||
"translation.\n\n"
|
||||
"Install manually with one of:\n"
|
||||
" pip install argostranslate"
|
||||
" # In a virtualenv\n"
|
||||
" pipx install argostranslate"
|
||||
" # System-wide via pipx\n"
|
||||
" pacman -S python-argostranslate"
|
||||
" # Arch Linux (if available)\n\n"
|
||||
f"Original error: {error_msg}"
|
||||
)
|
||||
raise ImportError(msg) from e
|
||||
except ImportError:
|
||||
msg = (
|
||||
"argostranslate installation succeeded but "
|
||||
"import failed"
|
||||
)
|
||||
raise ImportError(msg) from None
|
||||
|
||||
|
||||
def _ensure_language_pair(from_lang: str, to_lang: str) -> None:
|
||||
"""Ensure the language pair is available, download if needed.
|
||||
|
||||
Args:
|
||||
from_lang: Source language code.
|
||||
to_lang: Target language code.
|
||||
|
||||
Raises:
|
||||
ValueError: If language pair cannot be obtained.
|
||||
"""
|
||||
installed_languages = (
|
||||
argostranslate.translate.get_installed_languages()
|
||||
)
|
||||
from_lang_obj = None
|
||||
to_lang_obj = None
|
||||
|
||||
for lang in installed_languages:
|
||||
if lang.code == from_lang:
|
||||
from_lang_obj = lang
|
||||
if lang.code == to_lang:
|
||||
to_lang_obj = lang
|
||||
|
||||
if from_lang_obj and to_lang_obj:
|
||||
# Check if translation is available
|
||||
translation = from_lang_obj.get_translation(to_lang_obj)
|
||||
if translation:
|
||||
return # Already available
|
||||
|
||||
# Need to download
|
||||
logger.info(
|
||||
"Downloading language pack: %s -> %s...",
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
logger.info(" Fetching package index...")
|
||||
argostranslate.package.update_package_index()
|
||||
available = argostranslate.package.get_available_packages()
|
||||
|
||||
pkg = next(
|
||||
(
|
||||
p
|
||||
for p in available
|
||||
if p.from_code == from_lang and p.to_code == to_lang
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if pkg is None:
|
||||
msg = (
|
||||
f"No language pack available for "
|
||||
f"{from_lang} -> {to_lang}. "
|
||||
"Available pairs can be listed with "
|
||||
"--list-languages."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
logger.info(
|
||||
" Downloading package (~50-100MB, "
|
||||
"this may take a minute)...",
|
||||
)
|
||||
download_path = pkg.download()
|
||||
logger.info(" Installing language pack...")
|
||||
argostranslate.package.install_from_path(download_path)
|
||||
logger.info(
|
||||
"Language pack %s -> %s installed.",
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
|
||||
|
||||
def translate_word(
|
||||
word: str,
|
||||
from_lang: str,
|
||||
@ -443,12 +192,17 @@ def translate_word(
|
||||
|
||||
try:
|
||||
translated = argostranslate.translate.translate(
|
||||
word, from_lang, to_lang,
|
||||
word,
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
# Cache the result
|
||||
if use_cache and get_translation_cache is not None:
|
||||
get_translation_cache().set(
|
||||
word, from_lang, to_lang, translated,
|
||||
word,
|
||||
from_lang,
|
||||
to_lang,
|
||||
translated,
|
||||
)
|
||||
return TranslationResult(
|
||||
source_word=word,
|
||||
@ -551,9 +305,7 @@ def _run_batch_translation(
|
||||
new_translations: dict[str, str] = {}
|
||||
num_to_translate = len(words_to_translate)
|
||||
|
||||
gpu_status = (
|
||||
" (GPU)" if _check_cuda_available() else " (CPU)"
|
||||
)
|
||||
gpu_status = " (GPU)" if _check_cuda_available() else " (CPU)"
|
||||
logger.info(
|
||||
"Translating %d words from %s to %s%s...",
|
||||
num_to_translate,
|
||||
@ -577,8 +329,7 @@ def _run_batch_translation(
|
||||
pct = int(words_done / num_to_translate * 100)
|
||||
|
||||
logger.info(
|
||||
" [%3d%%] Translating batch %d/%d "
|
||||
"(%d/%d words)...",
|
||||
" [%3d%%] Translating batch %d/%d " "(%d/%d words)...",
|
||||
pct,
|
||||
batch_idx + 1,
|
||||
total_batches,
|
||||
@ -587,16 +338,16 @@ def _run_batch_translation(
|
||||
)
|
||||
|
||||
_, batch_translations = _translate_batch_worker(
|
||||
batch_words, from_lang, to_lang, batch_idx,
|
||||
batch_words,
|
||||
from_lang,
|
||||
to_lang,
|
||||
batch_idx,
|
||||
)
|
||||
new_translations.update(batch_translations)
|
||||
|
||||
logger.info(" Translation complete.")
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"Translation failed for "
|
||||
f"{from_lang} -> {to_lang}: {e}"
|
||||
)
|
||||
msg = f"Translation failed for " f"{from_lang} -> {to_lang}: {e}"
|
||||
raise RuntimeError(msg) from e
|
||||
|
||||
return new_translations
|
||||
@ -639,26 +390,29 @@ def translate_words_batch(
|
||||
if use_cache and get_translation_cache is not None:
|
||||
cache = get_translation_cache()
|
||||
cached_results = cache.get_many(
|
||||
list(words), from_lang, to_lang,
|
||||
list(words),
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
|
||||
# Find words that still need translation
|
||||
words_to_translate = [
|
||||
word for word in words
|
||||
if word.lower() not in cached_results
|
||||
]
|
||||
words_to_translate = [word for word in words if word.lower() not in cached_results]
|
||||
|
||||
# Translate uncached words using argos batch
|
||||
new_translations: dict[str, str] = {}
|
||||
if words_to_translate:
|
||||
new_translations = _run_batch_translation(
|
||||
words_to_translate, from_lang, to_lang,
|
||||
words_to_translate,
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
|
||||
# Cache new translations
|
||||
if use_cache and get_translation_cache is not None:
|
||||
get_translation_cache().set_many(
|
||||
new_translations, from_lang, to_lang,
|
||||
new_translations,
|
||||
from_lang,
|
||||
to_lang,
|
||||
)
|
||||
|
||||
# Merge cached and new translations
|
||||
@ -682,270 +436,7 @@ def translate_words_batch(
|
||||
return results
|
||||
|
||||
|
||||
def format_translations(
|
||||
results: list[TranslationResult],
|
||||
*,
|
||||
show_errors: bool = True,
|
||||
) -> str:
|
||||
"""Format translation results as a table.
|
||||
|
||||
Args:
|
||||
results: List of TranslationResult to format.
|
||||
show_errors: If True, show error messages for failed translations.
|
||||
|
||||
Returns:
|
||||
Formatted string with translations.
|
||||
"""
|
||||
if not results:
|
||||
return "No translations."
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# Find max widths
|
||||
max_source = max(len(r.source_word) for r in results)
|
||||
max_source = max(max_source, 6) # "Source" header
|
||||
|
||||
successful_lengths = [len(r.translated_word) for r in results if r.success]
|
||||
max_trans = max(successful_lengths) if successful_lengths else 0
|
||||
max_trans = max(max_trans, 11) # "Translation" header minimum
|
||||
|
||||
# Header
|
||||
from_lang = results[0].source_lang
|
||||
to_lang = results[0].target_lang
|
||||
lines.append(f"Translation: {from_lang} -> {to_lang}")
|
||||
lines.append("")
|
||||
lines.append(f"{'Source':<{max_source}} {'Translation':<{max_trans}}")
|
||||
lines.append("-" * (max_source + max_trans + 2))
|
||||
|
||||
# Data
|
||||
for r in results:
|
||||
if r.success:
|
||||
lines.append(
|
||||
f"{r.source_word:<{max_source}} {r.translated_word:<{max_trans}}"
|
||||
)
|
||||
elif show_errors:
|
||||
error_msg = f"[Error: {r.error}]" if r.error else "[Failed]"
|
||||
lines.append(f"{r.source_word:<{max_source}} {error_msg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_file(filepath: str | Path) -> str:
|
||||
"""Read text content from a file."""
|
||||
return Path(filepath).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for the translator CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Offline translator using Argos Translate.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
action_group = parser.add_mutually_exclusive_group()
|
||||
action_group.add_argument(
|
||||
"--list-languages",
|
||||
"-l",
|
||||
action="store_true",
|
||||
help="List installed languages",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--list-available",
|
||||
"-L",
|
||||
action="store_true",
|
||||
help="List available language packages for download",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--download",
|
||||
"-d",
|
||||
nargs="+",
|
||||
metavar="LANG",
|
||||
help=(
|
||||
"Download language packs "
|
||||
"(e.g., --download en es pl)"
|
||||
),
|
||||
)
|
||||
|
||||
input_group = parser.add_mutually_exclusive_group()
|
||||
input_group.add_argument(
|
||||
"--text",
|
||||
"-t",
|
||||
type=str,
|
||||
help="Single text/word to translate",
|
||||
)
|
||||
input_group.add_argument(
|
||||
"--words",
|
||||
"-w",
|
||||
nargs="+",
|
||||
help="Words to translate",
|
||||
)
|
||||
input_group.add_argument(
|
||||
"--words-file",
|
||||
"-W",
|
||||
type=str,
|
||||
help="File with words to translate (one per line)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--from",
|
||||
"-f",
|
||||
dest="from_lang",
|
||||
type=str,
|
||||
default="en",
|
||||
help="Source language code (default: en)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--to",
|
||||
"-T",
|
||||
dest="to_lang",
|
||||
type=str,
|
||||
default="en",
|
||||
help="Target language code (default: en)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
help="Output file path",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _handle_list_languages() -> int:
|
||||
"""Handle --list-languages command."""
|
||||
langs = get_installed_languages()
|
||||
if not langs:
|
||||
sys.stdout.write("No languages installed.\n")
|
||||
sys.stdout.write(
|
||||
"Download some with: --download en es pl de fr\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write("Installed languages:\n")
|
||||
for code, name in sorted(langs):
|
||||
sys.stdout.write(f" {code}: {name}\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_list_available() -> int:
|
||||
"""Handle --list-available command."""
|
||||
packages = get_available_packages()
|
||||
if not packages:
|
||||
sys.stdout.write(
|
||||
"No packages available "
|
||||
"(check internet connection).\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write("Available language packages:\n")
|
||||
for from_code, from_name, to_code, to_name in sorted(
|
||||
packages,
|
||||
):
|
||||
sys.stdout.write(
|
||||
f" {from_code} ({from_name})"
|
||||
f" -> {to_code} ({to_name})\n",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_download(lang_codes: list[str]) -> int:
|
||||
"""Handle --download command."""
|
||||
download_results = download_languages(lang_codes)
|
||||
success_count = sum(
|
||||
1 for v in download_results.values() if v
|
||||
)
|
||||
sys.stdout.write(
|
||||
f"\nDownloaded {success_count}/"
|
||||
f"{len(download_results)} language pairs.\n",
|
||||
)
|
||||
return 0 if success_count > 0 else 1
|
||||
|
||||
|
||||
def _collect_words(
|
||||
args: argparse.Namespace,
|
||||
) -> list[str] | None:
|
||||
"""Collect words from args. Returns None on error."""
|
||||
if args.text:
|
||||
return [args.text]
|
||||
if args.words:
|
||||
return args.words
|
||||
if args.words_file:
|
||||
try:
|
||||
content = read_file(args.words_file)
|
||||
except FileNotFoundError:
|
||||
sys.stderr.write(
|
||||
f"Error: File not found: {args.words_file}\n",
|
||||
)
|
||||
return None
|
||||
return [
|
||||
w.strip()
|
||||
for w in content.splitlines()
|
||||
if w.strip()
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _handle_translation(args: argparse.Namespace) -> int:
|
||||
"""Handle the translation action."""
|
||||
try:
|
||||
results = translate_words_batch(
|
||||
args.words, args.from_lang, args.to_lang,
|
||||
)
|
||||
except ImportError:
|
||||
logger.exception("Translation import error")
|
||||
return 1
|
||||
|
||||
output = format_translations(results)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output, encoding="utf-8")
|
||||
sys.stdout.write(
|
||||
f"Translations written to {args.output}\n",
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(output + "\n")
|
||||
|
||||
if any(not r.success for r in results):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Main entry point for the translator.
|
||||
|
||||
Args:
|
||||
argv: Command line arguments.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not _check_argos():
|
||||
sys.stderr.write(
|
||||
"Error: argostranslate is not installed.\n"
|
||||
"Install it with: pip install argostranslate\n",
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.list_languages:
|
||||
return _handle_list_languages()
|
||||
if args.list_available:
|
||||
return _handle_list_available()
|
||||
if args.download:
|
||||
return _handle_download(args.download)
|
||||
|
||||
words = _collect_words(args)
|
||||
if not words:
|
||||
if words is not None:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
args.words = words
|
||||
return _handle_translation(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user